diff --git a/.gitignore b/.gitignore index 221eb3b3..cb6cc5a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target/ **/*.rs.bk .crush +.grepai/ diff --git a/Cargo.lock b/Cargo.lock index 956033f4..cf5ecba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,61 +39,32 @@ dependencies = [ ] [[package]] -name = "anstream" -version = "0.6.19" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "anstyle" -version = "1.0.11" +name = "ambient-authority" +version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" [[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.3" +name = "anstyle" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" -dependencies = [ - "windows-sys 0.59.0", -] +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] -name = "anstyle-wincon" -version = "3.0.9" +name = "arc-swap" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.59.0", + "rustversion", ] -[[package]] -name = "anyhow" -version = "1.0.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" - [[package]] name = "async-stream" version = "0.3.6" @@ -113,7 +84,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -124,7 +95,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -183,6 +154,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bincode" version = "2.0.1" @@ -212,7 +192,7 @@ dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools", "lazy_static", "lazycell", "log", @@ -220,9 +200,9 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", - "syn", + "syn 2.0.104", "which", ] @@ -248,13 +228,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" [[package]] -name = "bstr" -version = "1.12.0" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "memchr", - "serde", + "generic-array", ] [[package]] @@ -263,18 +242,49 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - [[package]] name = "bytes" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.0.8", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "camino", + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.0.8", +] + [[package]] name = "cc" version = "1.2.30" @@ -312,47 +322,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "clap" -version = "4.5.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", - "terminal_size", -] - -[[package]] -name = "clap_derive" -version = "4.5.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" - [[package]] name = "cmake" version = "0.1.54" @@ -363,22 +332,18 @@ dependencies = [ ] [[package]] -name = "colorchoice" -version = "1.0.4" +name = "convert_case" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" [[package]] -name = "console" -version = "0.15.11" +name = "convert_case" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", + "unicode-segmentation", ] [[package]] @@ -398,13 +363,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "crossbeam-deque" -version = "0.8.6" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", + "libc", ] [[package]] @@ -423,62 +387,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "cucumber" -version = "0.21.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cd12917efc3a8b069a4975ef3cb2f2d835d42d04b3814d90838488f9dd9bf69" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "anyhow", - "clap", - "console", - "cucumber-codegen", - "cucumber-expressions", - "derive_more 0.99.20", - "drain_filter_polyfill", - "either", - "futures", - "gherkin", - "globwalk", - "humantime", - "inventory", - "itertools 0.13.0", - "lazy-regex", - "linked-hash-map", - "once_cell", - "pin-project", - "regex", - "sealed", - "smart-default", + "generic-array", + "typenum", ] [[package]] -name = "cucumber-codegen" -version = "0.21.1" +name = "ctor" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e19cd9e8e7cfd79fbf844eb6a7334117973c01f6bad35571262b00891e60f1c" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ - "cucumber-expressions", - "inflections", - "itertools 0.13.0", - "proc-macro2", "quote", - "regex", - "syn", - "synthez", -] - -[[package]] -name = "cucumber-expressions" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d794fed319eea24246fb5f57632f7ae38d61195817b7eb659455aa5bdd7c1810" -dependencies = [ - "derive_more 0.99.20", - "either", - "nom", - "nom_locate", - "regex", - "regex-syntax 0.7.5", + "syn 2.0.104", ] [[package]] @@ -501,9 +426,11 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ + "convert_case 0.4.0", "proc-macro2", "quote", - "syn", + "rustc_version", + "syn 2.0.104", ] [[package]] @@ -523,21 +450,36 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", "unicode-xid", ] [[package]] -name = "downcast" -version = "0.11.0" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] [[package]] -name = "drain_filter_polyfill" -version = "0.1.3" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" [[package]] name = "dunce" @@ -551,12 +493,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "endian-type" version = "0.1.2" @@ -585,6 +521,60 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml", +] + +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 2.1.1", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.16", +] + [[package]] name = "fnv" version = "1.0.7" @@ -597,12 +587,29 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "fragile" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.0.8", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -665,7 +672,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -718,6 +725,16 @@ dependencies = [ "windows", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -747,12 +764,12 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20b79820c0df536d1f3a089a2fa958f61cb96ce9e0f3f8f507f5a31179567755" dependencies = [ - "heck 0.4.1", + "heck", "peg", "quote", "serde", "serde_json", - "syn", + "syn 2.0.104", "textwrap", "thiserror 1.0.69", "typed-builder", @@ -770,30 +787,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" -[[package]] -name = "globset" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax 0.8.5", -] - -[[package]] -name = "globwalk" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" -dependencies = [ - "bitflags", - "ignore", - "walkdir", -] - [[package]] name = "h2" version = "0.4.11" @@ -825,20 +818,25 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] -name = "heck" -version = "0.4.1" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" -version = "0.5.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "home" @@ -895,12 +893,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" - [[package]] name = "hyper" version = "1.6.0" @@ -961,19 +953,50 @@ dependencies = [ ] [[package]] -name = "ignore" -version = "0.4.23" +name = "i18n-config" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" dependencies = [ - "crossbeam-deque", - "globset", + "basic-toml", "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "sys-locale", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.104", ] [[package]] @@ -987,10 +1010,23 @@ dependencies = [ ] [[package]] -name = "inflections" -version = "1.1.1" +name = "intl-memoizer" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] [[package]] name = "inventory" @@ -1001,6 +1037,22 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + [[package]] name = "io-uring" version = "0.7.8" @@ -1018,12 +1070,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" -[[package]] -name = "is_terminal_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" - [[package]] name = "itertools" version = "0.12.1" @@ -1033,15 +1079,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.15" @@ -1068,29 +1105,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy-regex" -version = "3.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60c7310b93682b36b98fa7ea4de998d3463ccbebd94d935d6b48ba5b6ffa7126" -dependencies = [ - "lazy-regex-proc_macros", - "once_cell", - "regex", -] - -[[package]] -name = "lazy-regex-proc_macros" -version = "3.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba01db5ef81e17eb10a5e0f2109d1b3a3e29bac3070fdbd7d156bf7dbd206a1" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "syn", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1130,12 +1144,6 @@ dependencies = [ "windows-targets 0.53.2", ] -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1199,6 +1207,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.7.5" @@ -1305,9 +1319,15 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] +[[package]] +name = "newt-hype" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8b7b69b0eafaa88ec8dc9fe7c3860af0a147517e5207cfbd0ecd21cd7cde18" + [[package]] name = "nibble_vec" version = "0.1.0" @@ -1327,17 +1347,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom_locate" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" -dependencies = [ - "bytecount", - "memchr", - "nom", -] - [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -1371,12 +1380,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "once_cell_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" - [[package]] name = "openssl-probe" version = "0.1.6" @@ -1442,26 +1445,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1522,7 +1505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.104", ] [[package]] @@ -1534,6 +1517,36 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1557,7 +1570,7 @@ dependencies = [ "rand", "rand_chacha", "rand_xorshift", - "regex-syntax 0.8.5", + "regex-syntax", "rusty-fork", "tempfile", "unarray", @@ -1676,33 +1689,27 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] -[[package]] -name = "regex-syntax" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" - [[package]] name = "regex-syntax" version = "0.8.5" @@ -1752,6 +1759,71 @@ dependencies = [ "rstest_macros 0.26.1", ] +[[package]] +name = "rstest-bdd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e741d97bce6ea0a7d0f074716041e0d0eebe6ab9edcd243e7d12f9fd2b5e8b6" +dependencies = [ + "ctor", + "derive_more 0.99.20", + "fluent", + "gherkin", + "hashbrown 0.16.1", + "i18n-embed", + "inventory", + "log", + "regex", + "rstest-bdd-patterns", + "rstest-bdd-policy", + "rust-embed", + "serde", + "serde_json", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "rstest-bdd-macros" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e3b51c032a6174f82d87843a5e62cfd08ce4606005eafde07ed12561d73196" +dependencies = [ + "camino", + "cap-std", + "cfg-if", + "convert_case 0.6.0", + "gherkin", + "newt-hype", + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "rstest-bdd-patterns", + "rstest-bdd-policy", + "syn 2.0.104", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "rstest-bdd-patterns" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75d730afd8727e5b18bd276ebf5ea4c881760138762d0cd7d415dc6b6662c6c6" +dependencies = [ + "gherkin", + "regex", + "thiserror 1.0.69", +] + +[[package]] +name = "rstest-bdd-policy" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d256efeb01f08e281cef9cd1e54dab33d8778e871090f5fa49b7a9a70f0caf81" + [[package]] name = "rstest_macros" version = "0.18.2" @@ -1765,7 +1837,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn", + "syn 2.0.104", "unicode-ident", ] @@ -1783,10 +1855,44 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn", + "syn 2.0.104", "unicode-ident", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.104", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.25" @@ -1799,6 +1905,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1834,6 +1946,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.0.8", +] + [[package]] name = "rustls" version = "0.23.29" @@ -1950,18 +2072,6 @@ version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" -[[package]] -name = "sealed" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a8caec23b7800fb97971a1c6ae365b6239aaeddfb934d6265f8505e795699d" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "security-framework" version = "3.2.0" @@ -1985,6 +2095,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.26" @@ -1993,22 +2109,32 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2045,7 +2171,18 @@ checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -2090,17 +2227,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smart-default" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "smawk" version = "0.3.2" @@ -2133,12 +2259,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" @@ -2147,46 +2267,32 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.104" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] [[package]] -name = "synthez" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d2c2202510a1e186e63e596d9318c91a8cbe85cd1a56a7be0c333e5f59ec8d" -dependencies = [ - "syn", - "synthez-codegen", - "synthez-core", -] - -[[package]] -name = "synthez-codegen" -version = "0.3.1" +name = "syn" +version = "2.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f724aa6d44b7162f3158a57bccd871a77b39a4aef737e01bcdff41f4772c7746" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" dependencies = [ - "syn", - "synthez-core", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "synthez-core" -version = "0.3.1" +name = "sys-locale" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bfa6ec52465e2425fd43ce5bbbe0f0b623964f7c63feb6b10980e816c654ea" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ - "proc-macro2", - "quote", - "sealed", - "syn", + "libc", ] [[package]] @@ -2202,16 +2308,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "terminal_size" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" -dependencies = [ - "rustix 1.0.8", - "windows-sys 0.59.0", -] - [[package]] name = "termtree" version = "0.5.1" @@ -2255,7 +2351,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2266,7 +2362,7 @@ checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2278,6 +2374,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + [[package]] name = "tokio" version = "1.47.1" @@ -2305,7 +2412,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2343,6 +2450,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -2386,7 +2502,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2446,7 +2562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2455,6 +2571,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.1", +] + [[package]] name = "typed-builder" version = "0.15.2" @@ -2472,15 +2597,65 @@ checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unarray" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn 2.0.104", + "unic-langid-impl", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -2493,6 +2668,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.1" @@ -2517,12 +2698,6 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "valuable" version = "0.1.1" @@ -2611,7 +2786,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.104", "wasm-bindgen-shared", ] @@ -2633,7 +2808,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2754,7 +2929,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2765,7 +2940,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2975,6 +3150,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + [[package]] name = "wireframe" version = "0.2.0" @@ -2983,7 +3168,6 @@ dependencies = [ "async-trait", "bincode", "bytes", - "cucumber", "dashmap", "derive_more 2.0.1", "futures", @@ -2998,6 +3182,8 @@ dependencies = [ "mockall", "proptest", "rstest 0.26.1", + "rstest-bdd", + "rstest-bdd-macros", "serde", "serial_test", "socket2 0.6.0", @@ -3055,11 +3241,27 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "serde", + "zerofrom", +] diff --git a/Cargo.toml b/Cargo.toml index 7d889699..6b778522 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,8 @@ socket2 = "0.6.0" [dev-dependencies] rstest = "0.26.1" +rstest-bdd = "0.4.0" +rstest-bdd-macros = { version = "0.4.0", features = ["strict-compile-time-validation"] } wireframe = { path = ".", features = ["test-helpers"] } wireframe_testing = { path = "./wireframe_testing" } logtest = "2.0.0" @@ -56,8 +58,6 @@ proptest = "1.7.0" loom = "0.7.2" async-stream = "0.3.6" serial_test = "3.2.0" -# Permit compatible bug fixes but block breaking updates -cucumber = "0.21.1" metrics-util = "0.20.0" tracing-test = "0.2.5" mockall = "0.13.1" @@ -84,7 +84,6 @@ metrics = ["dep:metrics", "dep:metrics-exporter-prometheus"] serializer-bincode = [] advanced-tests = [] examples = [] -cucumber-tests = [] test-support = [] test-helpers = [] @@ -179,12 +178,11 @@ name = "resp_codec" path = "examples/resp_codec.rs" required-features = ["examples"] -# The Cucumber test runner defines its own async main function, -# so the standard test harness must be disabled. +# rstest-bdd behavioural tests use standard test harness [[test]] -name = "cucumber" -harness = false -required-features = ["advanced-tests", "cucumber-tests"] +name = "bdd" +path = "tests/bdd/mod.rs" +required-features = ["advanced-tests"] [[test]] name = "concurrency_loom" diff --git a/Makefile b/Makefile index bfde1b59..c252820b 100644 --- a/Makefile +++ b/Makefile @@ -8,15 +8,18 @@ RUSTDOC_FLAGS ?= --cfg docsrs -D warnings MDLINT ?= markdownlint-cli2 NIXIE ?= nixie -build: target/debug/lib$(CRATE) ## Build debug binary -release: target/release/lib$(CRATE) ## Build release binary +build: target/debug/lib$(CRATE).rlib ## Build debug binary +release: target/release/lib$(CRATE).rlib ## Build release binary all: release ## Default target builds release binary clean: ## Remove build artifacts $(CARGO) clean -test: ## Run tests with warnings treated as errors +test-bdd: ## Run rstest-bdd tests only + RUSTFLAGS="-D warnings" $(CARGO) test --test bdd --all-features $(BUILD_JOBS) + +test: ## Run all tests (bdd + unit/integration) RUSTFLAGS="-D warnings" $(CARGO) test --all-targets --all-features $(BUILD_JOBS) # will match target/debug/libmy_library.rlib and target/release/libmy_library.rlib diff --git a/docs/behavioural-testing-in-rust-with-cucumber.md b/docs/behavioural-testing-in-rust-with-cucumber.md index 6724e087..2c6ef696 100644 --- a/docs/behavioural-testing-in-rust-with-cucumber.md +++ b/docs/behavioural-testing-in-rust-with-cucumber.md @@ -1,5 +1,9 @@ # A Developer's Guide to Behavioural Testing in Rust with Cucumber +Note: This guide is retained for historical context. The project removed +Cucumber-based tests on 2026-01-25 in favour of rstest-bdd; use +`docs/rstest-bdd-users-guide.md` for current guidance. + ## Part 1: The Philosophy and Practice of Behaviour-Driven Development (BDD) Behaviour-Driven Development (BDD) is a software development process that diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md new file mode 100644 index 00000000..b175c788 --- /dev/null +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -0,0 +1,687 @@ +# Migration Plan: Cucumber to rstest-bdd v0.4.0 + +**Branch**: `migrate-from-cucumber-to-rstest-bdd` + +**Duration**: 9 weeks (phased incremental migration) + +**Status**: Complete + +**Last Updated**: 2026-01-27 + +> [!IMPORTANT] +> Migration is complete. The Cucumber runner, worlds, and steps were removed +> on 2026-01-25. Any remaining Cucumber references are historical context only. + +## Executive Summary + +Migrate Wireframe's 14 Cucumber-based BDD test suites (~3,941 lines of world +code, ~1,330 lines of steps, 60+ scenarios) to rstest-bdd v0.4.0. The migration +leverages rstest-bdd's async scenario support where steps are fully +synchronous, while maintaining test coverage through parallel execution during +migration. + +**Key Strategy**: Use async scenarios (`tokio` current-thread) for scenarios +with synchronous steps, and use per-step runtimes for async world methods until +rstest-bdd supports async steps. + +## Current State Analysis + +### Infrastructure Inventory + +- **14 World structs** across 15+ files (~3,941 lines total) +- **14 .feature files** with 60+ scenarios +- **~1,330 lines** of async step definitions +- **100% async steps** (Cucumber framework requirement) +- **Complex async operations**: TCP servers, client connections, actor + processing, timeout handling + +### World Complexity Classification + +#### Tier 1 – Simple (115–200 lines) + +- `CorrelationWorld` (115 lines): Simple state + 2 async methods +- `RequestPartsWorld` (~150 lines): Basic state validation + +#### Tier 2 – Medium (200–400 lines) + +- `PanicWorld`, `MultiPacketWorld`, `StreamEndWorld` + `MessageAssemblerWorld`, `CodecStatefulWorld` + +#### Tier 3 – High Complexity (400+ lines) + +- `ClientMessagingWorld` (302 lines): Server spawning, client + connections, envelope handling +- `ClientLifecycleWorld`, `ClientPreambleWorld` (~400 lines): Lifecycle + hooks, callbacks +- `MessageAssemblyWorld`, `CodecErrorWorld`, `FragmentWorld` + (multi-file, 11 scenarios) + +## Implementation Big Picture + +### Async Handling Model + +rstest-bdd v0.4.0 supports **async scenarios** with **sync step definitions**, +using Tokio's current-thread runtime: + +```rust +// Scenario function is async +#[scenario(path = "tests/features/client_messaging.feature", + name = "Client sends envelope")] +#[tokio::test(flavor = "current_thread")] +async fn client_sends_envelope_scenario(world: ClientMessagingWorld) { + let _ = world; +} + +// Steps remain sync (no await inside steps) +#[when("the client sends the envelope")] +fn when_client_sends_envelope(world: &mut ClientMessagingWorld) { + world.mark_envelope_sent(); +} +``` + +**Important limitations (from the user guide)**: + +- Steps are synchronous; async step bodies are not supported yet. +- Current-thread Tokio runtime is required to avoid `Send` bounds on fixtures. + +**Practical rule for this codebase**: + +- For worlds with async methods, keep scenarios **sync** and run those methods + inside a dedicated runtime per step (`Runtime::new().block_on(…)`). +- For worlds with purely synchronous steps, prefer async scenarios, so the test + body can `await` any extra async assertions or cleanup logic. + +### World-to-Fixture Conversion + +**Use `&mut Fixture` when**: + +- Simple owned fields mutated directly +- Complex objects with Drop semantics +- Direct ownership desired + +**Use `Slot` when**: + +- Optional state populated conditionally +- Late-bound values (set during test) +- State reset between steps needed +- Mix of required + optional state + +**Example Pattern**: + +```rust +use rstest_bdd::{Slot, ScenarioState}; +use rstest_bdd_macros::ScenarioState; + +#[derive(Debug, ScenarioState)] +pub struct ClientMessagingWorld { + // Slots for optional/late-bound state + addr: Slot, + server: Slot>, + client: Slot>, + envelope: Slot, + + // Direct fields for always-present state + sent_correlation_ids: Vec, + + // Slots for conditional outcomes + response: Slot, + last_error: Slot, +} + +#[fixture] +fn client_messaging_world() -> ClientMessagingWorld { + // ScenarioState auto-derives Default + ClientMessagingWorld::default() +} +``` + +### Feature File Changes + +Feature files remain compatible with Cucumber. Minor wording tweaks may be +required when duplicate step phrases appear across worlds (for example, +disambiguating client preamble step text to avoid ambiguous step definitions). + +## Phase Breakdown + +### Phase 0: Foundation (Week 1) + +**Objective**: Set up parallel infrastructure without disrupting existing tests. + +**Tasks**: + +1. Add rstest-bdd dependencies to `Cargo.toml`: + + ```toml + [dev-dependencies] + rstest-bdd = "0.4.0" + rstest-bdd-macros = { version = "0.4.0", + features = ["compile-time-validation"] } + ``` + +2. Create directory structure: + + ```text + tests/ + bdd/ + mod.rs # rstest-bdd entrypoint + fixtures/ # rstest fixtures and test helpers + steps/ # rstest-bdd step definitions + scenarios/ # rstest-bdd scenario functions + features/ # shared `.feature` files + ``` + +3. Update `Cargo.toml` test configuration: + + ```toml + [[test]] + name = "bdd" + path = "tests/bdd/mod.rs" + required-features = ["advanced-tests"] + + [[test]] + name = "concurrency_loom" + path = "tests/advanced/concurrency_loom.rs" + required-features = ["advanced-tests"] + ``` + +4. Update Makefile: + + ```makefile + test-bdd: ## Run rstest-bdd tests only + RUSTFLAGS="-D warnings" $(CARGO) test --test bdd \ + --all-features $(BUILD_JOBS) + + test: ## Run all tests (bdd + unit/integration) + RUSTFLAGS="-D warnings" $(CARGO) test --all-targets \ + --all-features $(BUILD_JOBS) + ``` + +**Validation**: `make test-bdd` and `make test` both succeed. + +**Commit**: "Set up parallel rstest-bdd infrastructure" + +### Phase 1: Pilot Migration – Simple Worlds (Weeks 2-3) + +**Objective**: Validate approach with 2 simple worlds, establish conversion +patterns. + +**Selected Worlds**: + +1. `CorrelationWorld` (115 lines, 3 scenarios) +2. `RequestPartsWorld` (~150 lines, basic validation) + +**Per-World Steps**: + +1. Convert World struct → fixture +2. Migrate step definitions (remove `async`, run async methods via a per-step + runtime) +3. Create scenario tests with `#[scenario]` (use async scenarios only when + steps are fully synchronous) +4. Run and validate against Cucumber + +**Example – CorrelationWorld**: + +```rust +// tests/bdd/fixtures/correlation.rs +use rstest::fixture; + +#[derive(Debug, Default)] +pub struct CorrelationWorld { + expected: Option, + frames: Vec, +} + +#[fixture] +pub fn correlation_world() -> CorrelationWorld { + CorrelationWorld::default() +} + +// Methods stay async +impl CorrelationWorld { + pub fn set_expected(&mut self, expected: Option) { + self.expected = expected; + } + + pub async fn process(&mut self) -> TestResult { + // … existing async code + } + + pub fn verify(&self) -> TestResult { + // … existing sync code + } +} +``` + +```rust +// tests/bdd/steps/correlation_steps.rs +use rstest_bdd_macros::{given, when, then}; + +#[given(expr = "a correlation id {id:u64}")] +fn given_cid(world: &mut CorrelationWorld, id: u64) { + world.set_expected(Some(id)); +} + +#[when("a stream of frames is processed")] +fn when_process(world: &mut CorrelationWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(world.process()) +} + +#[then(expr = "each emitted frame uses correlation id {id:u64}")] +fn then_verify(world: &mut CorrelationWorld, id: u64) + -> TestResult +{ + if world.expected() != Some(id) { + return Err("mismatched expected correlation id".into()); + } + world.verify() +} +``` + +```rust +// tests/bdd/scenarios/correlation_scenarios.rs +use rstest_bdd_macros::scenario; +use crate::fixtures::correlation::*; + +#[scenario(path = "tests/features/correlation_id.feature", + name = "Streamed frames reuse the request correlation id")] +fn streamed_frames_correlation( + correlation_world: CorrelationWorld +) { let _ = correlation_world; } + +#[scenario( + path = "tests/features/correlation_id.feature", + name = "Multi-packet responses reuse the request correlation id" +)] +fn multi_packet_correlation( + correlation_world: CorrelationWorld +) { let _ = correlation_world; } + +#[scenario( + path = "tests/features/correlation_id.feature", + name = "Multi-packet responses clear correlation ids without \ + a request id" +)] +fn no_correlation(correlation_world: CorrelationWorld) { + let _ = correlation_world; +} +``` + +**Validation**: + +```bash +cargo test --test bdd correlation +cargo test --test bdd request_parts +``` + +**Commits**: + +- ✅ "Migrate CorrelationWorld to rstest-bdd" (commit 8ce5b55) +- ✅ "Migrate RequestPartsWorld to rstest-bdd" (commit 154e5c8) + +**Status**: ✅ **COMPLETE** – Both pilot worlds successfully migrated and all +tests passing. + +### Phase 2: Medium Complexity Worlds (Weeks 4-5) + +**Selected Worlds** (in order): + +1. `PanicWorld` (server spawning pattern) +2. `MultiPacketWorld` (channel operations) +3. `StreamEndWorld` (actor processing) +4. `CodecStatefulWorld` (codec state) + +**Focus**: Server lifecycle, channels, actors. + +**Server Spawning Pattern**: + +```rust +#[derive(ScenarioState)] +pub struct PanicWorld { + // Spawned in step, not fixture + server: Slot, +} + +#[fixture] +fn panic_world() -> PanicWorld { + PanicWorld::default() // Empty slot +} + +#[given("a panic server")] +fn given_panic_server(world: &mut PanicWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let server = PanicServer::spawn().await?; + world.server.set(server); + Ok(()) + }) +} +``` + +**Commits**: One per world (4 commits). + +**Status**: ✅ **COMPLETE** – `PanicWorld`, `MultiPacketWorld`, +`StreamEndWorld`, and `CodecStatefulWorld` migrated. + +### Phase 3: Complex Worlds – Client & Messaging (Weeks 6-7) + +**Selected Worlds** (in order): + +1. `ClientRuntimeWorld` (simpler client) +2. `ClientMessagingWorld` (server + client + envelope handling) +3. `ClientLifecycleWorld` (lifecycle hooks) +4. `ClientPreambleWorld` (preamble exchange) + +**Focus**: Multi-step async sequences, server + client coordination, callbacks. + +**Multi-Async Step Pattern**: + +```rust +#[given("an envelope echo server")] +fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + world.start_echo_server().await?; + world.connect_client().await + }) +} +``` + +**Commits**: One per world (4 commits). + +**Status**: ✅ **COMPLETE** – `ClientRuntimeWorld`, `ClientMessagingWorld`, +`ClientLifecycleWorld`, and `ClientPreambleWorld` migrated. + +### Phase 4: Specialized Worlds (Week 8) + +**Selected Worlds**: + +1. `MessageAssemblerWorld` (header parsing) +2. `MessageAssemblyWorld` (multiplexing) +3. `CodecErrorWorld` (multi-module structure) +4. `FragmentWorld` (multi-file, 11 scenarios) + +**Focus**: Multi-file structures, high scenario counts. + +**Multi-File Pattern** (FragmentWorld): + +```rust +// tests/fixtures/fragment/ +// mod.rs – Main world struct +// reassembly.rs – Helper types + +pub mod reassembly; +use reassembly::*; + +#[derive(Debug, ScenarioState)] +pub struct FragmentWorld { + // … fields +} + +#[fixture] +pub fn fragment_world() -> FragmentWorld { + FragmentWorld::default() +} +``` + +**Commits**: One per world (4 commits). + +**Status**: ✅ **COMPLETE** – `MessageAssemblerWorld`, `MessageAssemblyWorld`, +`CodecErrorWorld`, and `FragmentWorld` migrated. + +### Phase 5: Validation & Cleanup (Week 9) + +**Tasks**: + +1. **Comprehensive comparison**: + + ```bash + cargo test --test bdd > bdd-output.txt 2>&1 + cargo test --all-targets --all-features > full-output.txt 2>&1 + ``` + + Result (2026-01-27): rstest-bdd runs 65 scenarios and the full suite passes. + +2. **Enable strict validation**: + + ```toml + rstest-bdd-macros = { version = "0.4.0", + features = ["strict-compile-time-validation"] } + ``` + + Completed 2026-01-25: strict compile-time validation is enabled. + +3. **Performance check**: + + ```bash + hyperfine 'cargo test --test bdd' + ``` + + Result (2026-01-27): rstest-bdd completes within the historical baseline. + +4. **Remove Cucumber infrastructure**: + - ✅ Delete `tests/cucumber.rs` + - ✅ Delete `tests/worlds/` + - ✅ Delete legacy Cucumber `tests/steps/` + - ✅ Remove `cucumber = "0.21.1"` from `Cargo.toml` + - ✅ Update Makefile targets for rstest-bdd + + Completed 2026-01-25. + +5. **Rename structure** (optional cleanup): + + ```bash + mv tests/bdd/fixtures tests/fixtures + mv tests/bdd/steps tests/steps + mv tests/bdd/scenarios tests/scenarios + # Update imports + ``` + + Completed 2026-01-25: fixtures, steps, and scenarios now live under `tests/`. + +### Historical baseline (pre-removal) + +- Before removing the Cucumber runner on 2026-01-25, both suites were run + side by side and passed the same scenario set. +- The last recorded comparison showed Cucumber at ~923 ms mean and rstest-bdd + at ~934 ms mean. + +**Commits**: + +- "Enable strict compile-time validation" +- "Remove Cucumber infrastructure" +- "Rename bdd structure to standard layout" + +## Migration Progress Tracking + +| Phase | Worlds | Scenarios | Status | Completion | +| ----- | ------ | --------- | -------- | ---------- | +| 0 | - | - | Complete | 2026-01-22 | +| 1 | 2 | 6 | Complete | 2026-01-22 | +| 2 | 4 | 15 | Complete | 2026-01-24 | +| 3 | 4 | 20 | Complete | 2026-01-25 | +| 4 | 4 | 19+ | Complete | 2026-01-25 | +| 5 | - | - | Complete | 2026-01-25 | + +**Total**: 14 worlds, 60+ scenarios + +## Risk Mitigation + +### Risk 1: Async Boundary Issues + +**Mitigation**: Validate the per-step runtime pattern in Phase 1 before +widespread adoption. Keep complex `ClientMessagingWorld` for Phase 3 after +validation. + +**Contingency**: Add a shared runtime helper if runtime creation becomes too +costly or repetitive. + +### Risk 2: Server Spawning Conflicts + +**Mitigation**: Use `Slot` pattern, not direct fixture spawn. Test in +Phase 2 with `PanicWorld`. + +### Risk 3: Fragment.feature Complexity (11 scenarios) + +**Mitigation**: Migrate in Phase 4 after patterns are proven. Can use `scenarios!` +macro if individual tests become verbose. + +### Risk 4: Compile-Time Validation False Positives + +**Mitigation**: Start with `compile-time-validation` (warnings only), enable +strict mode in Phase 5. + +### Risk 5: Migration Timeline Slippage + +**Mitigation**: Strict phase boundaries. Parallel execution allows partial +migration. Can pause after any phase. + +## Critical Files + +### Phase 0 (Foundation) + +1. `Cargo.toml` – Dependencies, test targets +2. `tests/bdd/mod.rs` – New test module root +3. `Makefile` – Test targets + +### Phase 1 (Pilot) + +1. `tests/fixtures/correlation.rs` – First fixture +2. `tests/steps/correlation_steps.rs` – First steps +3. `tests/scenarios/correlation_scenarios.rs` – First scenarios +4. `tests/fixtures/request_parts.rs` +5. `tests/steps/request_parts_steps.rs` +6. `tests/scenarios/request_parts_scenarios.rs` + +### Phase 2 (Medium Complexity) + +Panic, MultiPacket, StreamEnd, CodecStateful (fixtures, steps, scenarios) – 12 +files total + +### Phase 3 (Complex) + +ClientRuntime, ClientMessaging, ClientLifecycle, ClientPreamble – 12 files total + +### Phase 4 (Specialized) + +MessageAssembler, MessageAssembly, CodecError, Fragment – 12 files total + +### Phase 5 (Cleanup) — completed 2026-01-25 + +- ✅ `Cargo.toml`: remove Cucumber dependency +- ✅ `tests/cucumber.rs`: delete runner +- ✅ `tests/worlds/`: delete directory +- ✅ `tests/steps/`: delete legacy Cucumber steps + +## Verification + +### Per-Phase Validation + +After each phase: + +- [ ] All migrated scenarios pass: `cargo test --test bdd` +- [ ] No compile warnings +- [ ] Output matches expected behaviour +- [ ] Commit gateways pass (lint, format) + +### Final Validation (Phase 5) + +- [ ] All 60+ scenarios passing +- [ ] Strict compile-time validation enabled +- [ ] No undefined steps +- [ ] No unused step definitions +- [ ] Performance within the historical baseline +- [ ] Cucumber infrastructure removed +- [ ] CI pipeline updated +- [ ] Documentation updated + +## Helper Utilities + +Create shared async helper: + +```rust +// tests/bdd/async_helpers.rs +/// Execute an async future in a dedicated Tokio runtime. +/// +/// # Errors +/// Returns an error if the runtime cannot be created. +pub fn run_async(future: F) -> Result +where + F: std::future::Future, +{ + let rt = tokio::runtime::Runtime::new()?; + Ok(rt.block_on(future)) +} + +// Usage in steps: +#[when("server starts")] +fn when_server_starts(world: &mut ServerWorld) -> TestResult { + run_async(world.start_server())?; + Ok(()) +} +``` + +## Success Criteria + +- [ ] All 14 worlds migrated to rstest-bdd fixtures +- [ ] All 60+ scenarios passing under `cargo test` +- [ ] Cucumber infrastructure removed +- [ ] Strict compile-time validation enabled +- [ ] No test coverage gaps +- [ ] Performance comparable to Cucumber +- [ ] Clean CI pipeline (single test command) +- [ ] Team onboarded to rstest-bdd patterns + +## Lessons Learned + +### Phase 1: CorrelationWorld Migration (Completed) + +**Key Observation**: rstest-bdd supports async scenarios, but step definitions +remain synchronous (per the user guide). If a step needs to call async world +methods, invoking `block_on` from inside an async scenario panics with "Cannot +start runtime within runtime". + +**Adopted Pattern**: + +- Keep scenarios **sync** when steps need to run async world methods. +- Create a fresh Tokio runtime per async step and call `block_on`. +- Reserve async scenarios for worlds whose steps are purely synchronous, so the + scenario body can `await` extra assertions or cleanup when needed. + +**Validation**: CorrelationWorld migration complete with all 3 scenarios +passing, verified against Cucumber output. + +### Phase 1: RequestPartsWorld Migration (Completed) + +**Date**: 2026-01-22 + +**Migration**: `RequestPartsWorld` demonstrates the pattern for purely +synchronous worlds with no async operations. + +**Key Patterns**: + +- Synchronous world with no async methods +- Step functions are simple sync wrappers (no `Runtime::new()` needed) +- All 6 scenarios migrated successfully + +**Test Results**: + +- Cucumber: 6 scenarios (6 passed), 20 steps (20 passed) +- rstest-bdd: 6 scenarios (6 passed), 20 steps (20 passed) + +**Additional Fixes**: + +- Added module-level `#[expect(unused_braces)]` to fixture files to suppress + clippy/rustfmt conflict +- Fixed `doc_markdown` clippy lints across all BDD files +- Added `#[must_use]` and `# Panics` documentation to `unused_listener()` + +**Validation**: RequestPartsWorld migration complete with all 6 scenarios +passing. Phase 1 is complete. + +## References + +- [rstest-bdd User's Guide](../rstest-bdd-users-guide.md) +- [ADR-003: Replace Cucumber with + rstest-bdd](../adr-003-replace-cucumber-with-rstest-bdd.md) +- [Plan Agent Output](https://claude.ai) – Agent ID: a9eb419 diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index a35bc1fe..b2e0baed 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -23,19 +23,20 @@ owner, the developer, and the tester. ## Toolchain requirements -`rstest-bdd` targets Rust 1.75 or newer across every crate in the workspace. -Each `Cargo.toml` declares `rust-version = "1.75"`, so `cargo` will refuse to -compile the project on older stable compilers. The workspace now settles on the -Rust 2021 edition to keep the declared Minimum Supported Rust Version (MSRV) -and edition compatible. The repository still pins a nightly toolchain for -development because the runtime uses auto traits and negative impls. Those -nightly-only features remain behind the existing `rust-toolchain.toml` pin and -do not alter the public MSRV. Step definitions and writers remain synchronous -functions; the framework no longer depends on the `async-trait` crate to -express async methods in traits. Projects that previously relied on -`#[async_trait]` in helper traits should replace those methods with ordinary -functions—`StepFn` continues to execute synchronously and exposes results via -`StepExecution`. +`rstest-bdd` targets Rust 1.85 or newer across every crate in the workspace. +Each `Cargo.toml` declares `rust-version = "1.85"`, so `cargo` will refuse to +compile the project on older compilers. The workspace uses the Rust 2024 +edition. + +`rstest-bdd` builds on stable Rust. The repository pins a stable toolchain for +development via `rust-toolchain.toml` so contributors get consistent `rustfmt` +and `clippy` behaviour. + +Step definitions and writers remain synchronous functions; the framework no +longer depends on the `async-trait` crate to express async methods in traits. +Projects that previously relied on `#[async_trait]` in helper traits should +replace those methods with ordinary functions—`StepFn` continues to execute +synchronously and exposes results via `StepExecution`. ## The three amigos @@ -64,7 +65,7 @@ Scenarios follow the simple `Given‑When‑Then` pattern. Support for **Scenari Outline** is available, enabling a single scenario to run with multiple sets of data from an `Examples` table. A `Background` section defines steps that run before each `Scenario` in a feature file, enabling shared setup across -scenarios. Advanced constructs such as data tables and Docstrings provide +scenarios. Advanced constructs such as data tables and doc strings provide structured or free‑form arguments to steps. ### Example feature file @@ -287,7 +288,27 @@ missing matches leave fixtures untouched, keeping scenarios predictable while still allowing a functional style without mutable fixtures. Steps may also return `Result`. An `Err` aborts the scenario, while an -`Ok` value is injected as above. Type aliases to `Result` behave identically. +`Ok` value is injected as above. + +The step macros recognize these `Result` shapes during expansion: + +- `Result<..>`, `std::result::Result<..>`, and `core::result::Result<..>` +- `rstest_bdd::StepResult<..>` (an alias provided by the runtime crate) + +When inference cannot determine whether a return type is a `Result` (for +example, when returning a type alias), prefer returning +`rstest_bdd::StepResult` or spelling out `Result<..>` in the signature. +Alternatively, add an explicit return-kind hint: `#[when(result)]` / +`#[when(value)]`. + +The `result`/`value` hints are validated for obvious misconfigurations. +`result` is rejected for primitive return types. For aliases, the macro cannot +validate the underlying definition and assumes `Result<..>` semantics. + +Use `#[when("...", value)]` (or `#[when(value)]` when using the inferred +pattern) to force treating the return value as a payload even when it is +`Result<..>`. + Returning `()` or `Ok(())` produces no stored value, so fixtures of `()` are not overwritten. @@ -635,15 +656,20 @@ substring matching to confirm that a message contains the expected reason. ```rust,no_run use rstest_bdd::{assert_scenario_skipped, assert_step_skipped, StepExecution}; -use rstest_bdd::reporting::{ScenarioRecord, ScenarioStatus, SkippedScenario}; +use rstest_bdd::reporting::{ScenarioMetadata, ScenarioRecord, ScenarioStatus, SkippedScenario}; let outcome = StepExecution::skipped(Some("maintenance pending".into())); let message = assert_step_skipped!(outcome, message = "maintenance"); assert_eq!(message, Some("maintenance pending".into())); -let record = ScenarioRecord::new( +let metadata = ScenarioMetadata::new( "features/unhappy.feature", "pending work", + 12, + vec!["@allow_skipped".into()], +); +let record = ScenarioRecord::from_metadata( + metadata, ScenarioStatus::Skipped(SkippedScenario::new(None, true, false)), ); let details = assert_scenario_skipped!( @@ -682,8 +708,115 @@ union of feature, scenario, and example tags described above. Scenarios that do not match simply do not generate a test, and outline examples drop unmatched rows. -Generated tests cannot currently accept fixtures; use `#[scenario]` when -fixture injection or custom assertions are required. +### Fixture injection with `scenarios!` + +The `fixtures = [name: Type, ...]` parameter injects fixtures into all +generated scenario tests. Fixtures are bound via rstest and inserted into the +step context, making them available to step functions that declare the +corresponding parameter. + +```rust,no_run +use rstest::fixture; +use rstest_bdd_macros::{given, scenarios}; + +struct TestWorld { value: i32 } + +#[fixture] +fn world() -> TestWorld { TestWorld { value: 42 } } + +#[given("a precondition")] +fn step_uses_world(world: &TestWorld) { + assert_eq!(world.value, 42); +} + +scenarios!("tests/features/auto", fixtures = [world: TestWorld]); +``` + +The macro adds `#[expect(unused_variables)]` to generated test functions when +fixtures are present, preventing lint warnings since fixture parameters are +consumed via `StepContext` rather than referenced directly in the test body. + +## Async scenario execution + +Scenarios can run asynchronously under Tokio's current-thread runtime. This +enables test code to `.await` async operations while preserving the +`RefCell`-backed fixture model for mutable borrows across await points. + +### Using `#[scenario]` with async + +Declare the test function as `async fn` and add +`#[tokio::test(flavor = "current_thread")]` before the `#[scenario]` attribute. +The macro detects the async signature and generates an async step executor: + +```rust,no_run +use rstest_bdd_macros::{given, scenario, then, when}; +use rstest::fixture; + +#[derive(Default)] +struct Counter { + value: i32, +} + +#[fixture] +fn counter() -> Counter { + Counter::default() +} + +#[given("a counter initialised to 0")] +fn init(counter: &mut Counter) { + counter.value = 0; +} + +#[when("the counter is incremented")] +fn increment(counter: &mut Counter) { + counter.value += 1; +} + +#[then(expr = "the counter value is {n}")] +fn check_value(counter: &Counter, n: i32) { + assert_eq!(counter.value, n); +} + +#[scenario(path = "tests/features/counter.feature", name = "Increment counter")] +#[tokio::test(flavor = "current_thread")] +async fn increment_counter(counter: Counter) {} +``` + +The macro generates `#[rstest::rstest]` without duplicating +`#[tokio::test(flavor = "current_thread")]` when the user already supplies it. + +### Using `scenarios!` with async + +The `scenarios!` macro accepts a `runtime` argument to generate async tests for +all discovered scenarios: + +```rust,no_run +use rstest_bdd_macros::{given, then, when, scenarios}; + +#[given("a precondition")] fn precondition() {} +#[when("an action occurs")] fn action() {} +#[then("events are recorded")] fn events() {} + +scenarios!("tests/features/auto", runtime = "tokio-current-thread"); +``` + +When `runtime = "tokio-current-thread"` is specified: + +- Generated test functions are `async fn`. +- Each test is annotated with `#[tokio::test(flavor = "current_thread")]`. +- Steps execute sequentially within the single-threaded Tokio runtime. + +### Current limitations + +- **Sync step definitions only:** The async executor currently calls the sync + `run` handler directly rather than `run_async`. This avoids higher-ranked + trait bound (HRTB) lifetime issues but means steps cannot `.await` + internally. True async step definitions (with `async fn` bodies) are planned + for a future release. +- **Current-thread mode only:** Multi-threaded Tokio mode would require `Send` + futures, which conflicts with the `RefCell`-backed fixture storage. See + [ADR-001](adr-001-async-fixtures-and-test.md) for the full design rationale. +- **No `async_std` runtime:** Only Tokio is supported at present. ## Running and maintaining tests @@ -705,14 +838,14 @@ To enable validation, pin a feature in the project's `dev-dependencies`: ```toml [dev-dependencies] -rstest-bdd-macros = { version = "0.2.0", features = ["compile-time-validation"] } +rstest-bdd-macros = { version = "0.4.0", features = ["compile-time-validation"] } ``` For strict checking use: ```toml [dev-dependencies] -rstest-bdd-macros = { version = "0.2.0", features = ["strict-compile-time-validation"] } +rstest-bdd-macros = { version = "0.4.0", features = ["strict-compile-time-validation"] } ``` Steps are only validated when one of these features is enabled. @@ -762,7 +895,7 @@ Best practices for writing effective scenarios include: treated as generic placeholders and capture any non-newline text using a non-greedy match. -## Data tables and Docstrings +## Data tables and doc strings Steps may supply structured or free-form data via a trailing argument. A data table is received by including a parameter annotated with `#[datatable]` or @@ -1029,8 +1162,8 @@ fn capture_both(datatable: Vec>, docstring: String) { At runtime, the generated wrapper converts the table cells or copies the block text and passes them to the step function. It panics if the step declares -`datatable` or `docstring` but the feature omits the content. Docstrings may be -delimited by triple double-quotes or triple backticks. +`datatable` or `docstring` but the feature omits the content. These doc strings +may be delimited by triple double-quotes or triple backticks. ## Limitations and roadmap @@ -1105,7 +1238,7 @@ Localization tooling can be added to `Cargo.toml` as follows: ```toml [dependencies] -rstest-bdd = "0.2.0" +rstest-bdd = "0.4.0" i18n-embed = { version = "0.16", features = ["fluent-system", "desktop-requester"] } unic-langid = "0.9" ``` @@ -1145,24 +1278,34 @@ https://docs.rs/i18n-embed/latest/i18n_embed/fluent/struct.FluentLanguageLoader. Synopsis - `cargo bdd steps` +- `cargo bdd steps --skipped` - `cargo bdd unused` - `cargo bdd duplicates` +- `cargo bdd skipped` Examples - `cargo bdd steps` +- `cargo bdd steps --skipped --json` - `cargo bdd unused --quiet` - `cargo bdd duplicates --json` +- `cargo bdd skipped --reasons` +- `cargo bdd steps --skipped --json` must be paired; using `--json` without + `--skipped` is rejected by the CLI, so invalid combinations fail fast. -The tool inspects the runtime step registry and offers three commands: +The tool inspects the runtime step registry and offers four commands: - `cargo bdd steps` prints every registered step with its source location and appends any skipped scenario outcomes using lowercase status labels whilst preserving long messages. +- `cargo bdd steps --skipped` limits the listing to step definitions that were + bypassed after a scenario requested a skip, preserving the scenario context. - `cargo bdd unused` lists steps that were never executed in the current process. - `cargo bdd duplicates` groups step definitions that share the same keyword and pattern, helping to identify accidental copies. +- `cargo bdd skipped` lists skipped scenarios and supports `--reasons` to show + file and line numbers alongside the explanatory message. The subcommand builds each test target in the workspace and runs the resulting binary with `RSTEST_BDD_DUMP_STEPS=1` and a private `--dump-steps` flag to @@ -1172,6 +1315,12 @@ during that same execution. The merged output powers the commands above and the skip status summary, helping to keep the step library tidy and discover dead code early in the development cycle. +`steps --skipped` and `skipped` accept `--json` and emit objects that always +include `feature`, `scenario`, `line`, `tags`, and `reason` fields. The former +adds an embedded `step` object describing each bypassed definition (keyword, +pattern, file, and line) to help trace which definitions were sidelined by a +runtime skip. + ### Scenario report writers Projects that need to persist scenario results outside the CLI can rely on the @@ -1197,6 +1346,208 @@ rstest_bdd::reporting::junit::write_snapshot(&mut xml)?; Both writers accept explicit `&[ScenarioRecord]` slices when callers want to serialize a custom selection of outcomes rather than the full snapshot. +## Language server + +The `rstest-bdd-server` crate provides a Language Server Protocol (LSP) +implementation that bridges Gherkin `.feature` files and Rust step definitions. +The binary is named `rstest-bdd-lsp` and communicates over stdin/stdout using +JSON-RPC, making it compatible with any editor supporting the LSP (VS Code, +Neovim, Zed, Helix, etc.). + +### Installation + +Build and install the language server from the workspace: + +```bash +cargo install --path crates/rstest-bdd-server +``` + +The binary `rstest-bdd-lsp` is placed in the Cargo bin directory. + +### Configuration + +The server reads configuration from environment variables: + +| Variable | Description | Default | +| ---------------------------- | --------------------------------------------------- | ------- | +| `RSTEST_BDD_LSP_LOG_LEVEL` | Logging verbosity (trace, debug, info, warn, error) | `info` | +| `RSTEST_BDD_LSP_DEBOUNCE_MS` | Delay (ms) before processing file changes | `300` | + +Example: + +```bash +RSTEST_BDD_LSP_LOG_LEVEL=debug rstest-bdd-lsp +``` + +### Editor integration + +#### VS Code + +Add a configuration in the `settings.json` file or use an extension that allows +custom LSP servers. A minimal example using the +[LSP-client](https://marketplace.visualstudio.com/items?itemName=ACharLuk.easy-lsp-client) + extension: + +```json +{ + "easylsp.servers": [ + { + "language": ["rust", "gherkin"], + "command": "rstest-bdd-lsp" + } + ] +} +``` + +#### Neovim (nvim-lspconfig) + +```lua +local lspconfig = require('lspconfig') +local configs = require('lspconfig.configs') + +if not configs.rstest_bdd then + configs.rstest_bdd = { + default_config = { + cmd = { 'rstest-bdd-lsp' }, + filetypes = { 'rust', 'cucumber' }, + root_dir = lspconfig.util.root_pattern('Cargo.toml'), + }, + } +end + +lspconfig.rstest_bdd.setup({}) +``` + +### Current capabilities + +The language server provides the following capabilities: + +- **Lifecycle handlers**: Responds to `initialize`, `initialized`, and + `shutdown` requests per the LSP specification. +- **Workspace discovery**: Uses `cargo metadata` to locate the workspace root + and enumerate packages. +- **Feature indexing (on save)**: Parses saved `.feature` files using the + `gherkin` parser and records steps, doc strings, data tables, and Examples + header columns with byte offsets. Parse failures are logged. +- **Rust step indexing (on save)**: Parses saved `.rs` files with `syn` and + records `#[given]`, `#[when]`, and `#[then]` functions, including the step + keyword, pattern string (including inferred patterns when the attribute has + no arguments), the parameter list, and whether the step expects a data table + or doc string. +- **Step pattern registry (on save)**: Compiles the indexed step patterns with + `rstest-bdd-patterns` and caches compiled regex matchers in a keyword-keyed + in-memory registry. The registry is updated incrementally per file save, so + removed steps do not linger. + - **API note (embedding)**: `StepDefinitionRegistry::{steps_for_keyword, + steps_for_file}` returns `Arc` entries so the + compiled matcher and metadata are shared between the per-file and + per-keyword indices. +- **Structured logging**: Configurable via environment variables; logs are + written to stderr using the `tracing` framework. + +### Navigation (Go to Definition) + +The language server supports navigation from Rust step definitions to matching +feature steps. This enables developers to quickly find all usages of a step +definition across feature files. + +**Usage:** + +1. Place the cursor on a Rust function annotated with `#[given]`, `#[when]`, or + `#[then]`. +2. Invoke "Go to Definition" (typically F12 or Ctrl+Click in most editors). +3. The editor navigates to all matching steps in `.feature` files. + +When multiple feature files contain matching steps, the editor presents a list +of locations to choose from. + +**How matching works:** + +- Matching is keyword-aware: a `#[given]` step only matches `Given` steps in + feature files. The parser correctly handles `And` and `But` keywords by + resolving them to their contextual step type. +- Patterns with placeholders (e.g., `"I have {count:u32} items"`) match feature + steps using the same regex semantics as the runtime. + +#### Go to Implementation (Feature → Rust) + +The inverse navigation—from feature steps to Rust implementations—is provided +via the `textDocument/implementation` handler. This enables developers to jump +from a step line in a `.feature` file directly to the Rust function(s) that +implement it. + +**Usage:** + +1. Place the cursor on a step line in a `.feature` file (e.g., `Given a user + exists`). +2. Invoke "Go to Implementation" (typically Ctrl+F12 or a similar keybinding in + most editors). +3. The editor navigates to all matching Rust step functions. + +When multiple implementations match (duplicate step patterns), the editor +presents a list of locations to choose from. + +**How matching works:** + +- Matching is keyword-aware: a `Given` step in a feature file only matches + `#[given]` implementations in Rust. +- The step text is matched against the compiled regex patterns from the step + registry, ensuring consistency with the runtime. + +### Diagnostics (on save) + +The language server publishes diagnostics when files are saved, helping +developers identify consistency issues between feature files and Rust step +definitions: + +- **Unimplemented feature steps** (`unimplemented-step`): When a step in a + `.feature` file has no matching Rust implementation, a warning diagnostic is + published at the step location. The message indicates the step keyword and + text that needs an implementation. + +- **Unused step definitions** (`unused-step-definition`): When a Rust step + definition (annotated with `#[given]`, `#[when]`, or `#[then]`) is not + matched by any feature step, a warning diagnostic is published at the + function definition. This helps identify dead code or typos in step patterns. + +- **Placeholder count mismatch** (`placeholder-count-mismatch`): When a step + pattern contains a different number of placeholder occurrences than the + function has step arguments, a warning diagnostic is published on the Rust + step definition. Each placeholder occurrence is counted separately (e.g., + `{x} and {x}` counts as two placeholders), matching the macro's capture + semantics. A step argument is a function parameter whose normalized name + matches a placeholder name in the pattern; `datatable`, `docstring`, and + fixture parameters are excluded from the count. + +- **Data table expected** (`table-expected`): When a Rust step expects a data + table (has a `datatable` parameter) but the matching feature step does not + provide one, a warning diagnostic is published on the feature step. + +- **Data table not expected** (`table-not-expected`): When a feature step + provides a data table but the matching Rust implementation does not expect + one, a warning diagnostic is published on the data table in the feature file. + +- **Doc string expected** (`docstring-expected`): When a Rust step expects a doc + string (has a `docstring: String` parameter) but the matching feature step + does not provide one, a warning diagnostic is published on the feature step. + +- **Doc string not expected** (`docstring-not-expected`): When a feature step + provides a doc string but the matching Rust implementation does not expect + one, a warning diagnostic is published on the doc string in the feature file. + +Diagnostics are updated incrementally: + +- Saving a `.feature` file recomputes diagnostics for that file, including + unimplemented steps and table/docstring expectation mismatches. +- Saving a `.rs` file recomputes diagnostics for all feature files (since new + or removed step definitions may affect which steps are implemented) and + checks for unused definitions and placeholder count mismatches in the saved + file. + +Diagnostics appear in the editor's Problems panel and as inline warnings, +similar to compiler diagnostics. They use the source `rstest-bdd` and the codes +listed above for filtering. + ## Summary `rstest‑bdd` seeks to bring the collaborative clarity of Behaviour‑Driven diff --git a/tests/bdd/mod.rs b/tests/bdd/mod.rs new file mode 100644 index 00000000..096cc6e5 --- /dev/null +++ b/tests/bdd/mod.rs @@ -0,0 +1,32 @@ +//! rstest-bdd behavioural tests. +//! +//! This module contains the rstest-bdd-based BDD tests that replaced the +//! former Cucumber test suite. These tests use the same `.feature` files but +//! execute under the standard `cargo test` harness with rstest fixtures. + +#![cfg(not(loom))] + +// Re-export common utilities from the parent tests directory +#[path = "../common/mod.rs"] +pub mod common; + +#[path = "../common/terminator.rs"] +mod terminator; + +#[path = "../support.rs"] +mod support; + +use wireframe::{app::Envelope, push::PushQueues, serializer::BincodeSerializer}; + +pub(crate) type TestApp = wireframe::app::WireframeApp; + +pub(crate) fn build_small_queues() +-> Result<(PushQueues, wireframe::push::PushHandle), wireframe::push::PushConfigError> { + support::builder::().unlimited().build() +} + +#[path = "../fixtures/mod.rs"] +mod fixtures; + +#[path = "../scenarios/mod.rs"] +mod scenarios; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7c19104d..89627812 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -39,10 +39,15 @@ use wireframe::{ }; /// Create a TCP listener bound to a free local port. +/// +/// # Panics +/// Panics if unable to bind to an ephemeral localhost port, which should never +/// happen in a well-configured test environment. #[expect( clippy::expect_used, reason = "binding to an ephemeral localhost port must abort the test immediately" )] +#[must_use] pub fn unused_listener() -> StdTcpListener { let addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0); StdTcpListener::bind(addr).expect("failed to bind port") @@ -86,11 +91,12 @@ impl Packet for CommonTestEnvelope { } } -/// Default app type used by cucumber worlds during integration tests. +/// Default app type used by integration test suites. pub type TestApp = wireframe::app::WireframeApp; -/// Shared result type for cucumber step implementations. +/// Shared result type for BDD step implementations. pub type TestResult = Result>; +/// Default `WireframeApp` factory for integration tests. #[fixture] pub fn factory() -> impl Fn() -> TestApp + Send + Sync + Clone + 'static { fn build() -> TestApp { TestApp::default() } diff --git a/tests/cucumber.rs b/tests/cucumber.rs deleted file mode 100644 index 1db7a691..00000000 --- a/tests/cucumber.rs +++ /dev/null @@ -1,80 +0,0 @@ -#![cfg(not(loom))] -//! Cucumber test runner for integration tests. -//! -//! Orchestrates thirteen distinct test suites: -//! - `PanicWorld`: Tests server resilience during connection panics -//! - `CorrelationWorld`: Tests correlation ID propagation in multi-frame responses -//! - `StreamEndWorld`: Verifies end-of-stream signalling -//! - `MultiPacketWorld`: Tests channel-backed multi-packet response delivery -//! - `FragmentWorld`: Tests fragment metadata enforcement and reassembly primitives -//! - `MessageAssemblerWorld`: Tests message assembler header parsing -//! - `MessageAssemblyWorld`: Tests message assembly multiplexing and continuity -//! - `ClientRuntimeWorld`: Tests client runtime configuration and framing behaviour -//! - `ClientMessagingWorld`: Tests client messaging APIs with correlation ID support -//! - `CodecStatefulWorld`: Tests instance-aware codec sequence counters -//! - `RequestPartsWorld`: Tests request parts metadata handling -//! - `ClientPreambleWorld`: Tests client preamble exchange and callbacks -//! - `ClientLifecycleWorld`: Tests client connection lifecycle hooks -//! - `CodecErrorWorld`: Tests codec error taxonomy and recovery policies -//! -//! # Example -//! -//! The runner executes feature files sequentially: -//! ```text -//! tests/features/connection_panic.feature -> PanicWorld context -//! tests/features/correlation_id.feature -> CorrelationWorld context -//! tests/features/stream_end.feature -> StreamEndWorld context -//! tests/features/multi_packet.feature -> MultiPacketWorld context -//! tests/features/fragment.feature -> FragmentWorld context -//! tests/features/message_assembler.feature -> MessageAssemblerWorld context -//! tests/features/message_assembly.feature -> MessageAssemblyWorld context -//! tests/features/client_runtime.feature -> ClientRuntimeWorld context -//! tests/features/client_messaging.feature -> ClientMessagingWorld context -//! tests/features/codec_stateful.feature -> CodecStatefulWorld context -//! tests/features/request_parts.feature -> RequestPartsWorld context -//! tests/features/client_preamble.feature -> ClientPreambleWorld context -//! tests/features/client_lifecycle.feature -> ClientLifecycleWorld context -//! tests/features/codec_error.feature -> CodecErrorWorld context -//! ``` -//! -//! Each context provides specialised step definitions and state management -//! for their respective test scenarios. - -mod steps; -mod world; - -use cucumber::World; -use world::{ - ClientLifecycleWorld, - ClientMessagingWorld, - ClientPreambleWorld, - ClientRuntimeWorld, - CodecErrorWorld, - CodecStatefulWorld, - CorrelationWorld, - FragmentWorld, - MessageAssemblerWorld, - MessageAssemblyWorld, - MultiPacketWorld, - PanicWorld, - RequestPartsWorld, - StreamEndWorld, -}; - -#[tokio::main] -async fn main() { - PanicWorld::run("tests/features/connection_panic.feature").await; - CorrelationWorld::run("tests/features/correlation_id.feature").await; - StreamEndWorld::run("tests/features/stream_end.feature").await; - MultiPacketWorld::run("tests/features/multi_packet.feature").await; - FragmentWorld::run("tests/features/fragment.feature").await; - MessageAssemblerWorld::run("tests/features/message_assembler.feature").await; - MessageAssemblyWorld::run("tests/features/message_assembly.feature").await; - ClientRuntimeWorld::run("tests/features/client_runtime.feature").await; - ClientMessagingWorld::run("tests/features/client_messaging.feature").await; - CodecStatefulWorld::run("tests/features/codec_stateful.feature").await; - RequestPartsWorld::run("tests/features/request_parts.feature").await; - ClientPreambleWorld::run("tests/features/client_preamble.feature").await; - ClientLifecycleWorld::run("tests/features/client_lifecycle.feature").await; - CodecErrorWorld::run("tests/features/codec_error.feature").await; -} diff --git a/tests/features/client_preamble.feature b/tests/features/client_preamble.feature index fab34f48..1a5fedaf 100644 --- a/tests/features/client_preamble.feature +++ b/tests/features/client_preamble.feature @@ -8,7 +8,7 @@ Feature: Client preamble exchange And the client success callback is invoked Scenario: Client receives server acknowledgement in success callback - Given a preamble-aware echo server that sends acknowledgement + Given a preamble-aware echo server that sends an acknowledgement preamble When a client connects with a preamble and reads the acknowledgement Then the client receives an accepted acknowledgement @@ -19,6 +19,6 @@ Feature: Client preamble exchange And the failure callback is invoked Scenario: Client without preamble connects normally - Given a standard echo server + Given a standard echo server without preamble support When a client connects without a preamble Then the client connects successfully diff --git a/tests/features/fragment.feature b/tests/features/fragment.feature index 99cd447a..aa7df5f2 100644 --- a/tests/features/fragment.feature +++ b/tests/features/fragment.feature @@ -64,7 +64,7 @@ Feature: Fragment metadata enforcement Scenario: Reassembler evicts stale partial messages Given a reassembler allowing 8 bytes with a 1-second reassembly timeout When fragment 0 for message 23 with 5 bytes arrives marked non-final - And time advances by 2 seconds + And time advances by 2 seconds for reassembly And expired reassembly buffers are purged Then the reassembler is buffering 0 messages And message 23 is evicted diff --git a/tests/features/message_assembler.feature b/tests/features/message_assembler.feature index 98597a37..9bfe2af5 100644 --- a/tests/features/message_assembler.feature +++ b/tests/features/message_assembler.feature @@ -10,7 +10,7 @@ Feature: Message assembler header parsing Then the app exposes a message assembler And the parsed header is first And the message key is 9 - And the metadata length is 2 + And the header metadata length is 2 And the body length is 12 And the header length is 16 And the total body length is absent @@ -21,7 +21,7 @@ Feature: Message assembler header parsing When the message assembler parses the header Then the parsed header is first And the message key is 9 - And the metadata length is 2 + And the header metadata length is 2 And the body length is 12 And the header length is 16 And the total body length is absent @@ -32,7 +32,7 @@ Feature: Message assembler header parsing When the message assembler parses the header Then the parsed header is first And the message key is 42 - And the metadata length is 0 + And the header metadata length is 0 And the body length is 8 And the header length is 20 And the total body length is 64 diff --git a/tests/worlds/client_lifecycle.rs b/tests/fixtures/client_lifecycle.rs similarity index 91% rename from tests/worlds/client_lifecycle.rs rename to tests/fixtures/client_lifecycle.rs index 9fc02bc5..e99b3045 100644 --- a/tests/worlds/client_lifecycle.rs +++ b/tests/fixtures/client_lifecycle.rs @@ -1,5 +1,7 @@ -//! Test world for client lifecycle hook scenarios. -#![cfg(not(loom))] +//! `ClientLifecycleWorld` fixture for rstest-bdd tests. +//! +//! Provides server/client coordination for lifecycle hook scenarios. + #![expect( clippy::expect_used, reason = "test code uses expect for concise assertions" @@ -19,6 +21,7 @@ use std::{ }; use futures::FutureExt; +use rstest::fixture; use tokio::{net::TcpListener, task::JoinHandle}; use wireframe::{ BincodeSerializer, @@ -27,7 +30,8 @@ use wireframe::{ rewind_stream::RewindStream, }; -use super::TestResult; +/// Re-export `TestResult` from common for use in steps. +pub use crate::common::TestResult; /// Preamble used for testing lifecycle with preamble. #[derive(Debug, Clone, PartialEq, Eq, Default, bincode::Encode, bincode::BorrowDecode)] @@ -59,7 +63,7 @@ pub const EXPECTED_SETUP_STATE: u32 = 42; type TestClient = WireframeClient, u32>; /// Test world exercising client lifecycle hooks. -#[derive(Debug, Default, cucumber::World)] +#[derive(Debug, Default)] pub struct ClientLifecycleWorld { addr: Option, server: Option>, @@ -74,19 +78,21 @@ pub struct ClientLifecycleWorld { impl Drop for ClientLifecycleWorld { fn drop(&mut self) { - // Ensure server tasks are cleaned up even if test assertions fail. if let Some(handle) = self.server.take() { handle.abort(); } } } +/// Fixture for `ClientLifecycleWorld`. +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] +#[fixture] +pub fn client_lifecycle_world() -> ClientLifecycleWorld { + ClientLifecycleWorld::default() +} + impl ClientLifecycleWorld { - /// Spawn a server that executes the provided behaviour closure after accepting a connection. - /// - /// This helper binds a `TcpListener`, stores the address in `self.addr`, spawns a task - /// that accepts a connection and runs the closure, and stores the task handle in - /// `self.server`. async fn spawn_server(&mut self, behaviour: F) -> TestResult where F: FnOnce(tokio::net::TcpStream) -> Fut + Send + 'static, @@ -104,10 +110,6 @@ impl ClientLifecycleWorld { Ok(()) } - /// Handle the result of a client connection attempt. - /// - /// Stores the client in `self.client` on success, or the error in `self.last_error` - /// on failure. fn handle_connection_result(&mut self, result: Result) { match result { Ok(client) => { @@ -119,10 +121,6 @@ impl ClientLifecycleWorld { } } - /// Connect using a builder configuration closure. - /// - /// This helper retrieves the server address, applies the provided configuration - /// to a new builder, connects, and handles the result. async fn connect_with_builder(&mut self, configure: F) -> TestResult where F: FnOnce( @@ -145,7 +143,6 @@ impl ClientLifecycleWorld { /// The spawned task panics if accept fails. pub async fn start_standard_server(&mut self) -> TestResult { self.spawn_server(|_stream| async { - // Hold connection briefly tokio::time::sleep(Duration::from_millis(100)).await; }) .await @@ -160,7 +157,6 @@ impl ClientLifecycleWorld { /// The spawned task panics if accept fails. pub async fn start_disconnecting_server(&mut self) -> TestResult { self.spawn_server(|stream| async { - // Disconnect immediately drop(stream); }) .await @@ -306,7 +302,6 @@ impl ClientLifecycleWorld { /// Returns `Ok` but stores any receive error in `last_error`. pub async fn attempt_receive(&mut self) -> TestResult { if let Some(ref mut client) = self.client { - // Wait a bit for server to disconnect tokio::time::sleep(Duration::from_millis(50)).await; let result: Result, ClientError> = client.receive().await; if let Err(e) = result { diff --git a/tests/worlds/client_messaging.rs b/tests/fixtures/client_messaging.rs similarity index 96% rename from tests/worlds/client_messaging.rs rename to tests/fixtures/client_messaging.rs index 8507328b..f9781a2e 100644 --- a/tests/worlds/client_messaging.rs +++ b/tests/fixtures/client_messaging.rs @@ -1,12 +1,13 @@ -//! Test world for client messaging scenarios with correlation ID support. -#![cfg(not(loom))] +//! `ClientMessagingWorld` fixture for rstest-bdd tests. +//! +//! Provides server/client coordination for correlation-aware message APIs. use std::net::SocketAddr; use bytes::Bytes; -use cucumber::World; use futures::{SinkExt, StreamExt}; use log::warn; +use rstest::fixture; use tokio::{net::TcpListener, task::JoinHandle}; use tokio_util::codec::{Framed, LengthDelimitedCodec}; use wireframe::{ @@ -18,10 +19,11 @@ use wireframe::{ }; use wireframe_testing::{ServerMode, process_frame}; -use super::TestResult; +/// `TestResult` for step definitions. +pub use crate::common::TestResult; /// Test world for client messaging scenarios. -#[derive(Debug, Default, World)] +#[derive(Debug, Default)] pub struct ClientMessagingWorld { addr: Option, server: Option>, @@ -37,6 +39,13 @@ pub struct ClientMessagingWorld { expected_payload: Option, } +/// Fixture for `ClientMessagingWorld`. +#[rustfmt::skip] +#[fixture] +pub fn client_messaging_world() -> ClientMessagingWorld { + ClientMessagingWorld::default() +} + impl ClientMessagingWorld { /// Start an envelope echo server. /// diff --git a/tests/worlds/client_preamble.rs b/tests/fixtures/client_preamble.rs similarity index 96% rename from tests/worlds/client_preamble.rs rename to tests/fixtures/client_preamble.rs index b69bcf88..51cfa647 100644 --- a/tests/worlds/client_preamble.rs +++ b/tests/fixtures/client_preamble.rs @@ -1,5 +1,7 @@ -//! Test world for client preamble scenarios. -#![cfg(not(loom))] +//! `ClientPreambleWorld` fixture for rstest-bdd tests. +//! +//! Provides server/client coordination for preamble exchange scenarios. + #![expect( clippy::expect_used, reason = "test code uses expect for concise assertions" @@ -12,6 +14,7 @@ use std::{net::SocketAddr, sync::Arc, time::Duration}; use futures::FutureExt; +use rstest::fixture; use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle}; use wireframe::{ BincodeSerializer, @@ -20,7 +23,8 @@ use wireframe::{ rewind_stream::RewindStream, }; -use super::TestResult; +/// `TestResult` for step definitions. +pub use crate::common::TestResult; /// Preamble used for testing. #[derive(Debug, Clone, PartialEq, Eq, Default, bincode::Encode, bincode::BorrowDecode)] @@ -72,7 +76,7 @@ fn send_signal(holder: &std::sync::Mutex>>, value: } /// Test world exercising client preamble exchange. -#[derive(Debug, Default, cucumber::World)] +#[derive(Debug, Default)] pub struct ClientPreambleWorld { addr: Option, server: Option>, @@ -85,6 +89,13 @@ pub struct ClientPreambleWorld { last_error: Option, } +/// Fixture for `ClientPreambleWorld`. +#[rustfmt::skip] +#[fixture] +pub fn client_preamble_world() -> ClientPreambleWorld { + ClientPreambleWorld::default() +} + impl ClientPreambleWorld { /// Start a preamble-aware echo server. /// @@ -103,7 +114,6 @@ impl ClientPreambleWorld { .await .expect("read preamble"); let _ = tx.send(preamble); - // Hold connection briefly tokio::time::sleep(Duration::from_millis(100)).await; }); @@ -151,7 +161,6 @@ impl ClientPreambleWorld { let addr = listener.local_addr()?; let handle = tokio::spawn(async move { let (_stream, _) = listener.accept().await.expect("accept"); - // Hold connection but don't respond tokio::time::sleep(Duration::from_secs(10)).await; }); @@ -216,7 +225,6 @@ impl ClientPreambleWorld { } } - // Now collect the preamble the server received if let Some(preamble_rx) = self.server_preamble_rx.take() && let Ok(Ok(preamble)) = tokio::time::timeout(Duration::from_secs(1), preamble_rx).await @@ -280,7 +288,6 @@ impl ClientPreambleWorld { .preamble_timeout(Duration::from_millis(timeout_ms)) .on_preamble_success(|_preamble, stream| { async move { - // Try to read server response - this should timeout. use tokio::io::AsyncReadExt; let mut buf = [0u8; 1]; stream.read_exact(&mut buf).await?; diff --git a/tests/fixtures/client_runtime.rs b/tests/fixtures/client_runtime.rs new file mode 100644 index 00000000..9dc91202 --- /dev/null +++ b/tests/fixtures/client_runtime.rs @@ -0,0 +1,234 @@ +//! `ClientRuntimeWorld` fixture for rstest-bdd tests. +//! +//! Provides an echo server/client pair to validate client runtime framing +//! behaviour. + +use std::{ + cell::{Cell, RefCell}, + net::SocketAddr, +}; + +use futures::{SinkExt, StreamExt}; +use log::warn; +use rstest::fixture; +use tokio::{net::TcpListener, task::JoinHandle}; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use wireframe::{ + BincodeSerializer, + client::{ClientCodecConfig, ClientError, WireframeClient}, + rewind_stream::RewindStream, +}; + +/// `TestResult` for step definitions. +pub use crate::common::TestResult; + +/// Test world exercising the wireframe client runtime. +#[derive(Debug)] +pub struct ClientRuntimeWorld { + runtime: Option, + runtime_error: Option, + addr: Cell>, + server: RefCell>>, + client: + RefCell>>>, + payload: RefCell>, + response: RefCell>, + last_error: RefCell>, +} + +impl ClientRuntimeWorld { + /// Build a new runtime-backed client world. + pub fn new() -> Self { + match tokio::runtime::Runtime::new() { + Ok(runtime) => Self { + runtime: Some(runtime), + runtime_error: None, + addr: Cell::new(None), + server: RefCell::new(None), + client: RefCell::new(None), + payload: RefCell::new(None), + response: RefCell::new(None), + last_error: RefCell::new(None), + }, + Err(err) => Self { + runtime: None, + runtime_error: Some(format!("failed to create runtime: {err}")), + addr: Cell::new(None), + server: RefCell::new(None), + client: RefCell::new(None), + payload: RefCell::new(None), + response: RefCell::new(None), + last_error: RefCell::new(None), + }, + } + } + + fn runtime(&self) -> TestResult<&tokio::runtime::Runtime> { + self.runtime.as_ref().ok_or_else(|| { + self.runtime_error + .clone() + .unwrap_or_else(|| "runtime unavailable".to_string()) + .into() + }) + } + + fn block_on(&self, future: F) -> TestResult + where + F: std::future::Future, + { + if tokio::runtime::Handle::try_current().is_ok() { + return Err("nested Tokio runtime detected in client runtime fixture".into()); + } + let runtime = self.runtime()?; + Ok(runtime.block_on(future)) + } +} + +#[derive(bincode::Encode, bincode::BorrowDecode, Debug, PartialEq, Eq, Clone)] +struct ClientPayload { + data: Vec, +} + +/// Fixture for `ClientRuntimeWorld`. +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] +#[fixture] +pub fn client_runtime_world() -> ClientRuntimeWorld { + ClientRuntimeWorld::new() +} + +impl ClientRuntimeWorld { + /// Start an echo server with the specified maximum frame length. + /// + /// # Errors + /// Returns an error if binding or spawning the server fails. + pub fn start_server(&self, max_frame_length: usize) -> TestResult { + let listener = self.block_on(async { TcpListener::bind("127.0.0.1:0").await })??; + let addr = listener.local_addr()?; + let handle = self.runtime()?.spawn(async move { + let Ok((stream, _)) = listener.accept().await else { + warn!("client runtime server failed to accept connection"); + return; + }; + let codec = LengthDelimitedCodec::builder() + .max_frame_length(max_frame_length) + .new_codec(); + let mut framed = Framed::new(stream, codec); + let Some(result) = framed.next().await else { + warn!("client runtime server closed before receiving a frame"); + return; + }; + let Ok(frame) = result else { + warn!("client runtime server failed to decode frame"); + return; + }; + if let Err(err) = framed.send(frame.freeze()).await { + warn!("client runtime server failed to send response: {err:?}"); + } + }); + + self.addr.set(Some(addr)); + *self.server.borrow_mut() = Some(handle); + Ok(()) + } + + /// Connect a client using the specified maximum frame length. + /// + /// # Errors + /// Returns an error if the server has not started or the client fails to connect. + pub fn connect_client(&self, max_frame_length: usize) -> TestResult { + let addr = self.addr.get().ok_or("server address missing")?; + let codec_config = ClientCodecConfig::default().max_frame_length(max_frame_length); + let client = self.block_on(async { + WireframeClient::builder() + .codec_config(codec_config) + .connect(addr) + .await + })??; + *self.client.borrow_mut() = Some(client); + Ok(()) + } + + /// Send a payload of the specified size and capture the response. + /// + /// # Errors + /// Returns an error if the client is missing or communication fails. + pub fn send_payload(&self, size: usize) -> TestResult { + let (payload, result) = self.send_payload_inner(size)?; + let response = result?; + *self.payload.borrow_mut() = Some(payload); + *self.response.borrow_mut() = Some(response); + *self.last_error.borrow_mut() = None; + Ok(()) + } + + /// Send a payload that should exceed the peer's frame limit. + /// + /// # Errors + /// Returns an error if the client is missing or if no failure is observed. + pub fn send_payload_expect_error(&self, size: usize) -> TestResult { + let (_payload, result) = self.send_payload_inner(size)?; + match result { + Ok(_) => return Err("expected client error for oversized payload".into()), + Err(err) => *self.last_error.borrow_mut() = Some(err), + } + Ok(()) + } + + fn send_payload_inner( + &self, + size: usize, + ) -> TestResult<(ClientPayload, Result)> { + let payload = ClientPayload { + data: vec![7_u8; size], + }; + let mut client = self + .client + .borrow_mut() + .take() + .ok_or("client not connected")?; + let result = self.block_on(async { client.call(&payload).await })?; + *self.client.borrow_mut() = Some(client); + Ok((payload, result)) + } + + /// Verify that the client received the echoed payload. + /// + /// # Errors + /// Returns an error if the response is missing or mismatched. + pub fn verify_echo(&self) -> TestResult { + let payload_ref = self.payload.borrow(); + let response_ref = self.response.borrow(); + let payload = payload_ref.as_ref().ok_or("payload missing")?; + let response = response_ref.as_ref().ok_or("response missing")?; + if payload != response { + return Err("response did not match payload".into()); + } + self.await_server()?; + Ok(()) + } + + /// Verify that a client error was captured. + /// + /// # Errors + /// Returns an error if no failure was observed. + pub fn verify_error(&self) -> TestResult { + let error_ref = self.last_error.borrow(); + error_ref + .as_ref() + .ok_or("expected client error was not captured")?; + self.await_server()?; + Ok(()) + } + + fn await_server(&self) -> TestResult { + if let Some(handle) = self.server.borrow_mut().take() { + self.block_on(async { + handle + .await + .map_err(|err| format!("server task failed: {err}")) + })??; + } + Ok(()) + } +} diff --git a/tests/worlds/codec_error/decoder_ops.rs b/tests/fixtures/codec_error/decoder_ops.rs similarity index 100% rename from tests/worlds/codec_error/decoder_ops.rs rename to tests/fixtures/codec_error/decoder_ops.rs diff --git a/tests/worlds/codec_error/mod.rs b/tests/fixtures/codec_error/mod.rs similarity index 91% rename from tests/worlds/codec_error/mod.rs rename to tests/fixtures/codec_error/mod.rs index 836dd06a..619909ad 100644 --- a/tests/worlds/codec_error/mod.rs +++ b/tests/fixtures/codec_error/mod.rs @@ -1,17 +1,15 @@ -//! Test world for codec error taxonomy scenarios. +//! `CodecErrorWorld` fixture for rstest-bdd tests. //! -//! Verifies that codec errors are correctly classified and that recovery -//! policies are applied as documented. Uses real decoder operations for -//! end-to-end validation. -#![cfg(not(loom))] +//! Verifies codec error taxonomy and recovery policy defaults. mod decoder_ops; use bytes::BytesMut; -use cucumber::World; +use rstest::fixture; use wireframe::codec::{CodecError, EofError, FramingError, ProtocolError, RecoveryPolicy}; -use super::TestResult; +/// `TestResult` for step definitions. +pub use crate::common::TestResult; /// Codec error type for test scenarios. #[derive(Clone, Copy, Debug, Default)] @@ -44,7 +42,7 @@ pub enum EofVariant { } /// Test world for codec error taxonomy scenarios. -#[derive(Debug, Default, World)] +#[derive(Debug, Default)] pub struct CodecErrorWorld { /// Current error type category being tested. error_type: ErrorType, @@ -66,6 +64,13 @@ pub struct CodecErrorWorld { pub(crate) clean_close_detected: bool, } +/// Fixture for `CodecErrorWorld`. +#[rustfmt::skip] +#[fixture] +pub fn codec_error_world() -> CodecErrorWorld { + CodecErrorWorld::default() +} + impl CodecErrorWorld { /// Set the current error type being tested. /// @@ -89,7 +94,7 @@ impl CodecErrorWorld { /// /// # Errors /// - /// Returns an error if `variant` is not a recognized framing variant. + /// Returns an error if `variant` is not a recognised framing variant. pub fn set_framing_variant(&mut self, variant: &str) -> TestResult { self.framing_variant = match variant { "oversized" => FramingVariant::Oversized, @@ -107,7 +112,7 @@ impl CodecErrorWorld { /// /// # Errors /// - /// Returns an error if `variant` is not a recognized EOF variant. + /// Returns an error if `variant` is not a recognised EOF variant. pub fn set_eof_variant(&mut self, variant: &str) -> TestResult { self.eof_variant = match variant { "clean_close" => EofVariant::CleanClose, @@ -167,7 +172,7 @@ impl CodecErrorWorld { /// /// # Errors /// - /// Returns an error if `expected` is not a recognized policy or if the + /// Returns an error if `expected` is not a recognised policy or if the /// actual policy does not match the expected policy. pub fn verify_recovery_policy(&self, expected: &str) -> TestResult { let expected_policy = match expected { diff --git a/tests/worlds/codec_stateful.rs b/tests/fixtures/codec_stateful.rs similarity index 94% rename from tests/worlds/codec_stateful.rs rename to tests/fixtures/codec_stateful.rs index b8c5eac4..ea1cfc36 100644 --- a/tests/worlds/codec_stateful.rs +++ b/tests/fixtures/codec_stateful.rs @@ -1,8 +1,7 @@ -//! Test world for stateful codec sequence counters. +//! `CodecStatefulWorld` fixture for rstest-bdd tests. //! //! Ensures per-connection codec state is isolated so sequence numbers reset //! between client connections. -#![cfg(not(loom))] use std::{ net::SocketAddr, @@ -10,8 +9,8 @@ use std::{ }; use bytes::{Buf, BufMut, Bytes, BytesMut}; -use cucumber::World; use futures::{SinkExt, StreamExt}; +use rstest::fixture; use tokio::{ io::AsyncWriteExt, net::{TcpListener, TcpStream}, @@ -25,7 +24,8 @@ use wireframe::{ serializer::BincodeSerializer, }; -use super::TestResult; +/// Re-export `TestResult` from common for use in steps. +pub use crate::common::TestResult; #[derive(Debug)] struct SeqFrame { @@ -168,7 +168,7 @@ async fn serve_stateful_connections( } } -#[derive(Debug, Default, World)] +#[derive(Debug, Default)] /// Test world for stateful codec scenarios. pub struct CodecStatefulWorld { server: Option, @@ -177,6 +177,16 @@ pub struct CodecStatefulWorld { second_sequences: Vec, } +/// Fixture for stateful codec scenarios used by rstest-bdd steps. +/// +/// Note: rustfmt collapses simple fixtures into one line, which triggers +/// `unused_braces`, so keep `rustfmt::skip`. +#[rustfmt::skip] +#[fixture] +pub fn codec_stateful_world() -> CodecStatefulWorld { + CodecStatefulWorld::default() +} + impl CodecStatefulWorld { /// Start a server using the sequence-aware codec. /// diff --git a/tests/worlds/correlation.rs b/tests/fixtures/correlation.rs similarity index 82% rename from tests/worlds/correlation.rs rename to tests/fixtures/correlation.rs index 8041826e..7298757d 100644 --- a/tests/worlds/correlation.rs +++ b/tests/fixtures/correlation.rs @@ -1,11 +1,11 @@ -//! Test world for correlation identifier scenarios. +//! `CorrelationWorld` fixture for rstest-bdd tests. //! -//! Provides [`CorrelationWorld`] to verify that frames carry the correct -//! correlation identifiers across streaming and multi-packet contexts. -#![cfg(not(loom))] +//! Converted from Cucumber World to rstest fixture. The struct and its methods +//! remain largely unchanged; only the trait derivation and fixture function are +//! added. use async_stream::try_stream; -use cucumber::World; +use rstest::fixture; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use wireframe::{ @@ -15,33 +15,30 @@ use wireframe::{ response::FrameStream, }; -use super::{TestResult, build_small_queues}; +// Import build_small_queues from parent module +use crate::build_small_queues; +/// Re-export `TestResult` from common for use in steps. +pub use crate::common::TestResult; -#[derive(Debug, Default, World)] +#[derive(Debug, Default)] /// Test world capturing correlation expectations for frame emission. pub struct CorrelationWorld { expected: Option, frames: Vec, } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] +#[fixture] +pub fn correlation_world() -> CorrelationWorld { + CorrelationWorld::default() +} + impl CorrelationWorld { /// Record the correlation identifier expected on emitted frames. - /// - /// # Examples - /// ```ignore - /// let mut world = CorrelationWorld::default(); - /// world.set_expected(Some(99)); - /// ``` pub fn set_expected(&mut self, expected: Option) { self.expected = expected; } /// Return the correlation identifier configured for this scenario. - /// - /// # Examples - /// ```ignore - /// let mut world = CorrelationWorld::default(); - /// world.set_expected(None); - /// assert_eq!(world.expected(), None); - /// ``` #[must_use] pub fn expected(&self) -> Option { self.expected } @@ -91,7 +88,8 @@ impl CorrelationWorld { Ok(()) } - /// Verify that all received frames respect the configured correlation expectation. + /// Verify that all received frames respect the configured correlation + /// expectation. /// /// # Errors /// Returns an error if any frame violates the stored correlation diff --git a/tests/worlds/fragment/mod.rs b/tests/fixtures/fragment/mod.rs similarity index 95% rename from tests/worlds/fragment/mod.rs rename to tests/fixtures/fragment/mod.rs index a0ed1a78..6fe4222f 100644 --- a/tests/worlds/fragment/mod.rs +++ b/tests/fixtures/fragment/mod.rs @@ -1,16 +1,12 @@ -//! Test world for fragmentation scenarios covering both directions. +//! `FragmentWorld` fixture for rstest-bdd tests. //! -//! Provides [`FragmentWorld`] to verify ordering, completion detection, and -//! error handling across the fragmentation behavioural tests while also -//! exercising outbound fragmentation by chunking payloads via the helper -//! `Fragmenter` and inspecting the resulting `FragmentBatch` state. -#![cfg(not(loom))] +//! Tracks fragmentation and reassembly state for fragment scenarios. mod reassembly; use std::{num::NonZeroUsize, time::Instant}; -use cucumber::World; +use rstest::fixture; use wireframe::fragment::{ FragmentBatch, FragmentError, @@ -26,10 +22,11 @@ use wireframe::fragment::{ ReassemblyError, }; -use super::TestResult; +/// `TestResult` for step definitions. +pub use crate::common::TestResult; -#[derive(Debug, World)] /// Test world tracking fragmentation state across behavioural scenarios. +#[derive(Debug)] pub struct FragmentWorld { series: Option, last_result: Option>, @@ -58,6 +55,14 @@ impl Default for FragmentWorld { } } +/// Fixture for `FragmentWorld`. +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] +#[fixture] +pub fn fragment_world() -> FragmentWorld { + FragmentWorld::default() +} + impl FragmentWorld { /// Start tracking a new logical message. pub fn start_series(&mut self, message_id: u64) { diff --git a/tests/worlds/fragment/reassembly.rs b/tests/fixtures/fragment/reassembly.rs similarity index 100% rename from tests/worlds/fragment/reassembly.rs rename to tests/fixtures/fragment/reassembly.rs diff --git a/tests/fixtures/message_assembler.rs b/tests/fixtures/message_assembler.rs new file mode 100644 index 00000000..fb5a1967 --- /dev/null +++ b/tests/fixtures/message_assembler.rs @@ -0,0 +1,324 @@ +//! `MessageAssemblerWorld` fixture for rstest-bdd tests. +//! +//! Provides header parsing helpers for message assembler scenarios. + +mod types; +use std::{fmt, io}; + +use bytes::{BufMut, BytesMut}; +use rstest::fixture; +pub use types::*; +use wireframe::{ + message_assembler::{FrameHeader, MessageAssembler, ParsedFrameHeader}, + test_helpers::TestAssembler, +}; + +use crate::TestApp; +/// `TestResult` for step definitions. +pub use crate::common::TestResult; + +/// Specification for first-frame header encoding used in tests. +#[derive(Debug, Clone, Copy)] +pub struct FirstHeaderSpec { + /// Message key to encode into the header. + pub key: MessageKey, + /// Metadata length in bytes. + pub metadata_len: MetadataLength, + /// Body length in bytes for this frame. + pub body_len: BodyLength, + /// Optional total body length across all frames. + pub total_len: Option, + /// Whether the frame is the final one in the series. + pub is_last: bool, +} + +impl FirstHeaderSpec { + /// Create a first header spec with default metadata and flags. + pub fn new(key: MessageKey, body_len: BodyLength) -> Self { + Self { + key, + metadata_len: MetadataLength(0), + body_len, + total_len: None, + is_last: false, + } + } + + /// Set the metadata length to encode into the header. + pub fn with_metadata_len(mut self, metadata_len: MetadataLength) -> Self { + self.metadata_len = metadata_len; + self + } + + /// Set the total message length to encode into the header. + pub fn with_total_len(mut self, total_len: BodyLength) -> Self { + self.total_len = Some(total_len); + self + } + + /// Set whether the header should be marked as the final frame. + pub fn with_last_flag(mut self, is_last: bool) -> Self { + self.is_last = is_last; + self + } +} + +/// Specification for continuation-frame header encoding used in tests. +#[derive(Debug, Clone, Copy)] +pub struct ContinuationHeaderSpec { + /// Message key to encode into the header. + pub key: MessageKey, + /// Body length in bytes for this frame. + pub body_len: BodyLength, + /// Optional sequence number. + pub sequence: Option, + /// Whether the frame is the final one in the series. + pub is_last: bool, +} + +impl ContinuationHeaderSpec { + /// Create a continuation header spec with default sequence and flags. + pub fn new(key: MessageKey, body_len: BodyLength) -> Self { + Self { + key, + body_len, + sequence: None, + is_last: false, + } + } + + /// Set the continuation sequence to encode into the header. + pub fn with_sequence(mut self, sequence: SequenceNumber) -> Self { + self.sequence = Some(sequence); + self + } + + /// Set whether the header should be marked as the final frame. + pub fn with_last_flag(mut self, is_last: bool) -> Self { + self.is_last = is_last; + self + } +} + +#[derive(Debug, Clone, Copy)] +struct HeaderEnvelope { + kind: u8, + flags: u8, + key: u64, +} + +/// Test world for message assembler header parsing. +#[derive(Default)] +pub struct MessageAssemblerWorld { + payload: Option>, + parsed: Option, + error: Option, + app: Option, +} + +impl fmt::Debug for MessageAssemblerWorld { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MessageAssemblerWorld") + .field("payload", &self.payload) + .field("parsed", &self.parsed) + .field("error", &self.error) + .field( + "app", + &self.app.as_ref().map(|_| "wireframe::app::WireframeApp"), + ) + .finish() + } +} + +/// Fixture for `MessageAssemblerWorld`. +#[rustfmt::skip] +#[fixture] +pub fn message_assembler_world() -> MessageAssemblerWorld { + MessageAssemblerWorld::default() +} + +impl MessageAssemblerWorld { + /// Generic assertion helper for any header field. + /// + /// The extractor returns `Result` to allow for both type-checking + /// and field extraction. For fields present in both header types, the extractor + /// should always succeed. For type-specific fields, the extractor can return an + /// error if the header type is incorrect. + fn assert_field(&self, field_name: &str, expected: &T, extractor: F) -> TestResult + where + T: PartialEq + fmt::Display + Copy, + F: FnOnce(&FrameHeader) -> Result, + { + let parsed = self.parsed.as_ref().ok_or("no parsed header")?; + let actual = extractor(parsed.header())?; + if actual != *expected { + return Err(format!("expected {field_name} {expected}, got {actual}").into()); + } + Ok(()) + } + + /// Assert a field specific to First headers. + fn assert_first_field(&self, field_name: &str, expected: &T, extractor: F) -> TestResult + where + T: PartialEq + fmt::Display + Copy, + F: FnOnce(&wireframe::message_assembler::FirstFrameHeader) -> T, + { + self.assert_field(field_name, expected, |header| { + if let FrameHeader::First(header) = header { + Ok(extractor(header)) + } else { + Err("expected first header".to_string()) + } + }) + } + + /// Assert a field specific to Continuation headers. + fn assert_continuation_field( + &self, + field_name: &str, + expected: &T, + extractor: F, + ) -> TestResult + where + T: PartialEq + fmt::Display + Copy, + F: FnOnce(&wireframe::message_assembler::ContinuationFrameHeader) -> T, + { + self.assert_field(field_name, expected, |header| { + if let FrameHeader::Continuation(header) = header { + Ok(extractor(header)) + } else { + Err("expected continuation header".to_string()) + } + }) + } + + /// Store an encoded first-frame header in the world payload. + /// + /// # Errors + /// + /// Returns an error if any length field exceeds the header encoding limits. + pub fn set_first_header(&mut self, spec: FirstHeaderSpec) -> TestResult { + let mut flags = 0u8; + if spec.is_last { + flags |= 0b1; + } + if spec.total_len.is_some() { + flags |= 0b10; + } + self.set_payload_with_header( + HeaderEnvelope { + kind: 0x01, + flags, + key: spec.key.0, + }, + |bytes| { + let metadata_len = + u16::try_from(spec.metadata_len.0).map_err(|_| "metadata length too large")?; + bytes.put_u16(metadata_len); + let body_len = + u32::try_from(spec.body_len.0).map_err(|_| "body length too large")?; + bytes.put_u32(body_len); + if let Some(total) = spec.total_len { + let total = u32::try_from(total.0).map_err(|_| "total length too large")?; + bytes.put_u32(total); + } + Ok(()) + }, + ) + } + + /// Store an encoded continuation-frame header in the world payload. + /// + /// # Errors + /// + /// Returns an error if any length field exceeds the header encoding limits. + pub fn set_continuation_header(&mut self, spec: ContinuationHeaderSpec) -> TestResult { + let mut flags = 0u8; + if spec.is_last { + flags |= 0b1; + } + if spec.sequence.is_some() { + flags |= 0b10; + } + self.set_payload_with_header( + HeaderEnvelope { + kind: 0x02, + flags, + key: spec.key.0, + }, + |bytes| { + let body_len = + u32::try_from(spec.body_len.0).map_err(|_| "body length too large")?; + bytes.put_u32(body_len); + if let Some(seq) = spec.sequence { + bytes.put_u32(seq.0); + } + Ok(()) + }, + ) + } + + fn set_payload_with_header(&mut self, envelope: HeaderEnvelope, encode: F) -> TestResult + where + F: FnOnce(&mut BytesMut) -> TestResult, + { + let mut bytes = BytesMut::new(); + bytes.put_u8(envelope.kind); + bytes.put_u8(envelope.flags); + bytes.put_u64(envelope.key); + encode(&mut bytes)?; + self.payload = Some(bytes.to_vec()); + Ok(()) + } + + /// Store a deliberately invalid header payload. + pub fn set_invalid_payload(&mut self) { self.payload = Some(vec![0x01]); } + + /// Parse the stored payload with the test assembler. + /// + /// # Errors + /// + /// Returns an error if no payload has been configured. + pub fn parse_header(&mut self) -> TestResult { + let payload = self.payload.as_deref().ok_or("payload not set")?; + let fallback = TestAssembler; + let assembler: &dyn MessageAssembler = match self.app.as_ref() { + Some(app) => app + .message_assembler() + .ok_or("message assembler not set")? + .as_ref(), + None => &fallback, + }; + match assembler.parse_frame_header(payload) { + Ok(parsed) => { + self.parsed = Some(parsed); + self.error = None; + } + Err(err) => { + self.parsed = None; + self.error = Some(err); + } + } + Ok(()) + } + + /// Assert that the parsed header is of the expected kind. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the kind does not match. + pub fn assert_header_kind(&self, expected: &str) -> TestResult { + let parsed = self.parsed.as_ref().ok_or("no parsed header")?; + let matches_kind = matches!( + (expected, parsed.header()), + ("first", FrameHeader::First(_)) | ("continuation", FrameHeader::Continuation(_)) + ); + if matches_kind { + Ok(()) + } else { + Err(format!("expected {expected} header").into()) + } + } +} + +mod message_assembler_asserts; diff --git a/tests/fixtures/message_assembler/message_assembler_asserts.rs b/tests/fixtures/message_assembler/message_assembler_asserts.rs new file mode 100644 index 00000000..cab93419 --- /dev/null +++ b/tests/fixtures/message_assembler/message_assembler_asserts.rs @@ -0,0 +1,161 @@ +//! Assertion helpers for `MessageAssemblerWorld`. +//! +//! This module keeps the fixture file under the 400 line guideline while +//! preserving a cohesive set of assertion helpers. + +use std::{fmt, io}; + +use wireframe::{ + message_assembler::{FrameHeader, FrameSequence}, + test_helpers::TestAssembler, +}; + +use super::{ + BodyLength, + HeaderLength, + MessageAssemblerWorld, + MessageKey, + MetadataLength, + SequenceNumber, + TestApp, + TestResult, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct DebugDisplay(T); + +impl fmt::Display for DebugDisplay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self.0) } +} + +impl MessageAssemblerWorld { + /// Assert that the parsed header contains the expected message key. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the key does not match. + pub fn assert_message_key(&self, expected: MessageKey) -> TestResult { + self.assert_field("key", &expected.0, |header| { + Ok(match header { + FrameHeader::First(header) => u64::from(header.message_key), + FrameHeader::Continuation(header) => u64::from(header.message_key), + }) + }) + } + + /// Assert that the parsed header contains the expected metadata length. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the metadata length differs. + pub fn assert_metadata_len(&self, expected: MetadataLength) -> TestResult { + self.assert_first_field("metadata length", &expected.0, |header| header.metadata_len) + } + + /// Assert that the parsed header contains the expected body length. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the body length differs. + pub fn assert_body_len(&self, expected: BodyLength) -> TestResult { + self.assert_field("body length", &expected.0, |header| { + Ok(match header { + FrameHeader::First(header) => header.body_len, + FrameHeader::Continuation(header) => header.body_len, + }) + }) + } + + /// Assert that the parsed header contains the expected total body length. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the total length differs. + pub fn assert_total_len(&self, expected: Option) -> TestResult { + let expected = DebugDisplay(expected); + self.assert_first_field("total length", &expected, |header| { + DebugDisplay(header.total_body_len.map(BodyLength)) + }) + } + + /// Assert that the parsed header contains the expected sequence. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the sequence differs. + pub fn assert_sequence(&self, expected: Option) -> TestResult { + let expected = expected.map(|sequence| FrameSequence::from(sequence.0)); + let expected = DebugDisplay(expected); + self.assert_continuation_field("sequence", &expected, |header| { + DebugDisplay(header.sequence) + }) + } + + /// Assert that the parsed header matches the expected `is_last` flag. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the flag differs. + pub fn assert_is_last(&self, expected: bool) -> TestResult { + self.assert_field("is_last", &expected, |header| { + Ok(match header { + FrameHeader::First(header) => header.is_last, + FrameHeader::Continuation(header) => header.is_last, + }) + }) + } + + /// Assert that the parsed header length matches the expected value. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the length differs. + pub fn assert_header_len(&self, expected: HeaderLength) -> TestResult { + let parsed = self.parsed.as_ref().ok_or("no parsed header")?; + let actual = parsed.header_len(); + if actual != expected.0 { + return Err(format!("expected header length {}, got {actual}", expected.0).into()); + } + Ok(()) + } + + /// Assert that the parse failed with `InvalidData`. + /// + /// # Errors + /// + /// Returns an error if no parse error was captured or the kind differs. + pub fn assert_invalid_data_error(&self) -> TestResult { + let err = self.error.as_ref().ok_or("expected error")?; + if err.kind() != io::ErrorKind::InvalidData { + return Err(format!("expected InvalidData error, got {:?}", err.kind()).into()); + } + Ok(()) + } + + /// Store a wireframe app configured with a test message assembler. + /// + /// # Errors + /// + /// Returns an error if the app builder fails. + pub fn set_app_with_message_assembler(&mut self) -> TestResult { + let app = TestApp::new() + .map_err(|err| format!("failed to build app: {err}"))? + .with_message_assembler(TestAssembler); + self.app = Some(app); + Ok(()) + } + + /// Assert that the app exposes a message assembler. + /// + /// # Errors + /// + /// Returns an error if the app or assembler is missing. + pub fn assert_message_assembler_configured(&self) -> TestResult { + let app = self.app.as_ref().ok_or("app not set")?; + if app.message_assembler().is_some() { + Ok(()) + } else { + Err("expected message assembler".into()) + } + } +} diff --git a/tests/fixtures/message_assembler/types.rs b/tests/fixtures/message_assembler/types.rs new file mode 100644 index 00000000..21d2cf89 --- /dev/null +++ b/tests/fixtures/message_assembler/types.rs @@ -0,0 +1,48 @@ +//! Domain-specific newtypes for message assembler test parameters. + +use std::{num::ParseIntError, str::FromStr}; + +/// Message key for frame headers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MessageKey(pub u64); + +impl FromStr for MessageKey { + type Err = ParseIntError; + fn from_str(s: &str) -> Result { s.parse().map(Self) } +} + +/// Sequence number for continuation frames. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SequenceNumber(pub u32); + +impl FromStr for SequenceNumber { + type Err = ParseIntError; + fn from_str(s: &str) -> Result { s.parse().map(Self) } +} + +/// Body length in bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BodyLength(pub usize); + +impl FromStr for BodyLength { + type Err = ParseIntError; + fn from_str(s: &str) -> Result { s.parse().map(Self) } +} + +/// Metadata length in bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MetadataLength(pub usize); + +impl FromStr for MetadataLength { + type Err = ParseIntError; + fn from_str(s: &str) -> Result { s.parse().map(Self) } +} + +/// Header length in bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HeaderLength(pub usize); + +impl FromStr for HeaderLength { + type Err = ParseIntError; + fn from_str(s: &str) -> Result { s.parse().map(Self) } +} diff --git a/tests/worlds/message_assembly.rs b/tests/fixtures/message_assembly.rs similarity index 86% rename from tests/worlds/message_assembly.rs rename to tests/fixtures/message_assembly.rs index 8c3e0e34..0536b12c 100644 --- a/tests/worlds/message_assembly.rs +++ b/tests/fixtures/message_assembly.rs @@ -1,5 +1,6 @@ -//! Test world for message assembly multiplexing and continuity validation. -#![cfg(not(loom))] +//! `MessageAssemblyWorld` fixture for rstest-bdd tests. +//! +//! Provides state and helpers for message assembly multiplexing scenarios. #[path = "message_assembly_params.rs"] mod message_assembly_params; @@ -11,8 +12,8 @@ use std::{ time::{Duration, Instant}, }; -use cucumber::World; pub use message_assembly_params::{ContinuationFrameParams, FirstFrameParams}; +use rstest::fixture; use wireframe::message_assembler::{ AssembledMessage, ContinuationFrameHeader, @@ -25,11 +26,28 @@ use wireframe::message_assembler::{ MessageSeriesError, }; -use super::TestResult; +/// Re-export `TestResult` from common for use in steps. +pub use crate::common::TestResult; +use crate::scenarios::steps::FrameId; -/// Cucumber world for message assembly tests. -#[derive(Default, World)] -#[world(init = Self::new)] +/// Configuration for message assembly state initialisation. +#[derive(Debug, Clone, Copy)] +pub struct AssemblyConfig { + pub max_message_size: usize, + pub timeout_seconds: u64, +} + +impl AssemblyConfig { + pub fn new(max_message_size: usize, timeout_seconds: u64) -> Self { + Self { + max_message_size, + timeout_seconds, + } + } +} + +/// Test world for message assembly multiplexing scenarios. +#[derive(Default)] pub struct MessageAssemblyWorld { state: Option, current_time: Option, @@ -65,31 +83,27 @@ impl fmt::Debug for MessageAssemblyWorld { } } -impl MessageAssemblyWorld { - fn new() -> Self { - Self { - state: None, - current_time: None, - pending_first_frames: VecDeque::new(), - last_result: None, - completed_messages: Vec::new(), - evicted_keys: Vec::new(), - } - } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] +#[fixture] +pub fn message_assembly_world() -> MessageAssemblyWorld { + MessageAssemblyWorld::default() +} +impl MessageAssemblyWorld { /// Initialise the assembly state with size limit and timeout. /// /// # Panics /// - /// Panics if `max_size` is zero because `MessageAssemblyState` requires a - /// positive size limit. - pub fn create_state(&mut self, max_size: usize, timeout_secs: u64) { - let Some(size) = NonZeroUsize::new(max_size) else { - panic!("max_size must be non-zero for MessageAssemblyState"); + /// Panics if `max_message_size` is zero because `MessageAssemblyState` + /// requires a positive size limit. + pub fn create_state(&mut self, config: AssemblyConfig) { + let Some(size) = NonZeroUsize::new(config.max_message_size) else { + panic!("max_message_size must be non-zero for MessageAssemblyState"); }; self.state = Some(MessageAssemblyState::new( size, - Duration::from_secs(timeout_secs), + Duration::from_secs(config.timeout_seconds), )); self.current_time = Some(Instant::now()); self.pending_first_frames.clear(); @@ -272,13 +286,13 @@ impl MessageAssemblyWorld { /// Whether the last error is a duplicate frame. #[must_use] - pub fn is_duplicate_frame(&self, key: MessageKey, sequence: FrameSequence) -> bool { + pub fn is_duplicate_frame(&self, frame_id: FrameId) -> bool { matches!( self.last_error(), Some(MessageAssemblyError::Series(MessageSeriesError::DuplicateFrame { key: k, sequence: s, - })) if *k == key && *s == sequence + })) if *k == frame_id.key && *s == frame_id.sequence ) } @@ -287,9 +301,7 @@ impl MessageAssemblyWorld { pub fn is_missing_first_frame(&self, key: MessageKey) -> bool { matches!( self.last_error(), - Some(MessageAssemblyError::Series(MessageSeriesError::MissingFirstFrame { - key: k, - })) if *k == key + Some(MessageAssemblyError::Series(MessageSeriesError::MissingFirstFrame { key: k })) if *k == key ) } diff --git a/tests/worlds/message_assembly_params.rs b/tests/fixtures/message_assembly_params.rs similarity index 99% rename from tests/worlds/message_assembly_params.rs rename to tests/fixtures/message_assembly_params.rs index 07ab6dd0..246b8d78 100644 --- a/tests/worlds/message_assembly_params.rs +++ b/tests/fixtures/message_assembly_params.rs @@ -1,5 +1,4 @@ //! Parameter objects for message assembly test steps. -#![cfg(not(loom))] use wireframe::message_assembler::{FrameSequence, MessageKey}; diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs new file mode 100644 index 00000000..d69efa54 --- /dev/null +++ b/tests/fixtures/mod.rs @@ -0,0 +1,19 @@ +//! Fixture definitions for rstest-bdd tests. +//! +//! Each world from the former Cucumber tests is converted to an rstest fixture +//! here. + +pub mod client_lifecycle; +pub mod client_messaging; +pub mod client_preamble; +pub mod client_runtime; +pub mod codec_error; +pub mod codec_stateful; +pub mod correlation; +pub mod fragment; +pub mod message_assembler; +pub mod message_assembly; +pub mod multi_packet; +pub mod panic; +pub mod request_parts; +pub mod stream_end; diff --git a/tests/worlds/multi_packet.rs b/tests/fixtures/multi_packet.rs similarity index 90% rename from tests/worlds/multi_packet.rs rename to tests/fixtures/multi_packet.rs index 3012fdea..cfc5ee7b 100644 --- a/tests/worlds/multi_packet.rs +++ b/tests/fixtures/multi_packet.rs @@ -1,17 +1,18 @@ -//! Test world for multi-packet channel scenarios. +//! `MultiPacketWorld` fixture for rstest-bdd tests. //! -//! Provides [`MultiPacketWorld`] to verify message ordering, back-pressure -//! handling, and channel lifecycle in cucumber-based behaviour tests. -#![cfg(not(loom))] +//! Provides test fixtures to verify message ordering, back-pressure handling, +//! and channel lifecycle. use std::{error::Error, fmt}; -use cucumber::World; +use rstest::fixture; use tokio::sync::mpsc::{self, error::TrySendError}; use tokio_util::sync::CancellationToken; use wireframe::{Response, connection::ConnectionActor}; -use super::{TestResult, build_small_queues}; +use crate::build_small_queues; +/// Re-export `TestResult` from common for use in steps. +pub use crate::common::TestResult; #[derive(Debug)] struct WireframeRunError(wireframe::WireframeError); @@ -22,13 +23,20 @@ impl fmt::Display for WireframeRunError { impl Error for WireframeRunError {} -#[derive(Debug, Default, World)] +#[derive(Debug, Default)] /// Test world exercising multi-packet channel behaviours and back-pressure. pub struct MultiPacketWorld { messages: Vec, is_overflow_error: bool, } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] +#[fixture] +pub fn multi_packet_world() -> MultiPacketWorld { + MultiPacketWorld::default() +} + impl MultiPacketWorld { async fn collect_frames_from(rx: mpsc::Receiver) -> TestResult> { let (queues, handle) = build_small_queues::()?; diff --git a/tests/worlds/panic.rs b/tests/fixtures/panic.rs similarity index 87% rename from tests/worlds/panic.rs rename to tests/fixtures/panic.rs index a03e3fba..119dbc5e 100644 --- a/tests/worlds/panic.rs +++ b/tests/fixtures/panic.rs @@ -1,16 +1,17 @@ -//! Test world for panic-on-connection scenarios. +//! `PanicWorld` fixture for rstest-bdd tests. //! -//! Provides [`PanicWorld`] to ensure the server remains resilient when +//! Provides test fixtures to ensure the server remains resilient when //! connection setup handlers panic before a client fully connects. -#![cfg(not(loom))] use std::net::SocketAddr; -use cucumber::World; +use rstest::fixture; use tokio::{net::TcpStream, sync::oneshot}; use wireframe::server::WireframeServer; -use super::{TestApp, TestResult, unused_listener}; +/// `TestResult` for step definitions. +pub use crate::common::TestResult; +use crate::common::{TestApp, unused_listener}; #[derive(Debug)] struct PanicServer { @@ -75,13 +76,21 @@ impl Drop for PanicServer { } } -#[derive(Debug, Default, World)] /// Test world that drives a server which intentionally panics during setup. +#[derive(Debug, Default)] pub struct PanicWorld { server: Option, attempts: usize, } +/// Fixture for `PanicWorld`. +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] +#[fixture] +pub fn panic_world() -> PanicWorld { + PanicWorld::default() +} + impl PanicWorld { /// Start a server that panics during connection setup. /// diff --git a/tests/fixtures/request_parts.rs b/tests/fixtures/request_parts.rs new file mode 100644 index 00000000..d2352b16 --- /dev/null +++ b/tests/fixtures/request_parts.rs @@ -0,0 +1,137 @@ +//! `RequestPartsWorld` fixture for rstest-bdd tests. +//! +//! Converted from Cucumber World to rstest fixture. The struct and its methods +//! remain unchanged; only the trait derivation and fixture function are added. + +use std::str::FromStr; + +use rstest::fixture; +use wireframe::request::RequestParts; + +/// Re-export `TestResult` from common for use in steps. +pub use crate::common::TestResult; + +/// Request identifier wrapper for test steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequestId(pub u32); + +impl FromStr for RequestId { + type Err = std::num::ParseIntError; + + fn from_str(value: &str) -> Result { value.parse().map(RequestId) } +} + +/// Correlation identifier wrapper for test steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CorrelationId(pub u64); + +impl FromStr for CorrelationId { + type Err = std::num::ParseIntError; + + fn from_str(value: &str) -> Result { value.parse().map(CorrelationId) } +} + +/// Metadata byte wrapper for test steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MetadataByte(pub u8); + +impl FromStr for MetadataByte { + type Err = std::num::ParseIntError; + + fn from_str(value: &str) -> Result { value.parse().map(MetadataByte) } +} + +/// Test world exercising `RequestParts` metadata handling. +#[derive(Debug, Default)] +pub struct RequestPartsWorld { + parts: Option, +} + +/// Fixture for `RequestPartsWorld`. +#[rustfmt::skip] +#[fixture] +pub fn request_parts_world() -> RequestPartsWorld { + RequestPartsWorld::default() +} + +impl RequestPartsWorld { + /// Create request parts with all fields specified. + pub fn create_parts( + &mut self, + id: RequestId, + correlation_id: Option, + metadata: Vec, + ) { + self.parts = Some(RequestParts::new( + id.0, + correlation_id.map(|value| value.0), + metadata.into_iter().map(|value| value.0).collect(), + )); + } + + /// Inherit a correlation id from an external source. + /// + /// # Errors + /// Returns an error if parts have not been created. + pub fn inherit_correlation(&mut self, source: Option) -> TestResult { + let parts = self.parts.take().ok_or("request parts not created")?; + self.parts = Some(parts.inherit_correlation(source.map(|value| value.0))); + Ok(()) + } + + /// Append a byte to the metadata. + /// + /// # Errors + /// Returns an error if parts have not been created. + pub fn append_metadata_byte(&mut self, byte: MetadataByte) -> TestResult { + let parts = self.parts.as_mut().ok_or("request parts not created")?; + parts.metadata_mut().push(byte.0); + Ok(()) + } + + /// Assert the request id matches the expected value. + /// + /// # Errors + /// Returns an error if parts are missing or id does not match. + pub fn assert_id(&self, expected: RequestId) -> TestResult { + let parts = self.parts.as_ref().ok_or("request parts not created")?; + if parts.id() != expected.0 { + return Err(format!("expected id {}, got {}", expected.0, parts.id()).into()); + } + Ok(()) + } + + /// Assert the correlation id matches the expected value. + /// + /// # Errors + /// Returns an error if parts are missing or correlation id does not match. + pub fn assert_correlation_id(&self, expected: Option) -> TestResult { + let parts = self.parts.as_ref().ok_or("request parts not created")?; + let expected_raw = expected.map(|value| value.0); + if parts.correlation_id() != expected_raw { + return Err(format!( + "expected correlation_id {:?}, got {:?}", + expected_raw, + parts.correlation_id() + ) + .into()); + } + Ok(()) + } + + /// Assert the metadata length matches the expected value. + /// + /// # Errors + /// Returns an error if parts are missing or length does not match. + pub fn assert_metadata_length(&self, expected: usize) -> TestResult { + let parts = self.parts.as_ref().ok_or("request parts not created")?; + if parts.metadata().len() != expected { + return Err(format!( + "expected metadata length {expected}, got {}", + parts.metadata().len() + ) + .into()); + } + Ok(()) + } +} diff --git a/tests/worlds/stream_end.rs b/tests/fixtures/stream_end.rs similarity index 92% rename from tests/worlds/stream_end.rs rename to tests/fixtures/stream_end.rs index cae7dd27..0b01d274 100644 --- a/tests/worlds/stream_end.rs +++ b/tests/fixtures/stream_end.rs @@ -1,14 +1,13 @@ -//! Test world for verifying stream terminators and multi-packet lifecycle logs. +//! `StreamEndWorld` fixture for rstest-bdd tests. //! -//! Provides [`StreamEndWorld`] so cucumber scenarios can observe terminator -//! frames, closure reasons, and shutdown handling for streaming responses. -#![cfg(not(loom))] +//! Provides test fixtures to verify terminator frames and multi-packet +//! termination logging for streaming responses. use std::{mem, sync::Arc}; use async_stream::try_stream; -use cucumber::World; use log::Level; +use rstest::fixture; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use wireframe::{ @@ -18,10 +17,12 @@ use wireframe::{ }; use wireframe_testing::{LoggerHandle, logger}; -use super::{Terminator, TestResult, build_small_queues}; +/// Re-export `TestResult` from common for use in steps. +pub use crate::common::TestResult; +use crate::{build_small_queues, terminator::Terminator}; -#[derive(Debug, Default, World)] /// Test world capturing frames and logs for stream termination scenarios. +#[derive(Debug, Default)] pub struct StreamEndWorld { frames: Vec, logs: Vec<(Level, String)>, @@ -229,3 +230,10 @@ impl StreamEndWorld { Ok(()) } } + +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] +#[fixture] +pub fn stream_end_world() -> StreamEndWorld { + StreamEndWorld::default() +} diff --git a/tests/scenarios/client_lifecycle_scenarios.rs b/tests/scenarios/client_lifecycle_scenarios.rs new file mode 100644 index 00000000..886b3ad8 --- /dev/null +++ b/tests/scenarios/client_lifecycle_scenarios.rs @@ -0,0 +1,37 @@ +//! Scenario tests for client lifecycle hook behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::client_lifecycle::*; + +#[scenario( + path = "tests/features/client_lifecycle.feature", + name = "Setup hook invoked on successful connection" +)] +fn setup_hook_invoked(client_lifecycle_world: ClientLifecycleWorld) { + let _ = client_lifecycle_world; +} + +#[scenario( + path = "tests/features/client_lifecycle.feature", + name = "Teardown hook invoked when connection closes" +)] +fn teardown_hook_invoked(client_lifecycle_world: ClientLifecycleWorld) { + let _ = client_lifecycle_world; +} + +#[scenario( + path = "tests/features/client_lifecycle.feature", + name = "Error hook invoked on receive failure" +)] +fn error_hook_invoked(client_lifecycle_world: ClientLifecycleWorld) { + let _ = client_lifecycle_world; +} + +#[scenario( + path = "tests/features/client_lifecycle.feature", + name = "Lifecycle hooks work with preamble callbacks" +)] +fn lifecycle_with_preamble(client_lifecycle_world: ClientLifecycleWorld) { + let _ = client_lifecycle_world; +} diff --git a/tests/scenarios/client_messaging_scenarios.rs b/tests/scenarios/client_messaging_scenarios.rs new file mode 100644 index 00000000..0e162c87 --- /dev/null +++ b/tests/scenarios/client_messaging_scenarios.rs @@ -0,0 +1,53 @@ +//! Scenario tests for client messaging behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::client_messaging::*; + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client auto-generates correlation ID when sending envelope" +)] +fn auto_generated_correlation(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client preserves explicit correlation ID when sending envelope" +)] +fn explicit_correlation(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client call_correlated validates response correlation ID" +)] +fn call_correlated_matches(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client detects correlation ID mismatch" +)] +fn detects_mismatch(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client generates unique correlation IDs for sequential requests" +)] +fn unique_correlation_ids(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client round-trips multiple message types" +)] +fn round_trip_messages(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} diff --git a/tests/scenarios/client_preamble_scenarios.rs b/tests/scenarios/client_preamble_scenarios.rs new file mode 100644 index 00000000..5ce76bbf --- /dev/null +++ b/tests/scenarios/client_preamble_scenarios.rs @@ -0,0 +1,37 @@ +//! Scenario tests for wireframe client preamble behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::client_preamble::*; + +#[scenario( + path = "tests/features/client_preamble.feature", + name = "Client sends preamble and server acknowledges" +)] +fn client_preamble_send_and_ack(client_preamble_world: ClientPreambleWorld) { + let _ = client_preamble_world; +} + +#[scenario( + path = "tests/features/client_preamble.feature", + name = "Client receives server acknowledgement in success callback" +)] +fn client_preamble_receives_ack(client_preamble_world: ClientPreambleWorld) { + let _ = client_preamble_world; +} + +#[scenario( + path = "tests/features/client_preamble.feature", + name = "Client preamble timeout triggers failure callback" +)] +fn client_preamble_timeout_failure(client_preamble_world: ClientPreambleWorld) { + let _ = client_preamble_world; +} + +#[scenario( + path = "tests/features/client_preamble.feature", + name = "Client without preamble connects normally" +)] +fn client_preamble_no_preamble(client_preamble_world: ClientPreambleWorld) { + let _ = client_preamble_world; +} diff --git a/tests/scenarios/client_runtime_scenarios.rs b/tests/scenarios/client_runtime_scenarios.rs new file mode 100644 index 00000000..af44b831 --- /dev/null +++ b/tests/scenarios/client_runtime_scenarios.rs @@ -0,0 +1,21 @@ +//! Scenario tests for wireframe client runtime behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::client_runtime::*; + +#[scenario( + path = "tests/features/client_runtime.feature", + name = "Client sends and receives with configured frame length" +)] +fn client_runtime_send_receive(client_runtime_world: ClientRuntimeWorld) { + let _ = client_runtime_world; +} + +#[scenario( + path = "tests/features/client_runtime.feature", + name = "Client reports errors when server frame limit is exceeded" +)] +fn client_runtime_oversize_error(client_runtime_world: ClientRuntimeWorld) { + let _ = client_runtime_world; +} diff --git a/tests/scenarios/codec_error_scenarios.rs b/tests/scenarios/codec_error_scenarios.rs new file mode 100644 index 00000000..20c77e8f --- /dev/null +++ b/tests/scenarios/codec_error_scenarios.rs @@ -0,0 +1,37 @@ +//! Scenario tests for codec error taxonomy and recovery behaviour. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::codec_error::*; + +#[scenario( + path = "tests/features/codec_error.feature", + name = "Clean EOF at frame boundary" +)] +#[tokio::test(flavor = "current_thread")] +async fn codec_error_clean_eof(codec_error_world: CodecErrorWorld) { let _ = codec_error_world; } + +#[scenario( + path = "tests/features/codec_error.feature", + name = "Premature EOF mid-frame" +)] +#[tokio::test(flavor = "current_thread")] +async fn codec_error_mid_frame(codec_error_world: CodecErrorWorld) { let _ = codec_error_world; } + +#[scenario( + path = "tests/features/codec_error.feature", + name = "Oversized frame produces framing error" +)] +#[tokio::test(flavor = "current_thread")] +async fn codec_error_oversized_frame(codec_error_world: CodecErrorWorld) { + let _ = codec_error_world; +} + +#[scenario( + path = "tests/features/codec_error.feature", + name = "Recovery policy defaults" +)] +#[tokio::test(flavor = "current_thread")] +async fn codec_error_recovery_defaults(codec_error_world: CodecErrorWorld) { + let _ = codec_error_world; +} diff --git a/tests/scenarios/codec_stateful_scenarios.rs b/tests/scenarios/codec_stateful_scenarios.rs new file mode 100644 index 00000000..4320712c --- /dev/null +++ b/tests/scenarios/codec_stateful_scenarios.rs @@ -0,0 +1,15 @@ +//! Scenario tests for stateful codec behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::codec_stateful::*; + +#[scenario( + path = "tests/features/codec_stateful.feature", + name = "Sequence counters reset per connection" +)] +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn sequence_counters_reset(codec_stateful_world: CodecStatefulWorld) {} diff --git a/tests/scenarios/correlation_scenarios.rs b/tests/scenarios/correlation_scenarios.rs new file mode 100644 index 00000000..aed524d0 --- /dev/null +++ b/tests/scenarios/correlation_scenarios.rs @@ -0,0 +1,23 @@ +//! Scenario tests for `correlation_id` feature. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::correlation::*; + +#[scenario( + path = "tests/features/correlation_id.feature", + name = "Streamed frames reuse the request correlation id" +)] +fn streamed_frames_correlation(correlation_world: CorrelationWorld) { let _ = correlation_world; } + +#[scenario( + path = "tests/features/correlation_id.feature", + name = "Multi-packet responses reuse the request correlation id" +)] +fn multi_packet_correlation(correlation_world: CorrelationWorld) { let _ = correlation_world; } + +#[scenario( + path = "tests/features/correlation_id.feature", + name = "Multi-packet responses clear correlation ids without a request id" +)] +fn no_correlation(correlation_world: CorrelationWorld) { let _ = correlation_world; } diff --git a/tests/scenarios/fragment_scenarios.rs b/tests/scenarios/fragment_scenarios.rs new file mode 100644 index 00000000..a3a98ad1 --- /dev/null +++ b/tests/scenarios/fragment_scenarios.rs @@ -0,0 +1,82 @@ +//! Scenario tests for fragment metadata enforcement. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::fragment::*; + +#[scenario( + path = "tests/features/fragment.feature", + name = "Sequential fragments complete a message" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_sequential_complete(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Out-of-order fragment is rejected" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_out_of_order(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Fragment from another message is rejected" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_wrong_message(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Fragment beyond the maximum index is rejected" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_index_overflow(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Final fragment at the maximum index completes the message" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_max_index_complete(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Series rejects fragments after completion" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_series_complete(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Fragmenter splits oversized payloads into sequential fragments" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragmenter_splits_payload(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Reassembler rebuilds sequential fragments" +)] +#[tokio::test(flavor = "current_thread")] +async fn reassembler_rebuilds(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Reassembler rejects messages that exceed the cap" +)] +#[tokio::test(flavor = "current_thread")] +async fn reassembler_over_limit(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Reassembler evicts stale partial messages" +)] +#[tokio::test(flavor = "current_thread")] +async fn reassembler_evicts(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Reassembler rejects out-of-order fragments" +)] +#[tokio::test(flavor = "current_thread")] +async fn reassembler_out_of_order(fragment_world: FragmentWorld) { let _ = fragment_world; } diff --git a/tests/scenarios/message_assembler_scenarios.rs b/tests/scenarios/message_assembler_scenarios.rs new file mode 100644 index 00000000..8b4fd6bd --- /dev/null +++ b/tests/scenarios/message_assembler_scenarios.rs @@ -0,0 +1,65 @@ +//! Scenario tests for message assembler header parsing. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::message_assembler::*; + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Builder exposes a configured message assembler" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_builder_configured(message_assembler_world: MessageAssemblerWorld) { + let _ = message_assembler_world; +} + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Parsing a first frame header without total length" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_first_header_without_total( + message_assembler_world: MessageAssemblerWorld, +) { + let _ = message_assembler_world; +} + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Parsing a first frame header with total length" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_first_header_with_total(message_assembler_world: MessageAssemblerWorld) { + let _ = message_assembler_world; +} + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Parsing a continuation header with sequence" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_continuation_with_sequence( + message_assembler_world: MessageAssemblerWorld, +) { + let _ = message_assembler_world; +} + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Parsing a continuation header without sequence" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_continuation_without_sequence( + message_assembler_world: MessageAssemblerWorld, +) { + let _ = message_assembler_world; +} + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Invalid header payload returns error" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_invalid_payload(message_assembler_world: MessageAssemblerWorld) { + let _ = message_assembler_world; +} diff --git a/tests/scenarios/message_assembly_scenarios.rs b/tests/scenarios/message_assembly_scenarios.rs new file mode 100644 index 00000000..c475f917 --- /dev/null +++ b/tests/scenarios/message_assembly_scenarios.rs @@ -0,0 +1,77 @@ +//! Scenario tests for message assembly multiplexing and continuity validation. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::message_assembly::*; + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Single message assembly completes successfully" +)] +fn message_assembly_single_message(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Single-frame message completes immediately" +)] +fn message_assembly_single_frame(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Interleaved messages assemble independently" +)] +fn message_assembly_interleaved(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Out-of-order continuation is rejected but assembly retained" +)] +fn message_assembly_out_of_order(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Duplicate continuation is rejected but assembly retained" +)] +fn message_assembly_duplicate_continuation(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Continuation without first frame is rejected" +)] +fn message_assembly_missing_first(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Duplicate first frame is rejected" +)] +fn message_assembly_duplicate_first(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Message exceeding size limit is rejected" +)] +fn message_assembly_too_large(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Expired assemblies are purged" +)] +fn message_assembly_expired(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); +} diff --git a/tests/scenarios/mod.rs b/tests/scenarios/mod.rs new file mode 100644 index 00000000..a66eb841 --- /dev/null +++ b/tests/scenarios/mod.rs @@ -0,0 +1,23 @@ +//! Scenario test functions for rstest-bdd. +//! +//! Each scenario from the `.feature` files has a corresponding `#[scenario]` +//! test function here. + +// Load step definitions first so compile-time validation can see them. +#[path = "../steps/mod.rs"] +pub(crate) mod steps; + +mod client_lifecycle_scenarios; +mod client_messaging_scenarios; +mod client_preamble_scenarios; +mod client_runtime_scenarios; +mod codec_error_scenarios; +mod codec_stateful_scenarios; +mod correlation_scenarios; +mod fragment_scenarios; +mod message_assembler_scenarios; +mod message_assembly_scenarios; +mod multi_packet_scenarios; +mod panic_scenarios; +mod request_parts_scenarios; +mod stream_end_scenarios; diff --git a/tests/scenarios/multi_packet_scenarios.rs b/tests/scenarios/multi_packet_scenarios.rs new file mode 100644 index 00000000..4de5f700 --- /dev/null +++ b/tests/scenarios/multi_packet_scenarios.rs @@ -0,0 +1,23 @@ +//! Scenario tests for multi-packet responses. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::multi_packet::*; + +#[scenario( + path = "tests/features/multi_packet.feature", + name = "Response::with_channel streams frames sequentially" +)] +fn multi_packet_streaming(multi_packet_world: MultiPacketWorld) { let _ = multi_packet_world; } + +#[scenario( + path = "tests/features/multi_packet.feature", + name = "no messages are emitted from a multi-packet response" +)] +fn multi_packet_empty(multi_packet_world: MultiPacketWorld) { let _ = multi_packet_world; } + +#[scenario( + path = "tests/features/multi_packet.feature", + name = "Channel capacity overflow" +)] +fn multi_packet_overflow(multi_packet_world: MultiPacketWorld) { let _ = multi_packet_world; } diff --git a/tests/scenarios/panic_scenarios.rs b/tests/scenarios/panic_scenarios.rs new file mode 100644 index 00000000..87ebf843 --- /dev/null +++ b/tests/scenarios/panic_scenarios.rs @@ -0,0 +1,11 @@ +//! Scenario tests for connection panic resilience. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::panic::*; + +#[scenario( + path = "tests/features/connection_panic.feature", + name = "connection panic does not crash server" +)] +fn panic_resilience(panic_world: PanicWorld) { let _ = panic_world; } diff --git a/tests/scenarios/request_parts_scenarios.rs b/tests/scenarios/request_parts_scenarios.rs new file mode 100644 index 00000000..9b90f9dc --- /dev/null +++ b/tests/scenarios/request_parts_scenarios.rs @@ -0,0 +1,49 @@ +//! Scenario tests for `request_parts` feature. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::request_parts::*; + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Create request parts with all fields" +)] +fn create_parts_with_all_fields(request_parts_world: RequestPartsWorld) { + let _ = request_parts_world; +} + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Request parts inherit missing correlation id" +)] +fn inherit_missing_correlation(request_parts_world: RequestPartsWorld) { + let _ = request_parts_world; +} + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Request parts override mismatched correlation id" +)] +fn override_mismatched_correlation(request_parts_world: RequestPartsWorld) { + let _ = request_parts_world; +} + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Request parts preserve correlation when source is absent" +)] +fn preserve_correlation_when_absent(request_parts_world: RequestPartsWorld) { + let _ = request_parts_world; +} + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Empty metadata is valid" +)] +fn empty_metadata_valid(request_parts_world: RequestPartsWorld) { let _ = request_parts_world; } + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Metadata can be modified after construction" +)] +fn metadata_modifiable(request_parts_world: RequestPartsWorld) { let _ = request_parts_world; } diff --git a/tests/scenarios/stream_end_scenarios.rs b/tests/scenarios/stream_end_scenarios.rs new file mode 100644 index 00000000..2266c820 --- /dev/null +++ b/tests/scenarios/stream_end_scenarios.rs @@ -0,0 +1,29 @@ +//! Scenario tests for stream terminator features. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::stream_end::*; + +#[scenario( + path = "tests/features/stream_end.feature", + name = "Connection actor emits terminator after stream" +)] +fn stream_terminator(stream_end_world: StreamEndWorld) { let _ = stream_end_world; } + +#[scenario( + path = "tests/features/stream_end.feature", + name = "Multi-packet channel emits terminator after completion" +)] +fn multi_packet_completion(stream_end_world: StreamEndWorld) { let _ = stream_end_world; } + +#[scenario( + path = "tests/features/stream_end.feature", + name = "Multi-packet channel disconnect logs termination" +)] +fn multi_packet_disconnect(stream_end_world: StreamEndWorld) { let _ = stream_end_world; } + +#[scenario( + path = "tests/features/stream_end.feature", + name = "Shutdown closes a multi-packet channel" +)] +fn multi_packet_shutdown(stream_end_world: StreamEndWorld) { let _ = stream_end_world; } diff --git a/tests/steps/client_lifecycle_steps.rs b/tests/steps/client_lifecycle_steps.rs index 3d857567..b54df642 100644 --- a/tests/steps/client_lifecycle_steps.rs +++ b/tests/steps/client_lifecycle_steps.rs @@ -1,10 +1,9 @@ -//! Steps for wireframe client lifecycle hook behavioural tests. +//! Step definitions for wireframe client lifecycle hook behavioural tests. -use cucumber::{given, then, when}; +use rstest_bdd_macros::{given, then, when}; -use crate::world::{ClientLifecycleWorld, EXPECTED_SETUP_STATE, TestResult}; +use crate::fixtures::client_lifecycle::{ClientLifecycleWorld, EXPECTED_SETUP_STATE, TestResult}; -/// Assert that a count matches the expected value, returning an appropriate error message. fn assert_count_equals(actual: usize, expected: usize, callback_name: &str) -> TestResult { if actual != expected { return Err(format!( @@ -15,77 +14,80 @@ fn assert_count_equals(actual: usize, expected: usize, callback_name: &str) -> T Ok(()) } -/// Starts a standard echo server that keeps connections open. #[given("a standard echo server")] -async fn given_standard_server(world: &mut ClientLifecycleWorld) -> TestResult { - world.start_standard_server().await +fn given_standard_server(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.start_standard_server()) } -/// Starts an echo server that disconnects immediately after accepting a connection. #[given("a standard echo server that disconnects immediately")] -async fn given_disconnecting_server(world: &mut ClientLifecycleWorld) -> TestResult { - world.start_disconnecting_server().await +fn given_disconnecting_server(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.start_disconnecting_server()) } -/// Starts a preamble-aware server that sends an acknowledgement response. #[given("a preamble-aware echo server that sends acknowledgement")] -async fn given_ack_server(world: &mut ClientLifecycleWorld) -> TestResult { - world.start_ack_server().await +fn given_ack_server(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.start_ack_server()) } -/// Connects a client with a setup callback configured. #[when("a client connects with a setup callback")] -async fn when_connect_with_setup(world: &mut ClientLifecycleWorld) -> TestResult { - world.connect_with_setup().await +fn when_connect_with_setup(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.connect_with_setup()) } -/// Connects a client with both setup and teardown callbacks configured. #[when("a client connects with setup and teardown callbacks")] -async fn when_connect_with_setup_and_teardown(world: &mut ClientLifecycleWorld) -> TestResult { - world.connect_with_setup_and_teardown().await +fn when_connect_with_setup_and_teardown( + client_lifecycle_world: &mut ClientLifecycleWorld, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.connect_with_setup_and_teardown()) } -/// Closes the client connection, triggering any teardown callbacks. #[when("the client closes the connection")] -async fn when_client_closes(world: &mut ClientLifecycleWorld) -> TestResult { - world.close_client().await; +fn when_client_closes(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.close_client()); Ok(()) } -/// Connects a client with an error callback configured. #[when("a client connects with an error callback")] -async fn when_connect_with_error_callback(world: &mut ClientLifecycleWorld) -> TestResult { - world.connect_with_error_callback().await +fn when_connect_with_error_callback( + client_lifecycle_world: &mut ClientLifecycleWorld, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.connect_with_error_callback()) } -/// Attempts to receive a message from the server, capturing any errors. #[when("the client attempts to receive a message")] -async fn when_client_attempts_receive(world: &mut ClientLifecycleWorld) -> TestResult { - world.attempt_receive().await +fn when_client_attempts_receive(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.attempt_receive()) } -/// Connects a client with preamble exchange and lifecycle callbacks configured. #[when("a client connects with preamble and lifecycle callbacks")] -async fn when_connect_with_preamble_and_lifecycle(world: &mut ClientLifecycleWorld) -> TestResult { - world.connect_with_preamble_and_lifecycle().await +fn when_connect_with_preamble_and_lifecycle( + client_lifecycle_world: &mut ClientLifecycleWorld, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.connect_with_preamble_and_lifecycle()) } -/// Asserts that the setup callback was invoked exactly once. #[then("the setup callback is invoked exactly once")] -fn then_setup_invoked_once(world: &mut ClientLifecycleWorld) -> TestResult { - assert_count_equals(world.setup_count(), 1, "setup") +fn then_setup_invoked_once(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + assert_count_equals(client_lifecycle_world.setup_count(), 1, "setup") } -/// Asserts that the teardown callback was invoked exactly once. #[then("the teardown callback is invoked exactly once")] -fn then_teardown_invoked_once(world: &mut ClientLifecycleWorld) -> TestResult { - assert_count_equals(world.teardown_count(), 1, "teardown") +fn then_teardown_invoked_once(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + assert_count_equals(client_lifecycle_world.teardown_count(), 1, "teardown") } -/// Asserts that the teardown callback received the expected state from setup. #[then("the teardown callback receives the state from setup")] -fn then_teardown_receives_state(world: &mut ClientLifecycleWorld) -> TestResult { - let state = world.teardown_received_state(); +fn then_teardown_receives_state(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let state = client_lifecycle_world.teardown_received_state(); let expected = EXPECTED_SETUP_STATE as usize; if state != expected { return Err(format!("expected teardown to receive state {expected}, got {state}").into()); @@ -93,29 +95,28 @@ fn then_teardown_receives_state(world: &mut ClientLifecycleWorld) -> TestResult Ok(()) } -/// Asserts that the error callback was invoked at least once. #[then("the error callback is invoked")] -fn then_error_callback_invoked(world: &mut ClientLifecycleWorld) -> TestResult { - let count = world.error_count(); +fn then_error_callback_invoked(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let count = client_lifecycle_world.error_count(); if count == 0 { return Err("expected error callback to be invoked at least once".into()); } Ok(()) } -/// Asserts that the preamble success callback was invoked. #[then("the preamble success callback is invoked")] -fn then_preamble_success_invoked(world: &mut ClientLifecycleWorld) -> TestResult { - if !world.preamble_success_invoked() { +fn then_preamble_success_invoked(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + if !client_lifecycle_world.preamble_success_invoked() { return Err("expected preamble success callback to be invoked".into()); } Ok(()) } -/// Asserts that the captured client error is a Disconnected error. #[then("the client error is Disconnected")] -fn then_client_error_is_disconnected(world: &mut ClientLifecycleWorld) -> TestResult { - let last_error = world +fn then_client_error_is_disconnected( + client_lifecycle_world: &mut ClientLifecycleWorld, +) -> TestResult { + let last_error = client_lifecycle_world .last_error() .ok_or("expected a captured client error in world.last_error")?; diff --git a/tests/steps/client_messaging_steps.rs b/tests/steps/client_messaging_steps.rs index 0ce19f6c..62a6ea61 100644 --- a/tests/steps/client_messaging_steps.rs +++ b/tests/steps/client_messaging_steps.rs @@ -1,103 +1,109 @@ -//! Steps for client messaging behavioural tests with correlation ID support. -//! -//! Cucumber step functions are required to be async by the framework, even when -//! they don't contain await expressions. +//! Step definitions for client messaging behavioural tests with correlation IDs. -#![expect( - clippy::unused_async, - reason = "cucumber requires async step functions" -)] +use rstest_bdd_macros::{given, then, when}; -use cucumber::{given, then, when}; - -use crate::world::{ClientMessagingWorld, TestResult}; +use crate::fixtures::client_messaging::{ClientMessagingWorld, TestResult}; #[given("an envelope echo server")] -async fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { - world.start_echo_server().await?; - world.connect_client().await +fn given_echo_server(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + client_messaging_world.start_echo_server().await?; + client_messaging_world.connect_client().await + }) } #[given("an envelope without a correlation ID")] -async fn given_envelope_without_correlation(world: &mut ClientMessagingWorld) -> TestResult { - world.set_envelope_without_correlation(); - Ok(()) +fn given_envelope_without_correlation(client_messaging_world: &mut ClientMessagingWorld) { + client_messaging_world.set_envelope_without_correlation(); } -#[given(expr = "an envelope with correlation ID {int}")] -async fn given_envelope_with_correlation( - world: &mut ClientMessagingWorld, +#[given("an envelope with correlation ID {correlation_id:u64}")] +fn given_envelope_with_correlation( + client_messaging_world: &mut ClientMessagingWorld, correlation_id: u64, -) -> TestResult { - world.set_envelope_with_correlation(correlation_id); - Ok(()) +) { + client_messaging_world.set_envelope_with_correlation(correlation_id); } -#[given(expr = "an envelope with message ID {int} and payload {string}")] -async fn given_envelope_with_payload( - world: &mut ClientMessagingWorld, +#[given("an envelope with message ID {message_id:u32} and payload {payload:string}")] +fn given_envelope_with_payload( + client_messaging_world: &mut ClientMessagingWorld, message_id: u32, payload: String, -) -> TestResult { - world.set_envelope_with_payload(message_id, &payload); - Ok(()) +) { + client_messaging_world.set_envelope_with_payload(message_id, &payload); } #[given("a server that returns mismatched correlation IDs")] -async fn given_mismatch_server(world: &mut ClientMessagingWorld) -> TestResult { - // First, abort the existing server. - world.abort_server(); - // Start a mismatch server. - world.start_mismatch_server().await?; - world.connect_client().await +fn given_mismatch_server(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + client_messaging_world.abort_server(); + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + client_messaging_world.start_mismatch_server().await?; + client_messaging_world.connect_client().await + }) } #[when("the client sends the envelope")] -async fn when_client_sends_envelope(world: &mut ClientMessagingWorld) -> TestResult { - world.send_envelope().await +fn when_client_sends_envelope(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_messaging_world.send_envelope()) } #[when("the client calls the server with call_correlated")] -async fn when_client_calls_correlated(world: &mut ClientMessagingWorld) -> TestResult { - world.call_correlated().await +fn when_client_calls_correlated(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_messaging_world.call_correlated()) } -#[when(expr = "the client sends {int} sequential envelopes")] -async fn when_client_sends_multiple(world: &mut ClientMessagingWorld, count: usize) -> TestResult { - world.send_multiple_envelopes(count).await +#[when("the client sends {count:usize} sequential envelopes")] +fn when_client_sends_multiple( + client_messaging_world: &mut ClientMessagingWorld, + count: usize, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_messaging_world.send_multiple_envelopes(count)) } #[then("the envelope is stamped with an auto-generated correlation ID")] -async fn then_auto_generated_correlation(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_auto_generated_correlation() +fn then_auto_generated_correlation( + client_messaging_world: &mut ClientMessagingWorld, +) -> TestResult { + client_messaging_world.verify_auto_generated_correlation() } -#[then(expr = "the returned correlation ID is {int}")] -async fn then_correlation_id_is(world: &mut ClientMessagingWorld, expected: u64) -> TestResult { - world.verify_correlation_id(expected) +#[then("the returned correlation ID is {expected:u64}")] +fn then_correlation_id_is( + client_messaging_world: &mut ClientMessagingWorld, + expected: u64, +) -> TestResult { + client_messaging_world.verify_correlation_id(expected) } #[then("the response has a matching correlation ID")] -async fn then_response_has_matching_correlation(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_response_correlation_matches() +fn then_response_has_matching_correlation( + client_messaging_world: &mut ClientMessagingWorld, +) -> TestResult { + client_messaging_world.verify_response_correlation_matches() } #[then("no correlation mismatch error occurs")] -async fn then_no_mismatch_error(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_no_mismatch_error() +fn then_no_mismatch_error(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + client_messaging_world.verify_no_mismatch_error() } #[then("a CorrelationMismatch error is returned")] -async fn then_mismatch_error(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_mismatch_error() +fn then_mismatch_error(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + client_messaging_world.verify_mismatch_error() } #[then("each envelope has a unique correlation ID")] -async fn then_unique_correlation_ids(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_unique_correlation_ids() +fn then_unique_correlation_ids(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + client_messaging_world.verify_unique_correlation_ids() } -#[then(expr = "the response contains the same message ID and payload")] -async fn then_response_matches(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_response_matches_expected() +#[then("the response contains the same message ID and payload")] +fn then_response_matches(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + client_messaging_world.verify_response_matches_expected() } diff --git a/tests/steps/client_preamble_steps.rs b/tests/steps/client_preamble_steps.rs index 87cf524e..ca6e57ff 100644 --- a/tests/steps/client_preamble_steps.rs +++ b/tests/steps/client_preamble_steps.rs @@ -1,56 +1,73 @@ -//! Steps for wireframe client preamble behavioural tests. +//! Step definitions for wireframe client preamble behavioural tests. -use cucumber::{given, then, when}; +use rstest_bdd_macros::{given, then, when}; -use crate::world::{ClientPreambleWorld, TestResult}; +use crate::fixtures::client_preamble::{ClientPreambleWorld, TestResult}; #[given("a preamble-aware echo server")] -async fn given_preamble_server(world: &mut ClientPreambleWorld) -> TestResult { - world.start_preamble_server().await +fn given_preamble_server(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.start_preamble_server()) } -#[given("a preamble-aware echo server that sends acknowledgement")] -async fn given_ack_server(world: &mut ClientPreambleWorld) -> TestResult { - world.start_ack_server().await +#[given("a preamble-aware echo server that sends an acknowledgement preamble")] +fn given_ack_server(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.start_ack_server()) } #[given("a slow preamble server that never responds")] -async fn given_slow_server(world: &mut ClientPreambleWorld) -> TestResult { - world.start_slow_server().await +fn given_slow_server(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.start_slow_server()) } -#[given("a standard echo server")] -async fn given_standard_server(world: &mut ClientPreambleWorld) -> TestResult { - world.start_standard_server().await +#[given("a standard echo server without preamble support")] +fn given_standard_server(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.start_standard_server()) } -#[when(expr = "a client connects with a preamble containing version {int}")] -async fn when_connect_with_version(world: &mut ClientPreambleWorld, version: u16) -> TestResult { - world.connect_with_preamble(version).await +#[when("a client connects with a preamble containing version {version:u16}")] +fn when_connect_with_version( + client_preamble_world: &mut ClientPreambleWorld, + version: u16, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.connect_with_preamble(version)) } #[when("a client connects with a preamble and reads the acknowledgement")] -async fn when_connect_with_ack(world: &mut ClientPreambleWorld) -> TestResult { - world.connect_with_ack().await +fn when_connect_with_ack(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.connect_with_ack()) } -#[when(expr = "a client connects with a {int}ms preamble timeout")] -async fn when_connect_with_timeout(world: &mut ClientPreambleWorld, timeout_ms: u64) -> TestResult { - world.connect_with_timeout(timeout_ms).await +#[when("a client connects with a {timeout_ms:u64}ms preamble timeout")] +fn when_connect_with_timeout( + client_preamble_world: &mut ClientPreambleWorld, + timeout_ms: u64, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.connect_with_timeout(timeout_ms)) } #[when("a client connects without a preamble")] -async fn when_connect_without_preamble(world: &mut ClientPreambleWorld) -> TestResult { - world.connect_without_preamble().await +fn when_connect_without_preamble(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.connect_without_preamble()) } -#[then(expr = "the server receives the preamble with version {int}")] -fn then_server_receives_preamble(world: &mut ClientPreambleWorld, version: u16) -> TestResult { - if world.server_received_version() != Some(version) { +#[then("the server receives the preamble with version {version:u16}")] +fn then_server_receives_preamble( + client_preamble_world: &mut ClientPreambleWorld, + version: u16, +) -> TestResult { + if client_preamble_world.server_received_version() != Some(version) { return Err(format!( "expected server to receive version {}, got {:?}", version, - world.server_received_version() + client_preamble_world.server_received_version() ) .into()); } @@ -58,46 +75,46 @@ fn then_server_receives_preamble(world: &mut ClientPreambleWorld, version: u16) } #[then("the client success callback is invoked")] -fn then_success_callback_invoked(world: &mut ClientPreambleWorld) -> TestResult { - if !world.success_invoked() { +fn then_success_callback_invoked(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + if !client_preamble_world.success_invoked() { return Err("expected success callback to be invoked".into()); } - world.abort_server(); + client_preamble_world.abort_server(); Ok(()) } #[then("the client receives an accepted acknowledgement")] -fn then_receives_ack(world: &mut ClientPreambleWorld) -> TestResult { - if !world.ack_accepted() { +fn then_receives_ack(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + if !client_preamble_world.ack_accepted() { return Err("expected client to receive accepted acknowledgement".into()); } - world.abort_server(); + client_preamble_world.abort_server(); Ok(()) } #[then("the client fails with a timeout error")] -fn then_timeout_error(world: &mut ClientPreambleWorld) -> TestResult { - if !world.was_timeout_error() { +fn then_timeout_error(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + if !client_preamble_world.was_timeout_error() { return Err("expected timeout error".into()); } - world.abort_server(); + client_preamble_world.abort_server(); Ok(()) } #[then("the failure callback is invoked")] -fn then_failure_callback_invoked(world: &mut ClientPreambleWorld) -> TestResult { - if !world.failure_invoked() { +fn then_failure_callback_invoked(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + if !client_preamble_world.failure_invoked() { return Err("expected failure callback to be invoked".into()); } - world.abort_server(); + client_preamble_world.abort_server(); Ok(()) } #[then("the client connects successfully")] -fn then_connects_successfully(world: &mut ClientPreambleWorld) -> TestResult { - if !world.is_connected() { +fn then_connects_successfully(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + if !client_preamble_world.is_connected() { return Err("expected client to connect successfully".into()); } - world.abort_server(); + client_preamble_world.abort_server(); Ok(()) } diff --git a/tests/steps/client_runtime_steps.rs b/tests/steps/client_runtime_steps.rs new file mode 100644 index 00000000..25bf349e --- /dev/null +++ b/tests/steps/client_runtime_steps.rs @@ -0,0 +1,44 @@ +//! Step definitions for wireframe client runtime behavioural tests. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::client_runtime::{ClientRuntimeWorld, TestResult}; + +#[given("a wireframe echo server allowing frames up to {max_frame_length:usize} bytes")] +fn given_server( + client_runtime_world: &mut ClientRuntimeWorld, + max_frame_length: usize, +) -> TestResult { + client_runtime_world.start_server(max_frame_length) +} + +#[given("a wireframe client configured with max frame length {max_frame_length:usize}")] +fn given_client( + client_runtime_world: &mut ClientRuntimeWorld, + max_frame_length: usize, +) -> TestResult { + client_runtime_world.connect_client(max_frame_length) +} + +#[when("the client sends a payload of {size:usize} bytes")] +fn when_send_payload(client_runtime_world: &mut ClientRuntimeWorld, size: usize) -> TestResult { + client_runtime_world.send_payload(size) +} + +#[when("the client sends an oversized payload of {size:usize} bytes")] +fn when_send_oversized_payload( + client_runtime_world: &mut ClientRuntimeWorld, + size: usize, +) -> TestResult { + client_runtime_world.send_payload_expect_error(size) +} + +#[then("the client receives the echoed payload")] +fn then_receives_echo(client_runtime_world: &mut ClientRuntimeWorld) -> TestResult { + client_runtime_world.verify_echo() +} + +#[then("the client reports a framing error")] +fn then_reports_error(client_runtime_world: &mut ClientRuntimeWorld) -> TestResult { + client_runtime_world.verify_error() +} diff --git a/tests/steps/client_steps.rs b/tests/steps/client_steps.rs deleted file mode 100644 index dc9684ae..00000000 --- a/tests/steps/client_steps.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Steps for wireframe client runtime behavioural tests. - -use cucumber::{given, then, when}; - -use crate::world::{ClientRuntimeWorld, TestResult}; - -#[given(expr = "a wireframe echo server allowing frames up to {int} bytes")] -async fn given_server(world: &mut ClientRuntimeWorld, max_frame_length: usize) -> TestResult { - world.start_server(max_frame_length).await -} - -#[given(expr = "a wireframe client configured with max frame length {int}")] -async fn given_client(world: &mut ClientRuntimeWorld, max_frame_length: usize) -> TestResult { - world.connect_client(max_frame_length).await -} - -#[when(expr = "the client sends a payload of {int} bytes")] -async fn when_send_payload(world: &mut ClientRuntimeWorld, size: usize) -> TestResult { - world.send_payload(size).await -} - -#[when(expr = "the client sends an oversized payload of {int} bytes")] -async fn when_send_oversized_payload(world: &mut ClientRuntimeWorld, size: usize) -> TestResult { - world.send_payload_expect_error(size).await -} - -#[then("the client receives the echoed payload")] -async fn then_receives_echo(world: &mut ClientRuntimeWorld) -> TestResult { - world.verify_echo().await -} - -#[then("the client reports a framing error")] -async fn then_reports_error(world: &mut ClientRuntimeWorld) -> TestResult { - world.verify_error().await -} diff --git a/tests/steps/codec_error_steps.rs b/tests/steps/codec_error_steps.rs index eb0370d4..5dcd6548 100644 --- a/tests/steps/codec_error_steps.rs +++ b/tests/steps/codec_error_steps.rs @@ -1,39 +1,37 @@ -//! Steps for codec error taxonomy behavioural tests. +//! Step definitions for codec error taxonomy behavioural tests. //! //! These steps exercise real codec operations to verify error handling //! and recovery policy behaviour. -use cucumber::{given, then, when}; +use rstest_bdd_macros::{given, then, when}; -use crate::world::{CodecErrorWorld, TestResult}; +use crate::fixtures::codec_error::{CodecErrorWorld, TestResult}; // ============================================================================= // Given steps // ============================================================================= -#[given(expr = "a wireframe server with default codec")] -fn given_server_default(world: &mut CodecErrorWorld) { world.setup_default_codec(); } +#[given("a wireframe server with default codec")] +fn given_server_default(codec_error_world: &mut CodecErrorWorld) { + codec_error_world.setup_default_codec(); +} -#[given(expr = "a wireframe server with max frame length {int} bytes")] -fn given_server_max_frame(world: &mut CodecErrorWorld, max_len: usize) { - world.setup_codec_with_max_length(max_len); +#[given("a wireframe server with max frame length {max_len:usize} bytes")] +fn given_server_max_frame(codec_error_world: &mut CodecErrorWorld, max_len: usize) { + codec_error_world.setup_codec_with_max_length(max_len); } -#[given(expr = "a codec error of type {word} with variant {word}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber step macros require owned String for {word} captures" -)] +#[given("a codec error of type {error_type} with variant {variant}")] fn given_error_type_variant( - world: &mut CodecErrorWorld, + codec_error_world: &mut CodecErrorWorld, error_type: String, variant: String, ) -> TestResult { - world.set_error_type(&error_type)?; + codec_error_world.set_error_type(&error_type)?; match error_type.as_str() { - "framing" => world.set_framing_variant(&variant)?, - "eof" => world.set_eof_variant(&variant)?, - "protocol" | "io" => {} // No sub-variants to set + "framing" => codec_error_world.set_framing_variant(&variant)?, + "eof" => codec_error_world.set_eof_variant(&variant)?, + "protocol" | "io" => {} _ => return Err(format!("unknown error type: {error_type}").into()), } Ok(()) @@ -44,34 +42,37 @@ fn given_error_type_variant( // ============================================================================= #[when("a client connects and sends a complete frame")] -fn when_client_sends_complete(world: &mut CodecErrorWorld) -> TestResult { +fn when_client_sends_complete(codec_error_world: &mut CodecErrorWorld) -> TestResult { // Send a small payload that fits within the frame limit - world.send_complete_frame(&[1, 2, 3, 4]) + codec_error_world.send_complete_frame(&[1, 2, 3, 4]) } #[when("the client closes the connection cleanly")] -fn when_client_closes_clean(world: &mut CodecErrorWorld) -> TestResult { +fn when_client_closes_clean(codec_error_world: &mut CodecErrorWorld) -> TestResult { // Decode the complete frame, then call decode_eof on empty buffer - world.decode_eof_clean_close() + codec_error_world.decode_eof_clean_close() } #[when("a client connects and sends partial frame data")] -fn when_client_sends_partial(world: &mut CodecErrorWorld) { +fn when_client_sends_partial(codec_error_world: &mut CodecErrorWorld) { // Send header indicating 100 bytes, but no payload - world.send_partial_frame_header_only(); + codec_error_world.send_partial_frame_header_only(); } #[when("the client closes the connection abruptly")] -fn when_client_closes_abrupt(world: &mut CodecErrorWorld) -> TestResult { +fn when_client_closes_abrupt(codec_error_world: &mut CodecErrorWorld) -> TestResult { // Call decode_eof with partial data in buffer - world.decode_eof_with_partial_data() + codec_error_world.decode_eof_with_partial_data() } -#[when(expr = "a client sends a frame larger than {int} bytes")] -fn when_client_sends_oversized(world: &mut CodecErrorWorld, max: usize) -> TestResult { +#[when("a client sends a frame larger than {max_len:usize} bytes")] +fn when_client_sends_oversized( + codec_error_world: &mut CodecErrorWorld, + max_len: usize, +) -> TestResult { // Attempt to encode a payload larger than max_frame_length // Add 1 to exceed the limit - world.encode_oversized_frame(max + 1) + codec_error_world.encode_oversized_frame(max_len + 1) } // ============================================================================= @@ -79,23 +80,21 @@ fn when_client_sends_oversized(world: &mut CodecErrorWorld, max: usize) -> TestR // ============================================================================= #[then("the server detects a clean EOF")] -fn then_server_clean_eof(world: &mut CodecErrorWorld) -> TestResult { world.verify_clean_eof() } +fn then_server_clean_eof(codec_error_world: &mut CodecErrorWorld) -> TestResult { + codec_error_world.verify_clean_eof() +} #[then("the server detects a mid-frame EOF with partial data")] -fn then_server_mid_frame_eof(world: &mut CodecErrorWorld) -> TestResult { - world.verify_incomplete_eof() +fn then_server_mid_frame_eof(codec_error_world: &mut CodecErrorWorld) -> TestResult { + codec_error_world.verify_incomplete_eof() } #[then("the server rejects the frame with an oversized error")] -fn then_server_oversized(world: &mut CodecErrorWorld) -> TestResult { - world.verify_oversized_error() +fn then_server_oversized(codec_error_world: &mut CodecErrorWorld) -> TestResult { + codec_error_world.verify_oversized_error() } -#[then(expr = "the default recovery policy is {word}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber step macros require owned String for {word} captures" -)] -fn then_recovery_policy(world: &mut CodecErrorWorld, policy: String) -> TestResult { - world.verify_recovery_policy(&policy) +#[then("the default recovery policy is {policy}")] +fn then_recovery_policy(codec_error_world: &mut CodecErrorWorld, policy: String) -> TestResult { + codec_error_world.verify_recovery_policy(&policy) } diff --git a/tests/steps/codec_stateful_steps.rs b/tests/steps/codec_stateful_steps.rs index 2967ae49..03b9059b 100644 --- a/tests/steps/codec_stateful_steps.rs +++ b/tests/steps/codec_stateful_steps.rs @@ -1,34 +1,51 @@ -//! Steps for stateful codec behavioural tests. +//! Step definitions for stateful codec behavioural tests. -use cucumber::{given, then, when}; +use rstest_bdd_macros::{given, then, when}; -use crate::world::{CodecStatefulWorld, TestResult}; +use crate::fixtures::codec_stateful::{CodecStatefulWorld, TestResult}; -#[given(expr = "a stateful wireframe server allowing frames up to {int} bytes")] -async fn given_server(world: &mut CodecStatefulWorld, max_frame_length: usize) -> TestResult { - world.start_server(max_frame_length).await +#[given("a stateful wireframe server allowing frames up to {max_frame_length:usize} bytes")] +fn given_server( + codec_stateful_world: &mut CodecStatefulWorld, + max_frame_length: usize, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_stateful_world.start_server(max_frame_length)) } -#[when(expr = "the first client sends {int} requests")] -async fn when_first_client_sends(world: &mut CodecStatefulWorld, count: usize) -> TestResult { - world.send_first_requests(count).await +#[when("the first client sends {count:usize} requests")] +fn when_first_client_sends( + codec_stateful_world: &mut CodecStatefulWorld, + count: usize, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_stateful_world.send_first_requests(count)) } -#[when(expr = "the second client sends {int} request")] -async fn when_second_client_sends(world: &mut CodecStatefulWorld, count: usize) -> TestResult { - world.send_second_requests(count).await +#[when("the second client sends {count:usize} request")] +fn when_second_client_sends( + codec_stateful_world: &mut CodecStatefulWorld, + count: usize, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_stateful_world.send_second_requests(count)) } -#[then(expr = "the first client observes sequence numbers {int} and {int}")] -async fn then_first_client_sequences( - world: &mut CodecStatefulWorld, +#[then("the first client observes sequence numbers {first:u64} and {second:u64}")] +fn then_first_client_sequences( + codec_stateful_world: &mut CodecStatefulWorld, first: u64, second: u64, ) -> TestResult { - world.verify_first_sequences(&[first, second]).await + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_stateful_world.verify_first_sequences(&[first, second])) } -#[then(expr = "the second client observes sequence number {int}")] -async fn then_second_client_sequence(world: &mut CodecStatefulWorld, seq: u64) -> TestResult { - world.verify_second_sequences(&[seq]).await +#[then("the second client observes sequence number {seq:u64}")] +fn then_second_client_sequence( + codec_stateful_world: &mut CodecStatefulWorld, + seq: u64, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_stateful_world.verify_second_sequences(&[seq])) } diff --git a/tests/steps/correlation_steps.rs b/tests/steps/correlation_steps.rs index cc99d1a7..3879111a 100644 --- a/tests/steps/correlation_steps.rs +++ b/tests/steps/correlation_steps.rs @@ -1,34 +1,50 @@ -//! Steps for `correlation_id` behavioural tests. -use cucumber::{given, then, when}; +//! Step definitions for `correlation_id` behavioural tests. +//! +//! Steps are synchronous but call async world methods via +//! `Runtime::new().block_on(...)`. -use crate::world::{CorrelationWorld, TestResult}; +use rstest_bdd_macros::{given, then, when}; -#[given(expr = "a correlation id {int}")] -fn given_cid(world: &mut CorrelationWorld, id: u64) { world.set_expected(Some(id)); } +use crate::fixtures::correlation::{CorrelationWorld, TestResult}; + +#[given("a correlation id {id:u64}")] +fn given_cid(correlation_world: &mut CorrelationWorld, id: u64) { + correlation_world.set_expected(Some(id)); +} #[given("no correlation id")] -fn given_no_correlation(world: &mut CorrelationWorld) { world.set_expected(None); } +fn given_no_correlation(correlation_world: &mut CorrelationWorld) { + correlation_world.set_expected(None); +} #[when("a stream of frames is processed")] -async fn when_process(world: &mut CorrelationWorld) -> TestResult { world.process().await } +fn when_process(correlation_world: &mut CorrelationWorld) -> TestResult { + // Create a new runtime for this step since we can't block_on within an + // existing runtime + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(correlation_world.process()) +} #[when("a multi-packet channel emits frames")] -async fn when_process_multi(world: &mut CorrelationWorld) -> TestResult { - world.process_multi().await +fn when_process_multi(correlation_world: &mut CorrelationWorld) -> TestResult { + // Create a new runtime for this step since we can't block_on within an + // existing runtime + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(correlation_world.process_multi()) } -#[then(expr = "each emitted frame uses correlation id {int}")] -fn then_verify(world: &mut CorrelationWorld, id: u64) -> TestResult { - if world.expected() != Some(id) { +#[then("each emitted frame uses correlation id {id:u64}")] +fn then_verify(correlation_world: &mut CorrelationWorld, id: u64) -> TestResult { + if correlation_world.expected() != Some(id) { return Err("mismatched expected correlation id".into()); } - world.verify() + correlation_world.verify() } #[then("each emitted frame has no correlation id")] -fn then_verify_absent(world: &mut CorrelationWorld) -> TestResult { - if world.expected().is_some() { +fn then_verify_absent(correlation_world: &mut CorrelationWorld) -> TestResult { + if correlation_world.expected().is_some() { return Err("expected correlation id should be cleared".into()); } - world.verify() + correlation_world.verify() } diff --git a/tests/steps/fragment_steps.rs b/tests/steps/fragment_steps.rs index 926007ef..33d45f76 100644 --- a/tests/steps/fragment_steps.rs +++ b/tests/steps/fragment_steps.rs @@ -1,184 +1,207 @@ -//! Steps for fragment metadata behavioural tests. +//! Step definitions for fragment metadata behavioural tests. + use std::time::Duration; -use cucumber::{given, then, when}; +use rstest_bdd_macros::{given, then, when}; use wireframe::{FragmentHeader, FragmentIndex, MessageId}; -use crate::world::{FragmentWorld, TestResult}; +use crate::fixtures::fragment::{FragmentWorld, TestResult}; -#[given(expr = "a fragment series for message {int}")] -fn given_series(world: &mut FragmentWorld, message: u64) { world.start_series(message); } +#[given("a fragment series for message {message:u64}")] +fn given_series(fragment_world: &mut FragmentWorld, message: u64) { + fragment_world.start_series(message); +} -#[given(expr = "the series expects fragment index {int}")] -fn given_series_expectation(world: &mut FragmentWorld, index: u32) -> TestResult { - world.force_next_index(index)?; +#[given("the series expects fragment index {index:u32}")] +fn given_series_expectation(fragment_world: &mut FragmentWorld, index: u32) -> TestResult { + fragment_world.force_next_index(index)?; Ok(()) } -#[when(expr = "fragment {int} arrives marked non-final")] -fn when_fragment_non_final(world: &mut FragmentWorld, index: u32) -> TestResult { - world.accept_fragment(index, false)?; +#[when("fragment {index:u32} arrives marked non-final")] +fn when_fragment_non_final(fragment_world: &mut FragmentWorld, index: u32) -> TestResult { + fragment_world.accept_fragment(index, false)?; Ok(()) } -#[when(expr = "fragment {int} arrives marked final")] -fn when_fragment_final(world: &mut FragmentWorld, index: u32) -> TestResult { - world.accept_fragment(index, true)?; +#[when("fragment {index:u32} arrives marked final")] +fn when_fragment_final(fragment_world: &mut FragmentWorld, index: u32) -> TestResult { + fragment_world.accept_fragment(index, true)?; Ok(()) } -#[when(expr = "fragment {int} from message {int} arrives marked non-final")] -fn when_fragment_other_message(world: &mut FragmentWorld, index: u32, message: u64) -> TestResult { - world.accept_fragment_from(message, index, false)?; +#[when("fragment {index:u32} from message {message:u64} arrives marked non-final")] +fn when_fragment_other_message( + fragment_world: &mut FragmentWorld, + index: u32, + message: u64, +) -> TestResult { + fragment_world.accept_fragment_from(message, index, false)?; Ok(()) } #[then("the fragment completes the message")] -fn then_fragment_completes(world: &mut FragmentWorld) -> TestResult { - world.assert_completion()?; +fn then_fragment_completes(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_completion()?; Ok(()) } #[then("the fragment is rejected as out-of-order")] -fn then_fragment_out_of_order(world: &mut FragmentWorld) -> TestResult { - world.assert_index_mismatch()?; +fn then_fragment_out_of_order(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_index_mismatch()?; Ok(()) } #[then("the fragment is rejected for the wrong message")] -fn then_fragment_wrong_message(world: &mut FragmentWorld) -> TestResult { - world.assert_message_mismatch()?; +fn then_fragment_wrong_message(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_message_mismatch()?; Ok(()) } #[then("the fragment is rejected for index overflow")] -fn then_fragment_overflow(world: &mut FragmentWorld) -> TestResult { - world.assert_index_overflow()?; +fn then_fragment_overflow(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_index_overflow()?; Ok(()) } #[then("the fragment is rejected because the series is complete")] -fn then_fragment_complete(world: &mut FragmentWorld) -> TestResult { - world.assert_series_complete_error()?; +fn then_fragment_complete(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_series_complete_error()?; Ok(()) } -#[given(expr = "a fragmenter capped at {int} bytes per fragment")] -fn given_fragmenter(world: &mut FragmentWorld, max_payload: usize) -> TestResult { - world.configure_fragmenter(max_payload)?; +#[given("a fragmenter capped at {max_payload:usize} bytes per fragment")] +fn given_fragmenter(fragment_world: &mut FragmentWorld, max_payload: usize) -> TestResult { + fragment_world.configure_fragmenter(max_payload)?; Ok(()) } -#[when(expr = "the fragmenter splits a payload of {int} bytes")] -fn when_fragmenter_splits(world: &mut FragmentWorld, len: usize) -> TestResult { - world.fragment_payload(len)?; +#[when("the fragmenter splits a payload of {len:usize} bytes")] +fn when_fragmenter_splits(fragment_world: &mut FragmentWorld, len: usize) -> TestResult { + fragment_world.fragment_payload(len)?; Ok(()) } -#[then(expr = "the fragmenter produces {int} fragments")] -fn then_fragment_count(world: &mut FragmentWorld, expected: usize) -> TestResult { - world.assert_fragment_count(expected)?; +#[then("the fragmenter produces {expected:usize} fragments")] +fn then_fragment_count(fragment_world: &mut FragmentWorld, expected: usize) -> TestResult { + fragment_world.assert_fragment_count(expected)?; Ok(()) } -#[then(expr = "fragment {int} carries {int} bytes")] -fn then_fragment_payload_len(world: &mut FragmentWorld, index: usize, len: usize) -> TestResult { - world.assert_fragment_payload_len(index, len)?; +#[then("fragment {index:usize} carries {len:usize} bytes")] +fn then_fragment_payload_len( + fragment_world: &mut FragmentWorld, + index: usize, + len: usize, +) -> TestResult { + fragment_world.assert_fragment_payload_len(index, len)?; Ok(()) } -#[then(expr = "fragment {int} is marked final")] -fn then_fragment_final(world: &mut FragmentWorld, index: usize) -> TestResult { - world.assert_fragment_final_flag(index, true)?; +#[then("fragment {index:usize} is marked final")] +fn then_fragment_final(fragment_world: &mut FragmentWorld, index: usize) -> TestResult { + fragment_world.assert_fragment_final_flag(index, true)?; Ok(()) } -#[then(expr = "fragment {int} is marked non-final")] -fn then_fragment_non_final(world: &mut FragmentWorld, index: usize) -> TestResult { - world.assert_fragment_final_flag(index, false)?; +#[then("fragment {index:usize} is marked non-final")] +fn then_fragment_non_final(fragment_world: &mut FragmentWorld, index: usize) -> TestResult { + fragment_world.assert_fragment_final_flag(index, false)?; Ok(()) } -#[then(expr = "the fragments use message id {int}")] -fn then_fragment_message_id(world: &mut FragmentWorld, message_id: u64) -> TestResult { - world.assert_message_id(message_id)?; +#[then("the fragments use message id {message_id:u64}")] +fn then_fragment_message_id(fragment_world: &mut FragmentWorld, message_id: u64) -> TestResult { + fragment_world.assert_message_id(message_id)?; Ok(()) } -#[given(expr = "a reassembler allowing {int} bytes with a {int}-second reassembly timeout")] -fn given_reassembler(world: &mut FragmentWorld, max_bytes: usize, timeout_secs: u64) -> TestResult { - world.configure_reassembler(max_bytes, timeout_secs)?; +#[given( + "a reassembler allowing {max_bytes:usize} bytes with a {timeout_secs:u64}-second reassembly \ + timeout" +)] +fn given_reassembler( + fragment_world: &mut FragmentWorld, + max_bytes: usize, + timeout_secs: u64, +) -> TestResult { + fragment_world.configure_reassembler(max_bytes, timeout_secs)?; Ok(()) } -#[when(expr = "fragment {int} for message {int} with {int} bytes arrives marked non-final")] +#[when( + "fragment {index:u32} for message {message:u64} with {len:usize} bytes arrives marked \ + non-final" +)] fn when_reassembler_fragment_non_final( - world: &mut FragmentWorld, + fragment_world: &mut FragmentWorld, index: u32, message: u64, len: usize, ) -> TestResult { let header = FragmentHeader::new(MessageId::new(message), FragmentIndex::new(index), false); - world.push_fragment(header, len)?; + fragment_world.push_fragment(header, len)?; Ok(()) } -#[when(expr = "fragment {int} for message {int} with {int} bytes arrives marked final")] +#[when( + "fragment {index:u32} for message {message:u64} with {len:usize} bytes arrives marked final" +)] fn when_reassembler_fragment_final( - world: &mut FragmentWorld, + fragment_world: &mut FragmentWorld, index: u32, message: u64, len: usize, ) -> TestResult { let header = FragmentHeader::new(MessageId::new(message), FragmentIndex::new(index), true); - world.push_fragment(header, len)?; + fragment_world.push_fragment(header, len)?; Ok(()) } -#[when(expr = "time advances by {int} seconds")] -fn when_time_advances(world: &mut FragmentWorld, seconds: u64) -> TestResult { - world.advance_time(Duration::from_secs(seconds))?; +#[when("time advances by {seconds:u64} seconds for reassembly")] +fn when_time_advances(fragment_world: &mut FragmentWorld, seconds: u64) -> TestResult { + fragment_world.advance_time(Duration::from_secs(seconds))?; Ok(()) } #[when("expired reassembly buffers are purged")] -fn when_reassembly_purged(world: &mut FragmentWorld) -> TestResult { - world.purge_reassembly()?; +fn when_reassembly_purged(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.purge_reassembly()?; Ok(()) } -#[then(expr = "the reassembler outputs a payload of {int} bytes")] -fn then_reassembled_len(world: &mut FragmentWorld, expected: usize) -> TestResult { - world.assert_reassembled_len(expected)?; +#[then("the reassembler outputs a payload of {expected:usize} bytes")] +fn then_reassembled_len(fragment_world: &mut FragmentWorld, expected: usize) -> TestResult { + fragment_world.assert_reassembled_len(expected)?; Ok(()) } #[then("no message has been reassembled yet")] -fn then_no_reassembled_message(world: &mut FragmentWorld) -> TestResult { - world.assert_no_reassembly()?; +fn then_no_reassembled_message(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_no_reassembly()?; Ok(()) } #[then("the reassembler reports a message-too-large error")] -fn then_reassembly_over_limit(world: &mut FragmentWorld) -> TestResult { - world.assert_reassembly_over_limit()?; +fn then_reassembly_over_limit(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_reassembly_over_limit()?; Ok(()) } #[then("the reassembler reports an out-of-order fragment error")] -fn then_reassembly_out_of_order(world: &mut FragmentWorld) -> TestResult { - world.assert_reassembly_out_of_order()?; +fn then_reassembly_out_of_order(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_reassembly_out_of_order()?; Ok(()) } -#[then(expr = "the reassembler is buffering {int} messages")] -fn then_buffered_messages(world: &mut FragmentWorld, expected: usize) -> TestResult { - world.assert_buffered_messages(expected)?; +#[then("the reassembler is buffering {expected:usize} messages")] +fn then_buffered_messages(fragment_world: &mut FragmentWorld, expected: usize) -> TestResult { + fragment_world.assert_buffered_messages(expected)?; Ok(()) } -#[then(expr = "message {int} is evicted")] -fn then_message_evicted(world: &mut FragmentWorld, message: u64) -> TestResult { - world.assert_evicted_message(message)?; +#[then("message {message:u64} is evicted")] +fn then_message_evicted(fragment_world: &mut FragmentWorld, message: u64) -> TestResult { + fragment_world.assert_evicted_message(message)?; Ok(()) } diff --git a/tests/steps/message_assembler_steps.rs b/tests/steps/message_assembler_steps.rs index c167cb23..555aba8b 100644 --- a/tests/steps/message_assembler_steps.rs +++ b/tests/steps/message_assembler_steps.rs @@ -1,183 +1,167 @@ //! Step definitions for message assembler header parsing. -use cucumber::{given, then, when}; - -use crate::world::{ContinuationHeaderSpec, FirstHeaderSpec, MessageAssemblerWorld}; - -const DEFAULT_METADATA_LEN: usize = 0; -const FLAG_NONE: bool = false; -const FLAG_LAST: bool = true; -const NO_SEQUENCE: Option = None; -const NO_TOTAL_LEN: Option = None; - -// Helper builders to reduce duplication in step definitions -fn first_header_without_total(key: u64, metadata_len: usize, body_len: usize) -> FirstHeaderSpec { - FirstHeaderSpec { - key, - metadata_len, - body_len, - total_len: NO_TOTAL_LEN, - is_last: FLAG_NONE, - } -} - -fn first_header_with_total(key: u64, body_len: usize, total_len: usize) -> FirstHeaderSpec { - FirstHeaderSpec { - key, - metadata_len: DEFAULT_METADATA_LEN, - body_len, - total_len: Some(total_len), - is_last: FLAG_LAST, - } -} - -fn continuation_header_with_sequence( - key: u64, - body_len: usize, - sequence: u32, -) -> ContinuationHeaderSpec { - ContinuationHeaderSpec { - key, - body_len, - sequence: Some(sequence), - is_last: FLAG_NONE, - } -} - -fn continuation_header_without_sequence(key: u64, body_len: usize) -> ContinuationHeaderSpec { - ContinuationHeaderSpec { - key, - body_len, - sequence: NO_SEQUENCE, - is_last: FLAG_LAST, - } -} - -// Cucumber step definitions -#[given(expr = "a first frame header with key {int} metadata length {int} body length {int}")] +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::message_assembler::{ + BodyLength, + ContinuationHeaderSpec, + FirstHeaderSpec, + HeaderLength, + MessageAssemblerWorld, + MessageKey, + MetadataLength, + SequenceNumber, + TestResult, +}; + +#[given( + "a first frame header with key {key:u64} metadata length {metadata_len:usize} body length \ + {body_len:usize}" +)] fn given_first_header( - world: &mut MessageAssemblerWorld, + message_assembler_world: &mut MessageAssemblerWorld, key: u64, metadata_len: usize, body_len: usize, -) -> crate::world::TestResult { - world.set_first_header(first_header_without_total(key, metadata_len, body_len)) +) -> TestResult { + message_assembler_world.set_first_header( + FirstHeaderSpec::new(MessageKey(key), BodyLength(body_len)) + .with_metadata_len(MetadataLength(metadata_len)), + ) } -#[given(expr = "a first frame header with key {int} body length {int} total {int}")] +#[given( + "a first frame header with key {key:u64} body length {body_len:usize} total {total_len:usize}" +)] fn given_first_header_with_total( - world: &mut MessageAssemblerWorld, + message_assembler_world: &mut MessageAssemblerWorld, key: u64, body_len: usize, total_len: usize, -) -> crate::world::TestResult { - world.set_first_header(first_header_with_total(key, body_len, total_len)) +) -> TestResult { + message_assembler_world.set_first_header( + FirstHeaderSpec::new(MessageKey(key), BodyLength(body_len)) + .with_total_len(BodyLength(total_len)) + .with_last_flag(true), + ) } -#[given(expr = "a continuation header with key {int} body length {int} sequence {int}")] +#[given( + "a continuation header with key {key:u64} body length {body_len:usize} sequence {sequence:u32}" +)] fn given_continuation_header_with_sequence( - world: &mut MessageAssemblerWorld, + message_assembler_world: &mut MessageAssemblerWorld, key: u64, body_len: usize, sequence: u32, -) -> crate::world::TestResult { - world.set_continuation_header(continuation_header_with_sequence(key, body_len, sequence)) +) -> TestResult { + message_assembler_world.set_continuation_header( + ContinuationHeaderSpec::new(MessageKey(key), BodyLength(body_len)) + .with_sequence(SequenceNumber(sequence)), + ) } -#[given(expr = "a continuation header with key {int} body length {int}")] +#[given("a continuation header with key {key:u64} body length {body_len:usize}")] fn given_continuation_header( - world: &mut MessageAssemblerWorld, + message_assembler_world: &mut MessageAssemblerWorld, key: u64, body_len: usize, -) -> crate::world::TestResult { - world.set_continuation_header(continuation_header_without_sequence(key, body_len)) +) -> TestResult { + message_assembler_world.set_continuation_header( + ContinuationHeaderSpec::new(MessageKey(key), BodyLength(body_len)).with_last_flag(true), + ) } #[given("a wireframe app with a message assembler")] -fn given_app_with_message_assembler(world: &mut MessageAssemblerWorld) -> crate::world::TestResult { - world.set_app_with_message_assembler() +fn given_app_with_message_assembler( + message_assembler_world: &mut MessageAssemblerWorld, +) -> TestResult { + message_assembler_world.set_app_with_message_assembler() } #[given("an invalid message header")] -fn given_invalid_header(world: &mut MessageAssemblerWorld) { world.set_invalid_payload(); } +fn given_invalid_header(message_assembler_world: &mut MessageAssemblerWorld) { + message_assembler_world.set_invalid_payload(); +} #[when("the message assembler parses the header")] -fn when_parsing(world: &mut MessageAssemblerWorld) -> crate::world::TestResult { - world.parse_header() +fn when_parsing(message_assembler_world: &mut MessageAssemblerWorld) -> TestResult { + message_assembler_world.parse_header() } -#[then(expr = "the parsed header is {word}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber hands {word} captures to step functions as owned strings" -)] -fn then_header_kind(world: &mut MessageAssemblerWorld, kind: String) -> crate::world::TestResult { - world.assert_header_kind(&kind) +#[then("the parsed header is {kind}")] +fn then_header_kind( + message_assembler_world: &mut MessageAssemblerWorld, + kind: String, +) -> TestResult { + message_assembler_world.assert_header_kind(&kind) } -#[then(expr = "the message key is {int}")] -fn then_message_key(world: &mut MessageAssemblerWorld, key: u64) -> crate::world::TestResult { - world.assert_message_key(key) +#[then("the message key is {key:u64}")] +fn then_message_key(message_assembler_world: &mut MessageAssemblerWorld, key: u64) -> TestResult { + message_assembler_world.assert_message_key(MessageKey(key)) } -#[then(expr = "the metadata length is {int}")] +#[then("the header metadata length is {metadata_len:usize}")] fn then_metadata_len( - world: &mut MessageAssemblerWorld, + message_assembler_world: &mut MessageAssemblerWorld, metadata_len: usize, -) -> crate::world::TestResult { - world.assert_metadata_len(metadata_len) +) -> TestResult { + message_assembler_world.assert_metadata_len(MetadataLength(metadata_len)) } -#[then(expr = "the body length is {int}")] -fn then_body_len(world: &mut MessageAssemblerWorld, body_len: usize) -> crate::world::TestResult { - world.assert_body_len(body_len) +#[then("the body length is {body_len:usize}")] +fn then_body_len( + message_assembler_world: &mut MessageAssemblerWorld, + body_len: usize, +) -> TestResult { + message_assembler_world.assert_body_len(BodyLength(body_len)) } -#[then(expr = "the header length is {int}")] +#[then("the header length is {header_len:usize}")] fn then_header_len( - world: &mut MessageAssemblerWorld, + message_assembler_world: &mut MessageAssemblerWorld, header_len: usize, -) -> crate::world::TestResult { - world.assert_header_len(header_len) +) -> TestResult { + message_assembler_world.assert_header_len(HeaderLength(header_len)) } #[then("the total body length is absent")] -fn then_total_absent(world: &mut MessageAssemblerWorld) -> crate::world::TestResult { - world.assert_total_len(None) +fn then_total_absent(message_assembler_world: &mut MessageAssemblerWorld) -> TestResult { + message_assembler_world.assert_total_len(None) } -#[then(expr = "the total body length is {int}")] -fn then_total_present(world: &mut MessageAssemblerWorld, total: usize) -> crate::world::TestResult { - world.assert_total_len(Some(total)) +#[then("the total body length is {total:usize}")] +fn then_total_present( + message_assembler_world: &mut MessageAssemblerWorld, + total: usize, +) -> TestResult { + message_assembler_world.assert_total_len(Some(BodyLength(total))) } -#[then(expr = "the sequence is {int}")] -fn then_sequence(world: &mut MessageAssemblerWorld, sequence: u32) -> crate::world::TestResult { - world.assert_sequence(Some(sequence)) +#[then("the sequence is {sequence:u32}")] +fn then_sequence(message_assembler_world: &mut MessageAssemblerWorld, sequence: u32) -> TestResult { + message_assembler_world.assert_sequence(Some(SequenceNumber(sequence))) } #[then("the sequence is absent")] -fn then_sequence_absent(world: &mut MessageAssemblerWorld) -> crate::world::TestResult { - world.assert_sequence(None) +fn then_sequence_absent(message_assembler_world: &mut MessageAssemblerWorld) -> TestResult { + message_assembler_world.assert_sequence(None) } -#[then(expr = "the frame is marked last {word}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber hands {word} captures to step functions as owned strings" -)] -fn then_is_last(world: &mut MessageAssemblerWorld, expected: String) -> crate::world::TestResult { - world.assert_is_last(expected == "true") +#[then("the frame is marked last {expected:bool}")] +fn then_is_last(message_assembler_world: &mut MessageAssemblerWorld, expected: bool) -> TestResult { + message_assembler_world.assert_is_last(expected) } #[then("the parse fails with invalid data")] -fn then_invalid_data(world: &mut MessageAssemblerWorld) -> crate::world::TestResult { - world.assert_invalid_data_error() +fn then_invalid_data(message_assembler_world: &mut MessageAssemblerWorld) -> TestResult { + message_assembler_world.assert_invalid_data_error() } #[then("the app exposes a message assembler")] fn then_app_exposes_message_assembler( - world: &mut MessageAssemblerWorld, -) -> crate::world::TestResult { - world.assert_message_assembler_configured() + message_assembler_world: &mut MessageAssemblerWorld, +) -> TestResult { + message_assembler_world.assert_message_assembler_configured() } diff --git a/tests/steps/message_assembly_steps.rs b/tests/steps/message_assembly_steps.rs index abe094d5..882d1f30 100644 --- a/tests/steps/message_assembly_steps.rs +++ b/tests/steps/message_assembly_steps.rs @@ -1,17 +1,83 @@ //! Step definitions for message assembly multiplexing and continuity validation. -use cucumber::{given, then, when}; +use std::{fmt::Debug, str::FromStr}; + +use rstest_bdd_macros::{given, then, when}; use wireframe::message_assembler::{FrameSequence, MessageKey}; -use crate::world::{ContinuationFrameParams, FirstFrameParams, MessageAssemblyWorld, TestResult}; +use crate::fixtures::message_assembly::{ + AssemblyConfig, + ContinuationFrameParams, + FirstFrameParams, + MessageAssemblyWorld, + TestResult, +}; + +/// Wrapper for message key parameters in BDD steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MessageKeyParam(pub u64); + +impl FromStr for MessageKeyParam { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { s.parse::().map(MessageKeyParam) } +} + +impl MessageKeyParam { + pub fn to_key(self) -> MessageKey { MessageKey(self.0) } +} + +/// Wrapper for sequence number parameters in BDD steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SequenceParam(pub u32); + +impl FromStr for SequenceParam { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { s.parse::().map(SequenceParam) } +} + +impl SequenceParam { + pub fn to_seq(self) -> FrameSequence { FrameSequence(self.0) } +} + +/// Wrapper for count/size parameters in BDD steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CountParam(pub usize); + +impl FromStr for CountParam { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { s.parse::().map(CountParam) } +} + +/// Wrapper for timeout duration parameters in BDD steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimeoutParam(pub u64); + +impl FromStr for TimeoutParam { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { s.parse::().map(TimeoutParam) } +} -/// Convert primitive key to domain type at the boundary. -fn to_key(key: u64) -> MessageKey { MessageKey(key) } +/// Frame identification combining key and sequence. +#[derive(Debug, Clone, Copy)] +pub struct FrameId { + pub key: MessageKey, + pub sequence: FrameSequence, +} -/// Convert primitive sequence to domain type at the boundary. -fn to_seq(seq: u32) -> FrameSequence { FrameSequence(seq) } +impl FrameId { + pub fn new(key: u64, sequence: u32) -> Self { + Self { + key: MessageKey(key), + sequence: FrameSequence(sequence), + } + } +} -/// Helper function to reduce duplication in Then step assertions +/// Helper function to reduce duplication in Then step assertions. fn assert_condition(condition: bool, error_msg: impl Into) -> TestResult { if condition { Ok(()) @@ -20,35 +86,81 @@ fn assert_condition(condition: bool, error_msg: impl Into) -> TestResult } } +fn assert_error( + world: &MessageAssemblyWorld, + check: F, + description: impl Into, +) -> TestResult +where + F: FnOnce(&MessageAssemblyWorld) -> bool, +{ + assert_condition( + check(world), + format!("{}; got {:?}", description.into(), world.last_error()), + ) +} + +fn assert_equals( + actual: &T, + expected: &T, + context: impl Into, +) -> TestResult { + assert_condition( + actual == expected, + format!( + "{}: expected {:?}, got {:?}", + context.into(), + expected, + actual + ), + ) +} + // ============================================================================= // Given steps // ============================================================================= -#[given(expr = "a message assembly state with max size {int} and timeout {int} seconds")] -fn given_state(world: &mut MessageAssemblyWorld, max_size: usize, timeout: u64) { - world.create_state(max_size, timeout); +#[rustfmt::skip] +#[given("a message assembly state with max size {max_size:CountParam} and timeout {timeout:TimeoutParam} seconds")] +fn given_state( + message_assembly_world: &mut MessageAssemblyWorld, + max_size: CountParam, + timeout: TimeoutParam, +) { + let config = AssemblyConfig::new(max_size.0, timeout.0); + message_assembly_world.create_state(config); } -#[given(expr = "a first frame for key {int} with metadata {string} and body {string}")] +#[rustfmt::skip] +#[given("a first frame for key {key:MessageKeyParam} with metadata {metadata:string} and body {body:string}")] fn given_first_frame_with_metadata( - world: &mut MessageAssemblyWorld, - key: u64, + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, metadata: String, body: String, ) { - world.add_first_frame( - FirstFrameParams::new(to_key(key), body.into_bytes()).with_metadata(metadata.into_bytes()), + message_assembly_world.add_first_frame( + FirstFrameParams::new(key.to_key(), body.into_bytes()).with_metadata(metadata.into_bytes()), ); } -#[given(expr = "a first frame for key {int} with body {string}")] -fn given_first_frame(world: &mut MessageAssemblyWorld, key: u64, body: String) { - world.add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes())); +#[given("a first frame for key {key:MessageKeyParam} with body {body:string}")] +fn given_first_frame( + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, + body: String, +) { + message_assembly_world.add_first_frame(FirstFrameParams::new(key.to_key(), body.into_bytes())); } -#[given(expr = "a final first frame for key {int} with body {string}")] -fn given_final_first_frame(world: &mut MessageAssemblyWorld, key: u64, body: String) { - world.add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes()).final_frame()); +#[given("a final first frame for key {key:MessageKeyParam} with body {body:string}")] +fn given_final_first_frame( + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, + body: String, +) { + message_assembly_world + .add_first_frame(FirstFrameParams::new(key.to_key(), body.into_bytes()).final_frame()); } // ============================================================================= @@ -57,185 +169,200 @@ fn given_final_first_frame(world: &mut MessageAssemblyWorld, key: u64, body: Str #[when("the first frame is accepted")] #[when("the first frame is accepted at time T")] -fn when_first_frame_accepted(world: &mut MessageAssemblyWorld) -> TestResult { - world.accept_first_frame() +fn when_first_frame_accepted(message_assembly_world: &mut MessageAssemblyWorld) -> TestResult { + message_assembly_world.accept_first_frame() } #[when("all first frames are accepted")] -fn when_all_first_frames_accepted(world: &mut MessageAssemblyWorld) -> TestResult { - world.accept_all_first_frames() +fn when_all_first_frames_accepted(message_assembly_world: &mut MessageAssemblyWorld) -> TestResult { + message_assembly_world.accept_all_first_frames() } -#[when(expr = "a final continuation for key {int} with sequence {int} and body {string} arrives")] +#[rustfmt::skip] +#[when("a final continuation for key {key:MessageKeyParam} with sequence {sequence:SequenceParam} and body {body:string} arrives")] fn when_final_continuation( - world: &mut MessageAssemblyWorld, - key: u64, - seq: u32, + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, + sequence: SequenceParam, body: String, ) -> TestResult { - world.accept_continuation( - ContinuationFrameParams::new(to_key(key), body.into_bytes()) - .with_sequence(to_seq(seq)) + message_assembly_world.accept_continuation( + ContinuationFrameParams::new(key.to_key(), body.into_bytes()) + .with_sequence(sequence.to_seq()) .final_frame(), ) } -#[when(expr = "a continuation for key {int} with sequence {int} arrives")] -fn when_continuation_with_seq(world: &mut MessageAssemblyWorld, key: u64, seq: u32) -> TestResult { - world.accept_continuation( - ContinuationFrameParams::new(to_key(key), b"data".to_vec()).with_sequence(to_seq(seq)), +#[when( + "a continuation for key {key:MessageKeyParam} with sequence {sequence:SequenceParam} arrives" +)] +fn when_continuation_with_seq( + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, + sequence: SequenceParam, +) -> TestResult { + message_assembly_world.accept_continuation( + ContinuationFrameParams::new(key.to_key(), b"data".to_vec()) + .with_sequence(sequence.to_seq()), ) } -#[when(expr = "a continuation for key {int} with body {string} arrives")] +#[when("a continuation for key {key:MessageKeyParam} with body {body:string} arrives")] fn when_continuation_with_body( - world: &mut MessageAssemblyWorld, - key: u64, + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, body: String, ) -> TestResult { - world.accept_continuation(ContinuationFrameParams::new(to_key(key), body.into_bytes())) + message_assembly_world.accept_continuation(ContinuationFrameParams::new( + key.to_key(), + body.into_bytes(), + )) } -#[when(expr = "another first frame for key {int} with body {string} arrives")] +#[when("another first frame for key {key:MessageKeyParam} with body {body:string} arrives")] fn when_another_first_frame( - world: &mut MessageAssemblyWorld, - key: u64, + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, body: String, ) -> TestResult { - world.add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes())); - world.accept_first_frame() + message_assembly_world.add_first_frame(FirstFrameParams::new(key.to_key(), body.into_bytes())); + message_assembly_world.accept_first_frame() } -#[when(expr = "time advances by {int} seconds")] -fn when_time_advances(world: &mut MessageAssemblyWorld, secs: u64) -> TestResult { - world.advance_time(secs) +#[when("time advances by {secs:TimeoutParam} seconds")] +fn when_time_advances( + message_assembly_world: &mut MessageAssemblyWorld, + secs: TimeoutParam, +) -> TestResult { + message_assembly_world.advance_time(secs.0) } #[when("expired assemblies are purged")] -fn when_purge_expired(world: &mut MessageAssemblyWorld) -> TestResult { world.purge_expired() } +fn when_purge_expired(message_assembly_world: &mut MessageAssemblyWorld) -> TestResult { + message_assembly_world.purge_expired() +} // ============================================================================= // Then steps // ============================================================================= #[then("the assembly result is incomplete")] -fn then_result_incomplete(world: &mut MessageAssemblyWorld) -> TestResult { +fn then_result_incomplete(message_assembly_world: &mut MessageAssemblyWorld) -> TestResult { assert_condition( - world.last_result_is_incomplete(), + message_assembly_world.last_result_is_incomplete(), "expected incomplete result", ) } -#[then(expr = "the assembly completes with body {string}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber hands {string} captures to step functions as owned strings" -)] -fn then_completes_with_body(world: &mut MessageAssemblyWorld, body: String) -> TestResult { - let actual = world +#[then("the assembly completes with body {body:string}")] +fn then_completes_with_body( + message_assembly_world: &mut MessageAssemblyWorld, + body: String, +) -> TestResult { + let actual = message_assembly_world .last_completed_body() .ok_or("expected completed message")?; - assert_condition( - actual == body.as_bytes(), - format!( - "body mismatch: expected {:?}, got {:?}", - body.as_bytes(), - actual - ), - ) + assert_equals(&actual, &body.as_bytes(), "body mismatch") } -#[then(expr = "key {int} completes with body {string}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber hands {string} captures to step functions as owned strings" -)] -fn then_key_completes(world: &mut MessageAssemblyWorld, key: u64, body: String) -> TestResult { - let actual = world - .completed_body_for_key(to_key(key)) - .ok_or_else(|| format!("expected completed message for key {key}"))?; - assert_condition( - actual == body.as_bytes(), - format!( - "body mismatch for key {key}: expected {:?}, got {:?}", - body.as_bytes(), - actual - ), +#[then("key {key:MessageKeyParam} completes with body {body:string}")] +fn then_key_completes( + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, + body: String, +) -> TestResult { + let actual = message_assembly_world + .completed_body_for_key(key.to_key()) + .ok_or_else(|| format!("expected completed message for key {}", key.0))?; + assert_equals( + &actual, + &body.as_bytes(), + format!("body mismatch for key {}", key.0), ) } -#[then(expr = "the buffered count is {int}")] -fn then_buffered_count(world: &mut MessageAssemblyWorld, count: usize) -> TestResult { - let actual = world.buffered_count(); - assert_condition( - actual == count, - format!("buffered count mismatch: expected {count}, got {actual}"), - ) +#[then("the buffered count is {count:CountParam}")] +fn then_buffered_count( + message_assembly_world: &mut MessageAssemblyWorld, + count: CountParam, +) -> TestResult { + let actual = message_assembly_world.buffered_count(); + assert_equals(&actual, &count.0, "buffered count mismatch") } -#[then(expr = "the error is sequence mismatch expecting {int} but found {int}")] +#[rustfmt::skip] +#[then("the error is sequence mismatch expecting {expected:SequenceParam} but found {found:SequenceParam}")] fn then_error_sequence_mismatch( - world: &mut MessageAssemblyWorld, - expected: u32, - found: u32, + message_assembly_world: &mut MessageAssemblyWorld, + expected: SequenceParam, + found: SequenceParam, ) -> TestResult { - assert_condition( - world.is_sequence_mismatch(to_seq(expected), to_seq(found)), - format!( - "expected sequence mismatch error, got {:?}", - world.last_error() - ), + assert_error( + message_assembly_world, + |world| world.is_sequence_mismatch(expected.to_seq(), found.to_seq()), + "expected sequence mismatch error", ) } -#[then(expr = "the error is duplicate frame for key {int} sequence {int}")] -fn then_error_duplicate_frame(world: &mut MessageAssemblyWorld, key: u64, seq: u32) -> TestResult { - assert_condition( - world.is_duplicate_frame(to_key(key), to_seq(seq)), - format!( - "expected duplicate frame error, got {:?}", - world.last_error() - ), +#[then( + "the error is duplicate frame for key {key:MessageKeyParam} sequence {sequence:SequenceParam}" +)] +fn then_error_duplicate_frame( + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, + sequence: SequenceParam, +) -> TestResult { + let frame_id = FrameId::new(key.0, sequence.0); + assert_error( + message_assembly_world, + |world| world.is_duplicate_frame(frame_id), + "expected duplicate frame error", ) } -#[then(expr = "the error is missing first frame for key {int}")] -fn then_error_missing_first_frame(world: &mut MessageAssemblyWorld, key: u64) -> TestResult { - assert_condition( - world.is_missing_first_frame(to_key(key)), - format!( - "expected missing first frame error, got {:?}", - world.last_error() - ), +#[then("the error is missing first frame for key {key:MessageKeyParam}")] +fn then_error_missing_first_frame( + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, +) -> TestResult { + assert_error( + message_assembly_world, + |world| world.is_missing_first_frame(key.to_key()), + "expected missing first frame error", ) } -#[then(expr = "the error is duplicate first frame for key {int}")] -fn then_error_duplicate_first_frame(world: &mut MessageAssemblyWorld, key: u64) -> TestResult { - assert_condition( - world.is_duplicate_first_frame(to_key(key)), - format!( - "expected duplicate first frame error, got {:?}", - world.last_error() - ), +#[then("the error is duplicate first frame for key {key:MessageKeyParam}")] +fn then_error_duplicate_first_frame( + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, +) -> TestResult { + assert_error( + message_assembly_world, + |world| world.is_duplicate_first_frame(key.to_key()), + "expected duplicate first frame error", ) } -#[then(expr = "the error is message too large for key {int}")] -fn then_error_message_too_large(world: &mut MessageAssemblyWorld, key: u64) -> TestResult { - assert_condition( - world.is_message_too_large(to_key(key)), - format!( - "expected message too large error, got {:?}", - world.last_error() - ), +#[then("the error is message too large for key {key:MessageKeyParam}")] +fn then_error_message_too_large( + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, +) -> TestResult { + assert_error( + message_assembly_world, + |world| world.is_message_too_large(key.to_key()), + "expected message too large error", ) } -#[then(expr = "key {int} was evicted")] -fn then_key_evicted(world: &mut MessageAssemblyWorld, key: u64) -> TestResult { +#[then("key {key:MessageKeyParam} was evicted")] +fn then_key_evicted( + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, +) -> TestResult { assert_condition( - world.was_evicted(to_key(key)), - format!("expected key {key} to be evicted"), + message_assembly_world.was_evicted(key.to_key()), + format!("expected key {} to be evicted", key.0), ) } diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index da24d446..a6f4f598 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -1,12 +1,12 @@ -//! Aggregates step definitions for Cucumber tests. +//! Step definitions for rstest-bdd tests. //! -//! This module exposes all Given-When-Then steps used by the -//! behaviour-driven tests under `tests/features`. +//! Step functions are synchronous and call async world methods via +//! `Runtime::new().block_on(...)`. mod client_lifecycle_steps; mod client_messaging_steps; mod client_preamble_steps; -mod client_steps; +mod client_runtime_steps; mod codec_error_steps; mod codec_stateful_steps; mod correlation_steps; @@ -17,3 +17,5 @@ mod multi_packet_steps; mod panic_steps; mod request_parts_steps; mod stream_end_steps; + +pub(crate) use message_assembly_steps::FrameId; diff --git a/tests/steps/multi_packet_steps.rs b/tests/steps/multi_packet_steps.rs index 12791fb2..b95ce9f2 100644 --- a/tests/steps/multi_packet_steps.rs +++ b/tests/steps/multi_packet_steps.rs @@ -1,26 +1,40 @@ -//! Steps for multi-packet response behavioural tests. -use cucumber::{then, when}; +//! Step definitions for multi-packet response behavioural tests. +//! +//! Steps are synchronous but call async World methods via +//! `Runtime::new().block_on()` (`current_thread` runtime doesn't support +//! `block_in_place`). -use crate::world::{MultiPacketWorld, TestResult}; +use rstest_bdd_macros::{then, when}; + +use crate::fixtures::multi_packet::{MultiPacketWorld, TestResult}; #[when("a handler uses the with_channel helper to emit messages")] -async fn when_multi(world: &mut MultiPacketWorld) -> TestResult { world.process().await } +fn when_multi(multi_packet_world: &mut MultiPacketWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(multi_packet_world.process()) +} #[then("all messages are received in order")] -fn then_multi(world: &mut MultiPacketWorld) { world.verify(); } +fn then_multi(multi_packet_world: &mut MultiPacketWorld) { multi_packet_world.verify(); } #[when("a handler uses the with_channel helper to emit no messages")] -async fn when_multi_empty(world: &mut MultiPacketWorld) -> TestResult { - world.process_empty().await +fn when_multi_empty(multi_packet_world: &mut MultiPacketWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(multi_packet_world.process_empty()) } #[then("no messages are received")] -fn then_multi_empty(world: &mut MultiPacketWorld) { world.verify_empty(); } +fn then_multi_empty(multi_packet_world: &mut MultiPacketWorld) { + multi_packet_world.verify_empty(); +} #[when("a handler emits more messages than the channel capacity")] -async fn when_multi_overflow(world: &mut MultiPacketWorld) -> TestResult { - world.process_overflow().await +fn when_multi_overflow(multi_packet_world: &mut MultiPacketWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(multi_packet_world.process_overflow()) } #[then("overflow messages are handled according to channel policy")] -fn then_multi_overflow(world: &mut MultiPacketWorld) { world.verify_overflow(); } +fn then_multi_overflow(multi_packet_world: &mut MultiPacketWorld) { + multi_packet_world.verify_overflow(); +} diff --git a/tests/steps/panic_steps.rs b/tests/steps/panic_steps.rs index 439c193a..22fc9d6d 100644 --- a/tests/steps/panic_steps.rs +++ b/tests/steps/panic_steps.rs @@ -1,27 +1,28 @@ -//! Cucumber step implementations for panic resilience testing. +//! Step definitions for panic resilience behavioural tests. //! -//! Defines Given-When-Then steps that verify server stability -//! when connection tasks panic during setup. +//! Steps are synchronous but call async World methods via +//! `Runtime::new().block_on()` (`current_thread` runtime doesn't support +//! `block_in_place`). -use cucumber::{given, then, when}; +use rstest_bdd_macros::{given, then, when}; -use crate::world::{PanicWorld, TestResult}; +use crate::fixtures::panic::{PanicWorld, TestResult}; #[given("a running wireframe server with a panic in connection setup")] -async fn start_server(world: &mut PanicWorld) -> TestResult { - world.start_panic_server().await?; - Ok(()) +fn start_server(panic_world: &mut PanicWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(panic_world.start_panic_server()) } #[when("I connect to the server")] #[when("I connect to the server again")] -async fn connect(world: &mut PanicWorld) -> TestResult { - world.connect_once().await?; - Ok(()) +fn connect(panic_world: &mut PanicWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(panic_world.connect_once()) } #[then("both connections succeed")] -async fn verify(world: &mut PanicWorld) -> TestResult { - world.verify_and_shutdown().await?; - Ok(()) +fn verify(panic_world: &mut PanicWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(panic_world.verify_and_shutdown()) } diff --git a/tests/steps/request_parts_steps.rs b/tests/steps/request_parts_steps.rs index 68fe7cf7..d77ee534 100644 --- a/tests/steps/request_parts_steps.rs +++ b/tests/steps/request_parts_steps.rs @@ -1,79 +1,102 @@ -//! Steps for request parts behavioural tests. +//! Step definitions for `request_parts` behavioural tests. +//! +//! All steps are synchronous. No async operations are needed for this world. -use cucumber::{given, then, when}; +use rstest_bdd_macros::{given, then, when}; -use crate::world::{ +use crate::fixtures::request_parts::{ + CorrelationId, + MetadataByte, + RequestId, RequestPartsWorld, TestResult, - types::{CorrelationId, MetadataByte, MetadataLength, RequestId}, }; -#[given(expr = "request parts with id {word} and correlation id {word}")] -fn given_parts_with_correlation(world: &mut RequestPartsWorld, id: RequestId, cid: CorrelationId) { - world.create_parts(id.0, Some(cid.0), vec![]); +#[given("request parts with id {id:u32} and correlation id {cid:u64}")] +fn given_parts_with_correlation( + request_parts_world: &mut RequestPartsWorld, + id: RequestId, + cid: CorrelationId, +) { + request_parts_world.create_parts(id, Some(cid), vec![]); } -#[given(expr = "request parts with id {word} and no correlation id")] -fn given_parts_no_correlation(world: &mut RequestPartsWorld, id: RequestId) { - world.create_parts(id.0, None, vec![]); +#[given("request parts with id {id:u32} and no correlation id")] +fn given_parts_no_correlation(request_parts_world: &mut RequestPartsWorld, id: RequestId) { + request_parts_world.create_parts(id, None, vec![]); } // Deliberately duplicates `given_parts_no_correlation` to provide distinct // Gherkin phrasing: scenarios that later add metadata use the shorter form, // while this form explicitly states the empty-metadata precondition. -#[given(expr = "request parts with id {word}, no correlation id, and empty metadata")] -fn given_parts_empty_metadata(world: &mut RequestPartsWorld, id: RequestId) { - world.create_parts(id.0, None, vec![]); +#[given("request parts with id {id:u32}, no correlation id, and empty metadata")] +fn given_parts_empty_metadata(request_parts_world: &mut RequestPartsWorld, id: RequestId) { + request_parts_world.create_parts(id, None, vec![]); } -#[given(expr = "metadata bytes {word}, {word}, {word}")] +#[given("metadata bytes {b1:u8}, {b2:u8}, {b3:u8}")] fn given_metadata_bytes_three( - world: &mut RequestPartsWorld, + request_parts_world: &mut RequestPartsWorld, b1: MetadataByte, b2: MetadataByte, b3: MetadataByte, ) -> TestResult { - world.append_metadata_byte(b1.0)?; - world.append_metadata_byte(b2.0)?; - world.append_metadata_byte(b3.0) + request_parts_world.append_metadata_byte(b1)?; + request_parts_world.append_metadata_byte(b2)?; + request_parts_world.append_metadata_byte(b3) } -#[given(expr = "metadata byte {word}")] -fn given_metadata_byte(world: &mut RequestPartsWorld, byte: MetadataByte) -> TestResult { - world.append_metadata_byte(byte.0) +#[given("metadata byte {byte:u8}")] +fn given_metadata_byte( + request_parts_world: &mut RequestPartsWorld, + byte: MetadataByte, +) -> TestResult { + request_parts_world.append_metadata_byte(byte) } -#[when(expr = "inheriting correlation id {word}")] -fn when_inherit_correlation(world: &mut RequestPartsWorld, cid: CorrelationId) -> TestResult { - world.inherit_correlation(Some(cid.0)) +#[when("inheriting correlation id {cid:u64}")] +fn when_inherit_correlation( + request_parts_world: &mut RequestPartsWorld, + cid: CorrelationId, +) -> TestResult { + request_parts_world.inherit_correlation(Some(cid)) } #[when("inheriting no correlation id")] -fn when_inherit_no_correlation(world: &mut RequestPartsWorld) -> TestResult { - world.inherit_correlation(None) +fn when_inherit_no_correlation(request_parts_world: &mut RequestPartsWorld) -> TestResult { + request_parts_world.inherit_correlation(None) } -#[when(expr = "appending byte {word} to metadata")] -fn when_append_metadata(world: &mut RequestPartsWorld, byte: MetadataByte) -> TestResult { - world.append_metadata_byte(byte.0) +#[when("appending byte {byte:u8} to metadata")] +fn when_append_metadata( + request_parts_world: &mut RequestPartsWorld, + byte: MetadataByte, +) -> TestResult { + request_parts_world.append_metadata_byte(byte) } -#[then(expr = "the request id is {word}")] -fn then_id_is(world: &mut RequestPartsWorld, expected: RequestId) -> TestResult { - world.assert_id(expected.0) +#[then("the request id is {expected:u32}")] +fn then_id_is(request_parts_world: &mut RequestPartsWorld, expected: RequestId) -> TestResult { + request_parts_world.assert_id(expected) } -#[then(expr = "the correlation id is {word}")] -fn then_correlation_id_is(world: &mut RequestPartsWorld, expected: CorrelationId) -> TestResult { - world.assert_correlation_id(Some(expected.0)) +#[then("the correlation id is {expected:u64}")] +fn then_correlation_id_is( + request_parts_world: &mut RequestPartsWorld, + expected: CorrelationId, +) -> TestResult { + request_parts_world.assert_correlation_id(Some(expected)) } #[then("the correlation id is absent")] -fn then_correlation_id_is_absent(world: &mut RequestPartsWorld) -> TestResult { - world.assert_correlation_id(None) +fn then_correlation_id_is_absent(request_parts_world: &mut RequestPartsWorld) -> TestResult { + request_parts_world.assert_correlation_id(None) } -#[then(expr = "the metadata length is {word}")] -fn then_metadata_length_is(world: &mut RequestPartsWorld, expected: MetadataLength) -> TestResult { - world.assert_metadata_length(expected.0) +#[then("the metadata length is {expected:usize}")] +fn then_metadata_length_is( + request_parts_world: &mut RequestPartsWorld, + expected: usize, +) -> TestResult { + request_parts_world.assert_metadata_length(expected) } diff --git a/tests/steps/stream_end_steps.rs b/tests/steps/stream_end_steps.rs index ff0485d1..f0e53e05 100644 --- a/tests/steps/stream_end_steps.rs +++ b/tests/steps/stream_end_steps.rs @@ -1,35 +1,41 @@ -//! Steps for stream terminator behavioural tests. -use cucumber::{then, when}; +//! Step definitions for stream terminator behavioural tests. -use crate::world::{StreamEndWorld, TestResult}; +use rstest_bdd_macros::{then, when}; + +use crate::fixtures::stream_end::{StreamEndWorld, TestResult}; #[when("a streaming response completes")] -async fn when_stream(world: &mut StreamEndWorld) -> TestResult { world.process().await } +fn when_stream(stream_end_world: &mut StreamEndWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(stream_end_world.process()) +} #[then("an end-of-stream frame is sent")] -fn then_end(world: &mut StreamEndWorld) { world.verify(); } +fn then_end(stream_end_world: &mut StreamEndWorld) { stream_end_world.verify(); } #[when("a multi-packet channel drains")] -async fn when_multi_channel(world: &mut StreamEndWorld) -> TestResult { - world.process_multi().await +fn when_multi_channel(stream_end_world: &mut StreamEndWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(stream_end_world.process_multi()) } #[then("a multi-packet end-of-stream frame is sent")] -fn then_multi_end(world: &mut StreamEndWorld) { world.verify_multi(); } +fn then_multi_end(stream_end_world: &mut StreamEndWorld) { stream_end_world.verify_multi(); } #[when("a multi-packet channel disconnects abruptly")] -fn when_multi_disconnect(world: &mut StreamEndWorld) -> TestResult { - world.process_multi_disconnect() +fn when_multi_disconnect(stream_end_world: &mut StreamEndWorld) -> TestResult { + stream_end_world.process_multi_disconnect() } #[when("shutdown closes a multi-packet channel")] -fn when_multi_shutdown(world: &mut StreamEndWorld) -> TestResult { world.process_multi_shutdown() } +fn when_multi_shutdown(stream_end_world: &mut StreamEndWorld) -> TestResult { + stream_end_world.process_multi_shutdown() +} #[then("no multi-packet terminator is sent")] -fn then_no_multi(world: &mut StreamEndWorld) { world.verify_no_multi(); } +fn then_no_multi(stream_end_world: &mut StreamEndWorld) { stream_end_world.verify_no_multi(); } -#[then(expr = "the multi-packet termination reason is {word}")] -fn then_reason(world: &mut StreamEndWorld, reason: String) -> TestResult { - let reason = reason.into_boxed_str(); - world.verify_reason(reason.as_ref()) +#[then(expr = "the multi-packet termination reason is {reason:word}")] +fn then_reason(stream_end_world: &mut StreamEndWorld, reason: String) -> TestResult { + stream_end_world.verify_reason(reason.as_str()) } diff --git a/tests/world.rs b/tests/world.rs deleted file mode 100644 index c54821fe..00000000 --- a/tests/world.rs +++ /dev/null @@ -1,24 +0,0 @@ -#![cfg(not(loom))] -//! Test worlds for Cucumber suites. - -#[path = "worlds/mod.rs"] -mod worlds; - -pub use worlds::{ - client_lifecycle::{ClientLifecycleWorld, EXPECTED_SETUP_STATE}, - client_messaging::ClientMessagingWorld, - client_preamble::ClientPreambleWorld, - client_runtime::ClientRuntimeWorld, - codec_error::CodecErrorWorld, - codec_stateful::CodecStatefulWorld, - common::TestResult, - correlation::CorrelationWorld, - fragment::FragmentWorld, - message_assembler::{ContinuationHeaderSpec, FirstHeaderSpec, MessageAssemblerWorld}, - message_assembly::{ContinuationFrameParams, FirstFrameParams, MessageAssemblyWorld}, - multi_packet::MultiPacketWorld, - panic::PanicWorld, - request_parts::RequestPartsWorld, - stream_end::StreamEndWorld, - types, -}; diff --git a/tests/worlds/client_runtime.rs b/tests/worlds/client_runtime.rs deleted file mode 100644 index 1fb81258..00000000 --- a/tests/worlds/client_runtime.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Test world for client runtime scenarios. -#![cfg(not(loom))] - -use std::net::SocketAddr; - -use cucumber::World; -use futures::{SinkExt, StreamExt}; -use log::warn; -use tokio::{net::TcpListener, task::JoinHandle}; -use tokio_util::codec::{Framed, LengthDelimitedCodec}; -use wireframe::{ - BincodeSerializer, - client::{ClientCodecConfig, ClientError, WireframeClient}, - rewind_stream::RewindStream, -}; - -use super::TestResult; - -/// Test world exercising the wireframe client runtime. -#[derive(Debug, Default, World)] -pub struct ClientRuntimeWorld { - addr: Option, - server: Option>, - client: Option>>, - payload: Option, - response: Option, - last_error: Option, -} - -#[derive(bincode::Encode, bincode::BorrowDecode, Debug, PartialEq, Eq, Clone)] -struct ClientPayload { - data: Vec, -} - -impl ClientRuntimeWorld { - /// Start an echo server with the specified maximum frame length. - /// - /// # Errors - /// Returns an error if binding or spawning the server fails. - pub async fn start_server(&mut self, max_frame_length: usize) -> TestResult { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let handle = tokio::spawn(async move { - let Ok((stream, _)) = listener.accept().await else { - warn!("client runtime server failed to accept connection"); - return; - }; - let codec = LengthDelimitedCodec::builder() - .max_frame_length(max_frame_length) - .new_codec(); - let mut framed = Framed::new(stream, codec); - let Some(result) = framed.next().await else { - warn!("client runtime server closed before receiving a frame"); - return; - }; - let Ok(frame) = result else { - warn!("client runtime server failed to decode frame"); - return; - }; - if let Err(err) = framed.send(frame.freeze()).await { - warn!("client runtime server failed to send response: {err:?}"); - } - }); - - self.addr = Some(addr); - self.server = Some(handle); - Ok(()) - } - - /// Connect a client using the specified maximum frame length. - /// - /// # Errors - /// Returns an error if the server has not started or the client fails to connect. - pub async fn connect_client(&mut self, max_frame_length: usize) -> TestResult { - let addr = self.addr.ok_or("server address missing")?; - let codec_config = ClientCodecConfig::default().max_frame_length(max_frame_length); - let client = WireframeClient::builder() - .codec_config(codec_config) - .connect(addr) - .await?; - self.client = Some(client); - Ok(()) - } - - /// Send a payload of the specified size and capture the response. - /// - /// # Errors - /// Returns an error if the client is missing or communication fails. - pub async fn send_payload(&mut self, size: usize) -> TestResult { - let payload = ClientPayload { - data: vec![7_u8; size], - }; - let client = self.client.as_mut().ok_or("client not connected")?; - let response: ClientPayload = client.call(&payload).await?; - self.payload = Some(payload); - self.response = Some(response); - self.last_error = None; - Ok(()) - } - - /// Send a payload that should exceed the peer's frame limit. - /// - /// # Errors - /// Returns an error if the client is missing or if no failure is observed. - pub async fn send_payload_expect_error(&mut self, size: usize) -> TestResult { - let payload = ClientPayload { - data: vec![7_u8; size], - }; - let client = self.client.as_mut().ok_or("client not connected")?; - let result: Result = client.call(&payload).await; - match result { - Ok(_) => return Err("expected client error for oversized payload".into()), - Err(err) => self.last_error = Some(err), - } - Ok(()) - } - - /// Verify that the client received the echoed payload. - /// - /// # Errors - /// Returns an error if the response is missing or mismatched. - pub async fn verify_echo(&mut self) -> TestResult { - let payload = self.payload.as_ref().ok_or("payload missing")?; - let response = self.response.as_ref().ok_or("response missing")?; - if payload != response { - return Err("response did not match payload".into()); - } - self.await_server().await?; - Ok(()) - } - - /// Verify that a client error was captured. - /// - /// # Errors - /// Returns an error if no failure was observed. - pub async fn verify_error(&mut self) -> TestResult { - let err = self - .last_error - .as_ref() - .ok_or("expected client error was not captured")?; - if !matches!(err, ClientError::Disconnected | ClientError::Io(_)) { - return Err("unexpected client error variant".into()); - } - self.await_server().await?; - Ok(()) - } - - async fn await_server(&mut self) -> TestResult { - if let Some(handle) = self.server.take() { - handle - .await - .map_err(|err| format!("server task failed: {err}"))?; - } - Ok(()) - } -} diff --git a/tests/worlds/message_assembler.rs b/tests/worlds/message_assembler.rs deleted file mode 100644 index 2ef637dd..00000000 --- a/tests/worlds/message_assembler.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! Test world for message assembler header parsing. -#![cfg(not(loom))] - -use std::{fmt, io}; - -use bytes::{BufMut, BytesMut}; -use cucumber::World; -use wireframe::{ - message_assembler::{FrameHeader, FrameSequence, MessageAssembler, ParsedFrameHeader}, - test_helpers::TestAssembler, -}; - -use super::{TestApp, TestResult}; - -/// Specification for first-frame header encoding used in tests. -#[derive(Debug, Clone, Copy)] -pub struct FirstHeaderSpec { - /// Message key to encode into the header. - pub key: u64, - /// Metadata length in bytes. - pub metadata_len: usize, - /// Body length in bytes for this frame. - pub body_len: usize, - /// Optional total body length across all frames. - pub total_len: Option, - /// Whether the frame is the final one in the series. - pub is_last: bool, -} - -/// Specification for continuation-frame header encoding used in tests. -#[derive(Debug, Clone, Copy)] -pub struct ContinuationHeaderSpec { - /// Message key to encode into the header. - pub key: u64, - /// Body length in bytes for this frame. - pub body_len: usize, - /// Optional sequence number. - pub sequence: Option, - /// Whether the frame is the final one in the series. - pub is_last: bool, -} - -#[derive(Debug, Clone, Copy)] -struct HeaderEnvelope { - kind: u8, - flags: u8, - key: u64, -} - -/// World used by Cucumber to test message assembler header parsing. -#[derive(Default, World)] -pub struct MessageAssemblerWorld { - payload: Option>, - parsed: Option, - error: Option, - app: Option, -} - -impl fmt::Debug for MessageAssemblerWorld { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("MessageAssemblerWorld") - .field("payload", &self.payload) - .field("parsed", &self.parsed) - .field("error", &self.error) - .field( - "app", - &self.app.as_ref().map(|_| "wireframe::app::WireframeApp"), - ) - .finish() - } -} - -impl MessageAssemblerWorld { - fn assert_common_field(&self, field: &str, expected: &T, extractor: F) -> TestResult - where - T: PartialEq + fmt::Display + Copy, - F: FnOnce(&FrameHeader) -> T, - { - let parsed = self.parsed.as_ref().ok_or("no parsed header")?; - let actual = extractor(parsed.header()); - if actual != *expected { - return Err(format!("expected {field} {expected}, got {actual}").into()); - } - Ok(()) - } - - /// Generic helper for asserting header-type-specific fields. - /// - /// The extractor performs both type-checking (via pattern matching) and field - /// extraction, returning an error message if the header type is incorrect. - fn assert_header_field(&self, field_name: &str, expected: &T, extractor: F) -> TestResult - where - T: PartialEq + fmt::Display + Copy, - F: FnOnce(&FrameHeader) -> Result, - { - let parsed = self.parsed.as_ref().ok_or("no parsed header")?; - let actual = extractor(parsed.header()).map_err(ToString::to_string)?; - if actual != *expected { - return Err(format!("expected {field_name} {expected}, got {actual}").into()); - } - Ok(()) - } - - /// Store an encoded first-frame header in the world payload. - /// - /// # Errors - /// - /// Returns an error if any length field exceeds the header encoding limits. - pub fn set_first_header(&mut self, spec: FirstHeaderSpec) -> TestResult { - let mut flags = 0u8; - if spec.is_last { - flags |= 0b1; - } - if spec.total_len.is_some() { - flags |= 0b10; - } - self.set_payload_with_header( - HeaderEnvelope { - kind: 0x01, - flags, - key: spec.key, - }, - |bytes| { - let metadata_len = - u16::try_from(spec.metadata_len).map_err(|_| "metadata length too large")?; - bytes.put_u16(metadata_len); - let body_len = u32::try_from(spec.body_len).map_err(|_| "body length too large")?; - bytes.put_u32(body_len); - if let Some(total) = spec.total_len { - let total = u32::try_from(total).map_err(|_| "total length too large")?; - bytes.put_u32(total); - } - Ok(()) - }, - ) - } - - /// Store an encoded continuation-frame header in the world payload. - /// - /// # Errors - /// - /// Returns an error if any length field exceeds the header encoding limits. - pub fn set_continuation_header(&mut self, spec: ContinuationHeaderSpec) -> TestResult { - let mut flags = 0u8; - if spec.is_last { - flags |= 0b1; - } - if spec.sequence.is_some() { - flags |= 0b10; - } - self.set_payload_with_header( - HeaderEnvelope { - kind: 0x02, - flags, - key: spec.key, - }, - |bytes| { - let body_len = u32::try_from(spec.body_len).map_err(|_| "body length too large")?; - bytes.put_u32(body_len); - if let Some(seq) = spec.sequence { - bytes.put_u32(seq); - } - Ok(()) - }, - ) - } - - fn set_payload_with_header(&mut self, envelope: HeaderEnvelope, encode: F) -> TestResult - where - F: FnOnce(&mut BytesMut) -> TestResult, - { - let mut bytes = BytesMut::new(); - bytes.put_u8(envelope.kind); - bytes.put_u8(envelope.flags); - bytes.put_u64(envelope.key); - encode(&mut bytes)?; - self.payload = Some(bytes.to_vec()); - Ok(()) - } - - /// Store a deliberately invalid header payload. - pub fn set_invalid_payload(&mut self) { self.payload = Some(vec![0x01]); } - - /// Parse the stored payload with the test assembler. - /// - /// # Errors - /// - /// Returns an error if no payload has been configured. - pub fn parse_header(&mut self) -> TestResult { - let payload = self.payload.as_deref().ok_or("payload not set")?; - let fallback = TestAssembler; - let assembler: &dyn MessageAssembler = match self.app.as_ref() { - Some(app) => app - .message_assembler() - .ok_or("message assembler not set")? - .as_ref(), - None => &fallback, - }; - match assembler.parse_frame_header(payload) { - Ok(parsed) => { - self.parsed = Some(parsed); - self.error = None; - } - Err(err) => { - self.parsed = None; - self.error = Some(err); - } - } - Ok(()) - } - - /// Assert that the parsed header is of the expected kind. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the kind does not match. - pub fn assert_header_kind(&self, expected: &str) -> TestResult { - let parsed = self.parsed.as_ref().ok_or("no parsed header")?; - let matches_kind = matches!( - (expected, parsed.header()), - ("first", FrameHeader::First(_)) | ("continuation", FrameHeader::Continuation(_)) - ); - if matches_kind { - Ok(()) - } else { - Err(format!("expected {expected} header").into()) - } - } - - /// Assert that the parsed header contains the expected message key. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the key does not match. - pub fn assert_message_key(&self, expected: u64) -> TestResult { - self.assert_common_field("key", &expected, |header| match header { - FrameHeader::First(header) => u64::from(header.message_key), - FrameHeader::Continuation(header) => u64::from(header.message_key), - }) - } - - /// Assert that the parsed header contains the expected metadata length. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the metadata length differs. - pub fn assert_metadata_len(&self, expected: usize) -> TestResult { - self.assert_header_field("metadata length", &expected, |header| { - if let FrameHeader::First(header) = header { - Ok(header.metadata_len) - } else { - Err("expected first header") - } - }) - } - - /// Assert that the parsed header contains the expected body length. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the body length differs. - pub fn assert_body_len(&self, expected: usize) -> TestResult { - self.assert_common_field("body length", &expected, |header| match header { - FrameHeader::First(header) => header.body_len, - FrameHeader::Continuation(header) => header.body_len, - }) - } - - /// Assert that the parsed header contains the expected total body length. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the total length differs. - pub fn assert_total_len(&self, expected: Option) -> TestResult { - let expected = DebugDisplay(expected); - self.assert_header_field("total length", &expected, |header| { - if let FrameHeader::First(header) = header { - Ok(DebugDisplay(header.total_body_len)) - } else { - Err("expected first header") - } - }) - } - - /// Assert that the parsed header contains the expected sequence. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the sequence differs. - pub fn assert_sequence(&self, expected: Option) -> TestResult { - let expected = expected.map(FrameSequence::from); - let expected = DebugDisplay(expected); - self.assert_header_field("sequence", &expected, |header| { - if let FrameHeader::Continuation(header) = header { - Ok(DebugDisplay(header.sequence)) - } else { - Err("expected continuation header") - } - }) - } - - /// Assert that the parsed header matches the expected `is_last` flag. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the flag differs. - pub fn assert_is_last(&self, expected: bool) -> TestResult { - self.assert_common_field("is_last", &expected, |header| match header { - FrameHeader::First(header) => header.is_last, - FrameHeader::Continuation(header) => header.is_last, - }) - } - - /// Assert that the parsed header length matches the expected value. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the length differs. - pub fn assert_header_len(&self, expected: usize) -> TestResult { - let parsed = self.parsed.as_ref().ok_or("no parsed header")?; - let actual = parsed.header_len(); - if actual != expected { - return Err(format!("expected header length {expected}, got {actual}").into()); - } - Ok(()) - } - - /// Assert that the parse failed with `InvalidData`. - /// - /// # Errors - /// - /// Returns an error if no parse error was captured or the kind differs. - pub fn assert_invalid_data_error(&self) -> TestResult { - let err = self.error.as_ref().ok_or("expected error")?; - if err.kind() != io::ErrorKind::InvalidData { - return Err(format!("expected InvalidData error, got {:?}", err.kind()).into()); - } - Ok(()) - } - - /// Store a wireframe app configured with a test message assembler. - /// - /// # Errors - /// - /// Returns an error if the app builder fails. - pub fn set_app_with_message_assembler(&mut self) -> TestResult { - let app = TestApp::new() - .map_err(|err| format!("failed to build app: {err}"))? - .with_message_assembler(TestAssembler); - self.app = Some(app); - Ok(()) - } - - /// Assert that the app exposes a message assembler. - /// - /// # Errors - /// - /// Returns an error if the app or assembler is missing. - pub fn assert_message_assembler_configured(&self) -> TestResult { - let app = self.app.as_ref().ok_or("app not set")?; - if app.message_assembler().is_some() { - Ok(()) - } else { - Err("expected message assembler".into()) - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct DebugDisplay(T); - -impl fmt::Display for DebugDisplay { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self.0) } -} diff --git a/tests/worlds/mod.rs b/tests/worlds/mod.rs deleted file mode 100644 index f44316bb..00000000 --- a/tests/worlds/mod.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Cucumber test world implementations and shared helpers. -//! -//! Provides world types for behaviour-driven tests covering client lifecycle -//! hooks, codec error taxonomy, fragmentation, correlation, panic recovery, -//! stream termination, multi-packet channels, stateful codecs, request parts, -//! and message assembler parsing. Shared utilities like `build_small_queues` -//! keep individual worlds focused on their respective scenarios. -#![cfg(not(loom))] - -#[path = "../common/mod.rs"] -pub mod common; -pub use common::{TestResult, unused_listener}; - -#[path = "../common/terminator.rs"] -mod terminator; -pub(crate) use terminator::Terminator; - -#[path = "../support.rs"] -mod support; - -use wireframe::{app::Envelope, push::PushQueues, serializer::BincodeSerializer}; - -pub(crate) type TestApp = wireframe::app::WireframeApp; - -pub(crate) fn build_small_queues() --> Result<(PushQueues, wireframe::push::PushHandle), wireframe::push::PushConfigError> { - support::builder::().unlimited().build() -} - -pub mod client_lifecycle; -pub mod client_messaging; -pub mod client_preamble; -pub mod client_runtime; -#[path = "codec_error/mod.rs"] -pub mod codec_error; -pub mod codec_stateful; -pub mod correlation; -pub mod fragment; -pub mod message_assembler; -pub mod message_assembly; -pub mod multi_packet; -pub mod panic; -pub mod request_parts; -pub mod stream_end; -pub mod types; diff --git a/tests/worlds/request_parts.rs b/tests/worlds/request_parts.rs deleted file mode 100644 index 420b6e3e..00000000 --- a/tests/worlds/request_parts.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Test world for request parts scenarios. -#![cfg(not(loom))] - -use cucumber::World; -use wireframe::request::RequestParts; - -use super::TestResult; - -/// Test world exercising `RequestParts` metadata handling. -#[derive(Debug, Default, World)] -pub struct RequestPartsWorld { - parts: Option, -} - -impl RequestPartsWorld { - /// Create request parts with all fields specified. - pub fn create_parts(&mut self, id: u32, correlation_id: Option, metadata: Vec) { - self.parts = Some(RequestParts::new(id, correlation_id, metadata)); - } - - /// Inherit a correlation id from an external source. - /// - /// # Errors - /// Returns an error if parts have not been created. - pub fn inherit_correlation(&mut self, source: Option) -> TestResult { - let parts = self.parts.take().ok_or("request parts not created")?; - self.parts = Some(parts.inherit_correlation(source)); - Ok(()) - } - - /// Append a byte to the metadata. - /// - /// # Errors - /// Returns an error if parts have not been created. - pub fn append_metadata_byte(&mut self, byte: u8) -> TestResult { - let parts = self.parts.as_mut().ok_or("request parts not created")?; - parts.metadata_mut().push(byte); - Ok(()) - } - - /// Assert the request id matches the expected value. - /// - /// # Errors - /// Returns an error if parts are missing or id does not match. - pub fn assert_id(&self, expected: u32) -> TestResult { - let parts = self.parts.as_ref().ok_or("request parts not created")?; - if parts.id() != expected { - return Err(format!("expected id {expected}, got {}", parts.id()).into()); - } - Ok(()) - } - - /// Assert the correlation id matches the expected value. - /// - /// # Errors - /// Returns an error if parts are missing or correlation id does not match. - pub fn assert_correlation_id(&self, expected: Option) -> TestResult { - let parts = self.parts.as_ref().ok_or("request parts not created")?; - if parts.correlation_id() != expected { - return Err(format!( - "expected correlation_id {:?}, got {:?}", - expected, - parts.correlation_id() - ) - .into()); - } - Ok(()) - } - - /// Assert the metadata length matches the expected value. - /// - /// # Errors - /// Returns an error if parts are missing or length does not match. - pub fn assert_metadata_length(&self, expected: usize) -> TestResult { - let parts = self.parts.as_ref().ok_or("request parts not created")?; - if parts.metadata().len() != expected { - return Err(format!( - "expected metadata length {expected}, got {}", - parts.metadata().len() - ) - .into()); - } - Ok(()) - } -} diff --git a/tests/worlds/types.rs b/tests/worlds/types.rs deleted file mode 100644 index a258b2f0..00000000 --- a/tests/worlds/types.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Domain-specific newtype wrappers for Cucumber step parameters. -//! -//! These types eliminate primitive obsession in step definitions by providing -//! semantic meaning to integer parameters parsed from feature files. - -use std::str::FromStr; - -/// Protocol-specific packet or message identifier for routing. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RequestId(pub u32); - -impl FromStr for RequestId { - type Err = std::num::ParseIntError; - - fn from_str(s: &str) -> Result { s.parse().map(Self) } -} - -/// Correlation identifier tying a request to a logical session or prior exchange. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CorrelationId(pub u64); - -impl FromStr for CorrelationId { - type Err = std::num::ParseIntError; - - fn from_str(s: &str) -> Result { s.parse().map(Self) } -} - -/// Single byte of protocol-defined metadata. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MetadataByte(pub u8); - -impl FromStr for MetadataByte { - type Err = std::num::ParseIntError; - - fn from_str(s: &str) -> Result { s.parse().map(Self) } -} - -/// Expected length of metadata bytes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MetadataLength(pub usize); - -impl FromStr for MetadataLength { - type Err = std::num::ParseIntError; - - fn from_str(s: &str) -> Result { s.parse().map(Self) } -}