diff --git a/Cargo.lock b/Cargo.lock index b613951..f2ef371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" @@ -67,18 +83,71 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -342,6 +411,12 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -540,6 +615,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -548,8 +624,22 @@ version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -573,6 +663,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compact_str" version = "0.8.1" @@ -630,6 +726,42 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation", + "core-graphics", + "foreign-types 0.5.0", + "libc", +] + [[package]] name = "core2" version = "0.4.0" @@ -739,7 +871,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.10.0", "crossterm_winapi", "mio", "parking_lot", @@ -929,7 +1061,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags", + "bitflags 2.10.0", "objc2", ] @@ -944,12 +1076,39 @@ dependencies = [ "syn", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1117,6 +1276,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -1133,6 +1303,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + [[package]] name = "fnv" version = "1.0.7" @@ -1145,13 +1321,59 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "font-kit" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" +dependencies = [ + "bitflags 2.10.0", + "byteorder", + "core-foundation", + "core-graphics", + "core-text", + "dirs 6.0.0", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1160,6 +1382,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1169,6 +1397,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "fs2" version = "0.4.3" @@ -1269,8 +1508,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1668,6 +1909,24 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imageproc" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.17", + "image", + "itertools 0.12.1", + "nalgebra", + "num", + "rand 0.8.5", + "rand_distr", + "rayon", +] + [[package]] name = "imgref" version = "1.12.0" @@ -1751,6 +2010,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1797,6 +2065,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -1806,6 +2080,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1878,13 +2161,29 @@ dependencies = [ "cc", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", ] @@ -1981,6 +2280,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -1997,6 +2306,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2044,6 +2362,21 @@ dependencies = [ "pxfm", ] +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2067,6 +2400,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nom" version = "8.0.0" @@ -2105,6 +2452,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2115,6 +2476,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2141,6 +2511,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -2159,6 +2540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2176,7 +2558,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags", + "bitflags 2.10.0", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -2188,7 +2570,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.10.0", "dispatch2", "objc2", ] @@ -2199,7 +2581,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags", + "bitflags 2.10.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -2218,7 +2600,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.10.0", "block2", "libc", "objc2", @@ -2231,7 +2613,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags", + "bitflags 2.10.0", "objc2", "objc2-core-foundation", ] @@ -2242,6 +2624,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -2265,9 +2653,9 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", @@ -2325,6 +2713,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking" version = "2.2.1" @@ -2372,6 +2769,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2451,7 +2867,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags", + "bitflags 2.10.0", "crc32fast", "fdeflate", "flate2", @@ -2472,6 +2888,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2640,13 +3077,23 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + [[package]] name = "ratatui" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cassowary", "compact_str", "crossterm", @@ -2727,6 +3174,12 @@ dependencies = [ "rgb", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.11.0" @@ -2753,7 +3206,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -2889,13 +3342,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2908,7 +3370,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.11.0", @@ -2960,6 +3422,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3014,7 +3485,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -3031,6 +3502,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -3137,6 +3614,61 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3157,6 +3689,22 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -3194,6 +3742,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3320,7 +3881,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "system-configuration-sys", ] @@ -3403,6 +3964,7 @@ dependencies = [ "serde", "serde_json", "tdlib-rs", + "termwright", "thiserror 1.0.69", "tokio", "tokio-test", @@ -3424,6 +3986,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + +[[package]] +name = "termwright" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff8ef1fa203835e6bc4f260447a998950d812af126ff86d39a641f253f35d560" +dependencies = [ + "ab_glyph", + "base64", + "chrono", + "clap", + "font-kit", + "image", + "imageproc", + "portable-pty", + "regex", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", + "vt100", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3713,7 +4308,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-util", "http", @@ -3804,6 +4399,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "typenum" version = "1.19.0" @@ -3816,7 +4417,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "winapi", ] @@ -3856,6 +4457,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -3880,6 +4487,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.20.0" @@ -3926,6 +4539,39 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -4035,6 +4681,16 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4538,6 +5194,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4582,6 +5256,17 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 947b5b8..a8a2c96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ license = "MIT" repository = "https://github.com/your-username/tele-tui" keywords = ["telegram", "tui", "terminal", "cli"] categories = ["command-line-utilities"] +default-run = "tele-tui" [features] default = ["clipboard", "url-open", "notifications", "images"] @@ -15,6 +16,7 @@ clipboard = ["dep:arboard"] url-open = ["dep:open"] notifications = ["dep:notify-rust"] images = ["dep:ratatui-image", "dep:image"] +test-support = [] [dependencies] ratatui = "0.29" @@ -43,6 +45,12 @@ fs2 = "0.4" insta = "1.34" tokio-test = "0.4" criterion = "0.5" +termwright = "0.2" + +[[bin]] +name = "tele-tui-test-fixture" +path = "src/bin/tele-tui-test-fixture.rs" +required-features = ["test-support"] [[bench]] name = "group_messages" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..fa0c8d4 --- /dev/null +++ b/build.rs @@ -0,0 +1,33 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + + for lib_dir in tdlib_lib_dirs() { + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir.display()); + } +} + +fn tdlib_lib_dirs() -> Vec { + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); + let build_dir = manifest_dir.join("target").join(profile).join("build"); + + let Ok(entries) = fs::read_dir(build_dir) else { + return Vec::new(); + }; + + entries + .flatten() + .map(|entry| entry.path().join("out").join("tdlib").join("lib")) + .filter(|path| has_tdjson(path)) + .collect() +} + +fn has_tdjson(path: &Path) -> bool { + path.join("libtdjson.1.8.29.dylib").exists() + || path.join("libtdjson.dylib").exists() + || path.join("libtdjson.so").exists() +} diff --git a/src/bin/tele-tui-test-fixture.rs b/src/bin/tele-tui-test-fixture.rs new file mode 100644 index 0000000..fcdc494 --- /dev/null +++ b/src/bin/tele-tui-test-fixture.rs @@ -0,0 +1,182 @@ +use std::io; +use std::time::Duration; + +use crossterm::{ + event::{ + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event, KeyCode, KeyEvent, KeyModifiers, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use tele_tui::{ + app::{App, AppScreen}, + input::handle_main_input, + test_support::{ + app_builder::TestAppBuilder, + fake_tdclient::FakeTdClient, + test_data::{TestChatBuilder, TestMessageBuilder}, + }, +}; + +#[tokio::main] +async fn main() -> io::Result<()> { + let scenario = parse_scenario(); + let mut app = build_app(&scenario); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + let result = run_fixture(&mut terminal, &mut app).await; + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture, + DisableBracketedPaste + )?; + terminal.show_cursor()?; + + result +} + +fn parse_scenario() -> String { + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if arg == "--scenario" { + return args.next().unwrap_or_else(|| "inbox".to_string()); + } + } + "inbox".to_string() +} + +async fn run_fixture( + terminal: &mut Terminal>, + app: &mut App, +) -> io::Result<()> { + loop { + if app.needs_redraw { + terminal.draw(|f| tele_tui::ui::render(f, app))?; + app.needs_redraw = false; + } + + if event::poll(Duration::from_millis(16))? { + match event::read()? { + Event::Key(key) => { + if key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + return Ok(()); + } + if key.code == KeyCode::F(10) { + return Ok(()); + } + handle_main_input(app, normalize_fixture_key(key)).await; + app.needs_redraw = true; + } + Event::Resize(_, _) => { + app.needs_redraw = true; + } + Event::Paste(text) => { + for ch in text.chars() { + handle_main_input( + app, + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + ) + .await; + } + app.needs_redraw = true; + } + _ => {} + } + } + } +} + +fn normalize_fixture_key(key: KeyEvent) -> KeyEvent { + match (key.code, key.modifiers) { + (KeyCode::Char('/'), KeyModifiers::NONE) => { + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL) + } + (KeyCode::Char('j' | 'm'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE) + } + _ => key, + } +} + +fn build_app(scenario: &str) -> App { + match scenario { + "open-chat" => TestAppBuilder::new() + .screen(AppScreen::Main) + .with_chats(sample_chats()) + .selected_chat(102) + .with_messages(102, sample_messages()) + .build(), + "compose-draft" => TestAppBuilder::new() + .screen(AppScreen::Main) + .with_chats(sample_chats()) + .selected_chat(102) + .message_input("hello from e2e") + .with_messages(102, sample_messages()) + .build(), + "inbox" => TestAppBuilder::new() + .screen(AppScreen::Main) + .with_chats(sample_chats()) + .with_messages(101, mom_messages()) + .with_messages(102, sample_messages()) + .with_messages(103, boss_messages()) + .build(), + other => { + eprintln!("unknown scenario: {other}"); + std::process::exit(2); + } + } +} + +fn sample_chats() -> Vec { + vec![ + TestChatBuilder::new("Mom", 101) + .last_message("Dinner at 7?") + .unread_count(2) + .build(), + TestChatBuilder::new("Work Group", 102) + .last_message("Standup notes are ready") + .unread_mentions(1) + .build(), + TestChatBuilder::new("Boss", 103) + .last_message("Please review the deck") + .build(), + ] +} + +fn sample_messages() -> Vec { + vec![ + TestMessageBuilder::new("Morning, team", 201) + .sender("Alice") + .build(), + TestMessageBuilder::new("Standup notes are ready", 202) + .sender("Bob") + .build(), + TestMessageBuilder::new("Thanks, I will review them after lunch", 203) + .outgoing() + .build(), + ] +} + +fn mom_messages() -> Vec { + vec![TestMessageBuilder::new("Dinner at 7?", 301) + .sender("Mom") + .build()] +} + +fn boss_messages() -> Vec { + vec![TestMessageBuilder::new("Please review the deck", 401) + .sender("Boss") + .build()] +} diff --git a/src/lib.rs b/src/lib.rs index 4ca43b4..976aaa1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,8 @@ pub mod media; pub mod message_grouping; pub mod notifications; pub mod tdlib; +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; pub mod types; pub mod ui; pub mod utils; diff --git a/src/test_support/app_builder.rs b/src/test_support/app_builder.rs new file mode 100644 index 0000000..6a72554 --- /dev/null +++ b/src/test_support/app_builder.rs @@ -0,0 +1,288 @@ +// Test App builder + +use super::FakeTdClient; +use crate::app::{App, AppScreen, ChatState, InputMode}; +use crate::config::Config; +use crate::tdlib::AuthState; +use crate::tdlib::{ChatInfo, MessageInfo}; +use crate::types::{ChatId, MessageId}; +use ratatui::widgets::ListState; +use std::collections::HashMap; + +/// Builder для создания тестового App с FakeTdClient\n///\n/// Использует trait-based DI для подмены TdClient на FakeTdClient в тестах. +#[allow(dead_code)] +pub struct TestAppBuilder { + config: Config, + screen: AppScreen, + chats: Vec, + selected_chat_id: Option, + message_input: String, + is_searching: bool, + search_query: String, + chat_state: Option, + input_mode: Option, + messages: HashMap>, + status_message: Option, + auth_state: Option, + phone_input: Option, + code_input: Option, + password_input: Option, +} + +impl Default for TestAppBuilder { + fn default() -> Self { + Self::new() + } +} + +#[allow(dead_code)] +impl TestAppBuilder { + pub fn new() -> Self { + Self { + config: Config::default(), + screen: AppScreen::Main, + chats: vec![], + selected_chat_id: None, + message_input: String::new(), + is_searching: false, + search_query: String::new(), + chat_state: None, + input_mode: None, + messages: HashMap::new(), + status_message: None, + auth_state: None, + phone_input: None, + code_input: None, + password_input: None, + } + } + + /// Установить экран + pub fn screen(mut self, screen: AppScreen) -> Self { + self.screen = screen; + self + } + + /// Установить конфиг + pub fn config(mut self, config: Config) -> Self { + self.config = config; + self + } + + /// Добавить чат + pub fn with_chat(mut self, chat: ChatInfo) -> Self { + self.chats.push(chat); + self + } + + /// Добавить несколько чатов + pub fn with_chats(mut self, chats: Vec) -> Self { + self.chats.extend(chats); + self + } + + /// Выбрать чат + pub fn selected_chat(mut self, chat_id: i64) -> Self { + self.selected_chat_id = Some(chat_id); + self + } + + /// Установить текст в инпуте + pub fn message_input(mut self, text: &str) -> Self { + self.message_input = text.to_string(); + self + } + + /// Режим поиска + pub fn searching(mut self, query: &str) -> Self { + self.is_searching = true; + self.search_query = query.to_string(); + self + } + + /// Режим редактирования сообщения + pub fn editing_message(mut self, message_id: i64, selected_index: usize) -> Self { + self.chat_state = Some(ChatState::Editing { + message_id: MessageId::new(message_id), + selected_index, + }); + self + } + + /// Режим ответа на сообщение + pub fn replying_to(mut self, message_id: i64) -> Self { + self.chat_state = Some(ChatState::Reply { message_id: MessageId::new(message_id) }); + self + } + + /// Режим выбора реакции + pub fn reaction_picker(mut self, message_id: i64, available_reactions: Vec) -> Self { + self.chat_state = Some(ChatState::ReactionPicker { + message_id: MessageId::new(message_id), + available_reactions, + selected_index: 0, + }); + self + } + + /// Режим профиля + pub fn profile_mode(mut self, info: crate::tdlib::ProfileInfo) -> Self { + self.chat_state = Some(ChatState::Profile { + info, + selected_action: 0, + leave_group_confirmation_step: 0, + }); + self + } + + /// Подтверждение удаления + pub fn delete_confirmation(mut self, message_id: i64) -> Self { + self.chat_state = + Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) }); + self + } + + /// Добавить сообщение для чата + pub fn with_message(mut self, chat_id: i64, message: MessageInfo) -> Self { + self.messages.entry(chat_id).or_default().push(message); + self + } + + /// Добавить несколько сообщений для чата + pub fn with_messages(mut self, chat_id: i64, messages: Vec) -> Self { + self.messages.entry(chat_id).or_default().extend(messages); + self + } + + /// Установить выбранное сообщение (режим selection) + pub fn selecting_message(mut self, selected_index: usize) -> Self { + self.chat_state = Some(ChatState::MessageSelection { selected_index }); + self + } + + /// Режим поиска по сообщениям в чате + pub fn message_search(mut self, query: &str) -> Self { + self.chat_state = Some(ChatState::SearchInChat { + query: query.to_string(), + results: Vec::new(), + selected_index: 0, + }); + self + } + + /// Установить Insert mode + pub fn insert_mode(mut self) -> Self { + self.input_mode = Some(InputMode::Insert); + self + } + + /// Режим пересылки сообщения + pub fn forward_mode(mut self, message_id: i64) -> Self { + self.chat_state = Some(ChatState::Forward { message_id: MessageId::new(message_id) }); + self + } + + /// Установить статус сообщение (для loading screen) + pub fn status_message(mut self, message: &str) -> Self { + self.status_message = Some(message.to_string()); + self + } + + /// Установить auth state + pub fn auth_state(mut self, state: AuthState) -> Self { + self.auth_state = Some(state); + self + } + + /// Установить phone input + pub fn phone_input(mut self, phone: &str) -> Self { + self.phone_input = Some(phone.to_string()); + self + } + + /// Установить code input + pub fn code_input(mut self, code: &str) -> Self { + self.code_input = Some(code.to_string()); + self + } + + /// Установить password input + pub fn password_input(mut self, password: &str) -> Self { + self.password_input = Some(password.to_string()); + self + } + + /// Построить App с FakeTdClient + /// + /// Создаёт App с FakeTdClient, подходит для любых тестов включая + /// интеграционные тесты логики. + pub fn build(self) -> App { + // Создаём FakeTdClient с чатами и сообщениями + let mut fake_client = FakeTdClient::new(); + + // Добавляем чаты + for chat in &self.chats { + fake_client = fake_client.with_chat(chat.clone()); + } + + // Добавляем сообщения + for (chat_id, messages) in self.messages { + fake_client = fake_client.with_messages(chat_id, messages); + } + + // Устанавливаем текущий чат если нужно + if let Some(chat_id) = self.selected_chat_id { + *fake_client.current_chat_id.lock().unwrap() = Some(chat_id); + } + + // Устанавливаем auth state если нужно + if let Some(auth_state) = self.auth_state { + fake_client = fake_client.with_auth_state(auth_state); + } + + // Создаём App с FakeTdClient + let mut app = App::with_client(self.config, fake_client); + + app.screen = self.screen; + app.chats = self.chats; + app.selected_chat_id = self.selected_chat_id.map(ChatId::new); + app.message_input = self.message_input; + app.is_searching = self.is_searching; + app.search_query = self.search_query; + + // Применяем chat_state если он установлен + if let Some(chat_state) = self.chat_state { + app.chat_state = chat_state; + } + + // Применяем input_mode если он установлен + if let Some(input_mode) = self.input_mode { + app.input_mode = input_mode; + } + + // Применяем status_message + if let Some(status) = self.status_message { + app.status_message = Some(status); + } + + // Применяем auth inputs + if let Some(phone) = self.phone_input { + app.set_phone_input(phone); + } + if let Some(code) = self.code_input { + app.set_code_input(code); + } + if let Some(password) = self.password_input { + app.set_password_input(password); + } + + // Выбираем первый чат если есть + if !app.chats.is_empty() { + let mut list_state = ListState::default(); + list_state.select(Some(0)); + app.chat_list_state = list_state; + } + + app + } +} diff --git a/src/test_support/fake_tdclient.rs b/src/test_support/fake_tdclient.rs new file mode 100644 index 0000000..5f3fd08 --- /dev/null +++ b/src/test_support/fake_tdclient.rs @@ -0,0 +1,12 @@ +// Fake TDLib client for testing. + +mod builders; +mod inspect; +mod operations; +mod state; + +#[allow(unused_imports)] +pub use state::{ + DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, PendingViewMessages, + SearchQuery, SentMessage, TdUpdate, ViewedMessages, +}; diff --git a/src/test_support/fake_tdclient/builders.rs b/src/test_support/fake_tdclient/builders.rs new file mode 100644 index 0000000..e7a48e5 --- /dev/null +++ b/src/test_support/fake_tdclient/builders.rs @@ -0,0 +1,86 @@ +use super::{FakeTdClient, TdUpdate}; +use crate::tdlib::types::FolderInfo; +use crate::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo}; +use tokio::sync::mpsc; + +#[allow(dead_code)] +impl FakeTdClient { + /// Create an update channel for receiving simulated TDLib events. + pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + *self.update_tx.lock().unwrap() = Some(tx); + (self, rx) + } + + /// Enable simulated delays, closer to real TDLib behavior. + pub fn with_delays(mut self) -> Self { + self.simulate_delays = true; + self + } + + pub fn with_chat(self, chat: ChatInfo) -> Self { + self.chats.lock().unwrap().push(chat); + self + } + + pub fn with_chats(self, chats: Vec) -> Self { + self.chats.lock().unwrap().extend(chats); + self + } + + pub fn with_message(self, chat_id: i64, message: MessageInfo) -> Self { + self.messages + .lock() + .unwrap() + .entry(chat_id) + .or_default() + .push(message); + self + } + + pub fn with_messages(self, chat_id: i64, messages: Vec) -> Self { + self.messages.lock().unwrap().insert(chat_id, messages); + self + } + + pub fn with_folder(self, id: i32, name: &str) -> Self { + self.folders + .lock() + .unwrap() + .push(FolderInfo { id, name: name.to_string() }); + self + } + + pub fn with_user(self, id: i64, name: &str) -> Self { + self.user_names.lock().unwrap().insert(id, name.to_string()); + self + } + + pub fn with_profile(self, chat_id: i64, profile: ProfileInfo) -> Self { + self.profiles.lock().unwrap().insert(chat_id, profile); + self + } + + pub fn with_network_state(self, state: NetworkState) -> Self { + *self.network_state.lock().unwrap() = state; + self + } + + pub fn with_auth_state(self, state: AuthState) -> Self { + *self.auth_state.lock().unwrap() = state; + self + } + + pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self { + self.downloaded_files + .lock() + .unwrap() + .insert(file_id, path.to_string()); + self + } + + pub fn with_available_reactions(self, reactions: Vec) -> Self { + *self.available_reactions.lock().unwrap() = reactions; + self + } +} diff --git a/src/test_support/fake_tdclient/inspect.rs b/src/test_support/fake_tdclient/inspect.rs new file mode 100644 index 0000000..7e24483 --- /dev/null +++ b/src/test_support/fake_tdclient/inspect.rs @@ -0,0 +1,92 @@ +use super::{ + DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage, + TdUpdate, +}; +use crate::tdlib::types::FolderInfo; +use crate::tdlib::{ChatInfo, MessageInfo, NetworkState}; +use tokio::sync::mpsc; + +#[allow(dead_code)] +impl FakeTdClient { + pub fn get_chats(&self) -> Vec { + self.chats.lock().unwrap().clone() + } + + pub fn get_folders(&self) -> Vec { + self.folders.lock().unwrap().clone() + } + + pub fn get_messages(&self, chat_id: i64) -> Vec { + self.messages + .lock() + .unwrap() + .get(&chat_id) + .cloned() + .unwrap_or_default() + } + + pub fn get_sent_messages(&self) -> Vec { + self.sent_messages.lock().unwrap().clone() + } + + pub fn get_edited_messages(&self) -> Vec { + self.edited_messages.lock().unwrap().clone() + } + + pub fn get_deleted_messages(&self) -> Vec { + self.deleted_messages.lock().unwrap().clone() + } + + pub fn get_forwarded_messages(&self) -> Vec { + self.forwarded_messages.lock().unwrap().clone() + } + + pub fn get_search_queries(&self) -> Vec { + self.searched_queries.lock().unwrap().clone() + } + + pub fn get_viewed_messages(&self) -> Vec<(i64, Vec)> { + self.viewed_messages.lock().unwrap().clone() + } + + pub fn get_chat_actions(&self) -> Vec<(i64, String)> { + self.chat_actions.lock().unwrap().clone() + } + + pub fn get_network_state(&self) -> NetworkState { + self.network_state.lock().unwrap().clone() + } + + pub fn get_current_chat_id(&self) -> Option { + *self.current_chat_id.lock().unwrap() + } + + pub fn set_current_pinned_message(&mut self, msg: Option) { + *self.current_pinned_message.lock().unwrap() = msg; + } + + pub async fn process_pending_view_messages(&mut self) { + let mut pending = self.pending_view_messages.lock().unwrap(); + for (chat_id, message_ids) in pending.drain(..) { + let ids: Vec = message_ids.iter().map(|id| id.as_i64()).collect(); + self.viewed_messages + .lock() + .unwrap() + .push((chat_id.as_i64(), ids)); + } + } + + pub fn set_update_channel(&self, tx: mpsc::UnboundedSender) { + *self.update_tx.lock().unwrap() = Some(tx); + } + + pub fn clear_all_history(&self) { + self.sent_messages.lock().unwrap().clear(); + self.edited_messages.lock().unwrap().clear(); + self.deleted_messages.lock().unwrap().clear(); + self.forwarded_messages.lock().unwrap().clear(); + self.searched_queries.lock().unwrap().clear(); + self.viewed_messages.lock().unwrap().clear(); + self.chat_actions.lock().unwrap().clear(); + } +} diff --git a/src/test_support/fake_tdclient/operations.rs b/src/test_support/fake_tdclient/operations.rs new file mode 100644 index 0000000..aad491b --- /dev/null +++ b/src/test_support/fake_tdclient/operations.rs @@ -0,0 +1,458 @@ +use super::{ + DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage, + TdUpdate, +}; +use crate::tdlib::types::ReactionInfo; +use crate::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo}; +use crate::types::{ChatId, MessageId, UserId}; + +#[allow(dead_code)] +impl FakeTdClient { + pub async fn load_chats(&self, limit: usize) -> Result, String> { + if self.should_fail() { + return Err("Failed to load chats".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + + let chats = self + .chats + .lock() + .unwrap() + .iter() + .take(limit) + .cloned() + .collect(); + Ok(chats) + } + + pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to open chat".to_string()); + } + + *self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64()); + Ok(()) + } + + pub async fn get_chat_history( + &self, + chat_id: ChatId, + limit: i32, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to load history".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + let messages = self + .messages + .lock() + .unwrap() + .get(&chat_id.as_i64()) + .map(|msgs| msgs.iter().take(limit as usize).cloned().collect()) + .unwrap_or_default(); + + Ok(messages) + } + + pub async fn load_older_messages( + &self, + chat_id: ChatId, + from_message_id: MessageId, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to load older messages".to_string()); + } + + let messages = self.messages.lock().unwrap(); + let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?; + + if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) { + let older = chat_messages.iter().take(idx).cloned().collect(); + Ok(older) + } else { + Ok(vec![]) + } + } + + pub async fn send_message( + &self, + chat_id: ChatId, + text: String, + reply_to: Option, + reply_info: Option, + ) -> Result { + if self.should_fail() { + return Err("Failed to send message".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + } + + let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000); + + self.sent_messages.lock().unwrap().push(SentMessage { + chat_id: chat_id.as_i64(), + text: text.clone(), + reply_to, + reply_info: reply_info.clone(), + }); + + let message = MessageInfo::new( + message_id, + "You".to_string(), + true, + text, + vec![], + chrono::Utc::now().timestamp() as i32, + 0, + false, + true, + true, + true, + reply_info, + None, + vec![], + ); + + self.messages + .lock() + .unwrap() + .entry(chat_id.as_i64()) + .or_default() + .push(message.clone()); + + self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message.clone()) }); + + Ok(message) + } + + pub async fn edit_message( + &self, + chat_id: ChatId, + message_id: MessageId, + new_text: String, + ) -> Result { + if self.should_fail() { + return Err("Failed to edit message".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; + } + + self.edited_messages.lock().unwrap().push(EditedMessage { + chat_id: chat_id.as_i64(), + message_id, + new_text: new_text.clone(), + }); + + let mut messages = self.messages.lock().unwrap(); + if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { + if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { + msg.content.text = new_text.clone(); + msg.metadata.edit_date = msg.metadata.date + 60; + + let updated = msg.clone(); + drop(messages); + + self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text }); + + return Ok(updated); + } + } + + Err("Message not found".to_string()) + } + + pub async fn delete_messages( + &self, + chat_id: ChatId, + message_ids: Vec, + revoke: bool, + ) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to delete messages".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + self.deleted_messages.lock().unwrap().push(DeletedMessages { + chat_id: chat_id.as_i64(), + message_ids: message_ids.clone(), + revoke, + }); + + let mut messages = self.messages.lock().unwrap(); + if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { + chat_msgs.retain(|m| !message_ids.contains(&m.id())); + } + drop(messages); + + self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids }); + + Ok(()) + } + + pub async fn forward_messages( + &self, + to_chat_id: ChatId, + from_chat_id: ChatId, + message_ids: Vec, + ) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to forward messages".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; + } + + self.forwarded_messages + .lock() + .unwrap() + .push(ForwardedMessages { + from_chat_id: from_chat_id.as_i64(), + to_chat_id: to_chat_id.as_i64(), + message_ids, + }); + + Ok(()) + } + + pub async fn search_messages( + &self, + chat_id: ChatId, + query: &str, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to search messages".to_string()); + } + + let messages = self.messages.lock().unwrap(); + let results: Vec<_> = messages + .get(&chat_id.as_i64()) + .map(|msgs| { + msgs.iter() + .filter(|m| m.text().to_lowercase().contains(&query.to_lowercase())) + .cloned() + .collect() + }) + .unwrap_or_default(); + + self.searched_queries.lock().unwrap().push(SearchQuery { + chat_id: chat_id.as_i64(), + query: query.to_string(), + results_count: results.len(), + }); + + Ok(results) + } + + pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> { + if text.is_empty() { + self.drafts.lock().unwrap().remove(&chat_id.as_i64()); + } else { + self.drafts + .lock() + .unwrap() + .insert(chat_id.as_i64(), text.clone()); + } + + self.send_update(TdUpdate::ChatDraftMessage { + chat_id, + draft_text: if text.is_empty() { None } else { Some(text) }, + }); + + Ok(()) + } + + pub async fn send_chat_action(&self, chat_id: ChatId, action: String) { + self.chat_actions + .lock() + .unwrap() + .push((chat_id.as_i64(), action.clone())); + + if action == "Typing" { + *self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64()); + } else if action == "Cancel" { + *self.typing_chat_id.lock().unwrap() = None; + } + } + + pub async fn get_message_available_reactions( + &self, + _chat_id: ChatId, + _message_id: MessageId, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to get available reactions".to_string()); + } + + Ok(self.available_reactions.lock().unwrap().clone()) + } + + pub async fn toggle_reaction( + &self, + chat_id: ChatId, + message_id: MessageId, + emoji: String, + ) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to toggle reaction".to_string()); + } + + let mut messages = self.messages.lock().unwrap(); + if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { + if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { + let reactions = &mut msg.interactions.reactions; + + if let Some(pos) = reactions + .iter() + .position(|reaction| reaction.emoji == emoji && reaction.is_chosen) + { + reactions.remove(pos); + } else if let Some(reaction) = reactions + .iter_mut() + .find(|reaction| reaction.emoji == emoji) + { + reaction.is_chosen = true; + reaction.count += 1; + } else { + reactions.push(ReactionInfo { + emoji: emoji.clone(), + count: 1, + is_chosen: true, + }); + } + + let updated_reactions = reactions.clone(); + drop(messages); + + self.send_update(TdUpdate::MessageInteractionInfo { + chat_id, + message_id, + reactions: updated_reactions, + }); + } + } + + Ok(()) + } + + pub async fn download_file(&self, file_id: i32) -> Result { + if self.should_fail() { + return Err("Failed to download file".to_string()); + } + + self.downloaded_files + .lock() + .unwrap() + .get(&file_id) + .cloned() + .ok_or_else(|| format!("File {} not found", file_id)) + } + + pub async fn get_profile_info(&self, chat_id: ChatId) -> Result { + if self.should_fail() { + return Err("Failed to get profile info".to_string()); + } + + self.profiles + .lock() + .unwrap() + .get(&chat_id.as_i64()) + .cloned() + .ok_or_else(|| "Profile not found".to_string()) + } + + pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec) { + self.viewed_messages + .lock() + .unwrap() + .push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect())); + } + + pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to load folder chats".to_string()); + } + + Ok(()) + } + + fn send_update(&self, update: TdUpdate) { + if let Some(tx) = self.update_tx.lock().unwrap().as_ref() { + let _ = tx.send(update); + } + } + + fn should_fail(&self) -> bool { + let mut fail = self.fail_next_operation.lock().unwrap(); + if *fail { + *fail = false; + true + } else { + false + } + } + + pub fn fail_next(&self) { + *self.fail_next_operation.lock().unwrap() = true; + } + + pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) { + let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp()); + + let message = MessageInfo::new( + message_id, + sender_name.to_string(), + false, + text, + vec![], + chrono::Utc::now().timestamp() as i32, + 0, + false, + false, + false, + true, + None, + None, + vec![], + ); + + self.messages + .lock() + .unwrap() + .entry(chat_id.as_i64()) + .or_default() + .push(message.clone()); + + self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message) }); + } + + pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) { + self.send_update(TdUpdate::ChatAction { chat_id, user_id, action: "Typing".to_string() }); + } + + pub fn simulate_network_change(&self, state: crate::tdlib::NetworkState) { + *self.network_state.lock().unwrap() = state.clone(); + self.send_update(TdUpdate::ConnectionState { state }); + } + + pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) { + self.send_update(TdUpdate::ChatReadOutbox { + chat_id, + last_read_outbox_message_id: last_read_message_id, + }); + } +} diff --git a/src/test_support/fake_tdclient/state.rs b/src/test_support/fake_tdclient/state.rs new file mode 100644 index 0000000..41dbf8a --- /dev/null +++ b/src/test_support/fake_tdclient/state.rs @@ -0,0 +1,201 @@ +use crate::tdlib::types::{FolderInfo, ReactionInfo}; +use crate::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo}; +use crate::types::{ChatId, MessageId, UserId}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tokio::sync::mpsc; + +pub type ViewedMessages = Vec<(i64, Vec)>; +pub type PendingViewMessages = Vec<(ChatId, Vec)>; + +/// Update events from TDLib, simplified for tests. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum TdUpdate { + NewMessage { + chat_id: ChatId, + message: Box, + }, + MessageContent { + chat_id: ChatId, + message_id: MessageId, + new_text: String, + }, + DeleteMessages { + chat_id: ChatId, + message_ids: Vec, + }, + ChatAction { + chat_id: ChatId, + user_id: UserId, + action: String, + }, + MessageInteractionInfo { + chat_id: ChatId, + message_id: MessageId, + reactions: Vec, + }, + ConnectionState { + state: NetworkState, + }, + ChatReadOutbox { + chat_id: ChatId, + last_read_outbox_message_id: MessageId, + }, + ChatDraftMessage { + chat_id: ChatId, + draft_text: Option, + }, +} + +/// Simplified mock TDLib client for tests. +#[allow(dead_code)] +pub struct FakeTdClient { + pub chats: Arc>>, + pub messages: Arc>>>, + pub folders: Arc>>, + pub user_names: Arc>>, + pub profiles: Arc>>, + pub drafts: Arc>>, + pub available_reactions: Arc>>, + + pub network_state: Arc>, + pub typing_chat_id: Arc>>, + pub current_chat_id: Arc>>, + pub current_pinned_message: Arc>>, + pub auth_state: Arc>, + + pub sent_messages: Arc>>, + pub edited_messages: Arc>>, + pub deleted_messages: Arc>>, + pub forwarded_messages: Arc>>, + pub searched_queries: Arc>>, + pub viewed_messages: Arc>, + pub chat_actions: Arc>>, + pub pending_view_messages: Arc>, + + pub update_tx: Arc>>>, + pub downloaded_files: Arc>>, + + pub simulate_delays: bool, + pub fail_next_operation: Arc>, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SentMessage { + pub chat_id: i64, + pub text: String, + pub reply_to: Option, + pub reply_info: Option, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct EditedMessage { + pub chat_id: i64, + pub message_id: MessageId, + pub new_text: String, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DeletedMessages { + pub chat_id: i64, + pub message_ids: Vec, + pub revoke: bool, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ForwardedMessages { + pub from_chat_id: i64, + pub to_chat_id: i64, + pub message_ids: Vec, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SearchQuery { + pub chat_id: i64, + pub query: String, + pub results_count: usize, +} + +impl Default for FakeTdClient { + fn default() -> Self { + Self::new() + } +} + +impl Clone for FakeTdClient { + fn clone(&self) -> Self { + Self { + chats: Arc::clone(&self.chats), + messages: Arc::clone(&self.messages), + folders: Arc::clone(&self.folders), + user_names: Arc::clone(&self.user_names), + profiles: Arc::clone(&self.profiles), + drafts: Arc::clone(&self.drafts), + available_reactions: Arc::clone(&self.available_reactions), + network_state: Arc::clone(&self.network_state), + typing_chat_id: Arc::clone(&self.typing_chat_id), + current_chat_id: Arc::clone(&self.current_chat_id), + current_pinned_message: Arc::clone(&self.current_pinned_message), + auth_state: Arc::clone(&self.auth_state), + sent_messages: Arc::clone(&self.sent_messages), + edited_messages: Arc::clone(&self.edited_messages), + deleted_messages: Arc::clone(&self.deleted_messages), + forwarded_messages: Arc::clone(&self.forwarded_messages), + searched_queries: Arc::clone(&self.searched_queries), + viewed_messages: Arc::clone(&self.viewed_messages), + chat_actions: Arc::clone(&self.chat_actions), + pending_view_messages: Arc::clone(&self.pending_view_messages), + downloaded_files: Arc::clone(&self.downloaded_files), + update_tx: Arc::clone(&self.update_tx), + simulate_delays: self.simulate_delays, + fail_next_operation: Arc::clone(&self.fail_next_operation), + } + } +} + +#[allow(dead_code)] +impl FakeTdClient { + pub fn new() -> Self { + Self { + chats: Arc::new(Mutex::new(vec![])), + messages: Arc::new(Mutex::new(HashMap::new())), + folders: Arc::new(Mutex::new(vec![FolderInfo { id: 0, name: "All".to_string() }])), + user_names: Arc::new(Mutex::new(HashMap::new())), + profiles: Arc::new(Mutex::new(HashMap::new())), + drafts: Arc::new(Mutex::new(HashMap::new())), + available_reactions: Arc::new(Mutex::new(vec![ + "👍".to_string(), + "❤️".to_string(), + "😂".to_string(), + "😮".to_string(), + "😢".to_string(), + "🙏".to_string(), + "👏".to_string(), + "🔥".to_string(), + ])), + network_state: Arc::new(Mutex::new(NetworkState::Ready)), + typing_chat_id: Arc::new(Mutex::new(None)), + current_chat_id: Arc::new(Mutex::new(None)), + current_pinned_message: Arc::new(Mutex::new(None)), + auth_state: Arc::new(Mutex::new(AuthState::Ready)), + sent_messages: Arc::new(Mutex::new(vec![])), + edited_messages: Arc::new(Mutex::new(vec![])), + deleted_messages: Arc::new(Mutex::new(vec![])), + forwarded_messages: Arc::new(Mutex::new(vec![])), + searched_queries: Arc::new(Mutex::new(vec![])), + viewed_messages: Arc::new(Mutex::new(vec![])), + chat_actions: Arc::new(Mutex::new(vec![])), + pending_view_messages: Arc::new(Mutex::new(vec![])), + downloaded_files: Arc::new(Mutex::new(HashMap::new())), + update_tx: Arc::new(Mutex::new(None)), + simulate_delays: false, + fail_next_operation: Arc::new(Mutex::new(false)), + } + } +} diff --git a/src/test_support/fake_tdclient_impl.rs b/src/test_support/fake_tdclient_impl.rs new file mode 100644 index 0000000..d03e22d --- /dev/null +++ b/src/test_support/fake_tdclient_impl.rs @@ -0,0 +1,360 @@ +//! Test implementation of the TDLib client traits for FakeTdClient. + +use super::fake_tdclient::FakeTdClient; +use crate::tdlib::{ + AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient, + MessageClient, NotificationClient, ReactionClient, UpdateClient, UserClient, +}; +use crate::tdlib::{ + AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, + UserOnlineStatus, +}; +use crate::types::{ChatId, MessageId, UserId}; +use async_trait::async_trait; +use std::borrow::Cow; +use std::path::PathBuf; +use tdlib_rs::enums::{ChatAction, Update}; + +#[async_trait] +impl AuthClient for FakeTdClient { + async fn send_phone_number(&self, _phone: String) -> Result<(), String> { + Ok(()) + } + + async fn send_code(&self, _code: String) -> Result<(), String> { + Ok(()) + } + + async fn send_password(&self, _password: String) -> Result<(), String> { + Ok(()) + } +} + +#[async_trait] +impl ChatClient for FakeTdClient { + async fn load_chats(&mut self, limit: i32) -> Result<(), String> { + let _ = FakeTdClient::load_chats(self, limit as usize).await?; + Ok(()) + } + + async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> { + FakeTdClient::load_folder_chats(self, folder_id, limit as usize).await + } + + async fn leave_chat(&self, _chat_id: ChatId) -> Result<(), String> { + Ok(()) + } + + async fn get_profile_info(&self, chat_id: ChatId) -> Result { + FakeTdClient::get_profile_info(self, chat_id).await + } + + fn chats(&self) -> &[ChatInfo] { + &[] + } + + fn folders(&self) -> &[FolderInfo] { + &[] + } + + fn main_chat_list_position(&self) -> i32 { + 0 + } + + fn set_main_chat_list_position(&mut self, _position: i32) {} + + fn update_chats(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + updater(&mut self.chats.lock().unwrap()); + } + + fn update_folders(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + updater(&mut self.folders.lock().unwrap()); + } +} + +#[async_trait] +impl ChatActionClient for FakeTdClient { + async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) { + FakeTdClient::send_chat_action(self, chat_id, format!("{:?}", action)).await; + } + + fn clear_stale_typing_status(&mut self) -> bool { + false + } + + fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> { + None + } + + fn set_typing_status(&mut self, _status: Option<(UserId, String, std::time::Instant)>) {} +} + +#[async_trait] +impl MessageClient for FakeTdClient { + async fn get_chat_history( + &mut self, + chat_id: ChatId, + limit: i32, + ) -> Result, String> { + FakeTdClient::get_chat_history(self, chat_id, limit).await + } + + async fn load_older_messages( + &mut self, + chat_id: ChatId, + from_message_id: MessageId, + ) -> Result, String> { + FakeTdClient::load_older_messages(self, chat_id, from_message_id).await + } + + async fn get_pinned_messages(&mut self, _chat_id: ChatId) -> Result, String> { + Ok(vec![]) + } + + async fn load_current_pinned_message(&mut self, _chat_id: ChatId) {} + + async fn search_messages( + &self, + chat_id: ChatId, + query: &str, + ) -> Result, String> { + FakeTdClient::search_messages(self, chat_id, query).await + } + + async fn send_message( + &mut self, + chat_id: ChatId, + text: String, + reply_to_message_id: Option, + reply_info: Option, + ) -> Result { + FakeTdClient::send_message(self, chat_id, text, reply_to_message_id, reply_info).await + } + + async fn edit_message( + &mut self, + chat_id: ChatId, + message_id: MessageId, + new_text: String, + ) -> Result { + FakeTdClient::edit_message(self, chat_id, message_id, new_text).await + } + + async fn delete_messages( + &mut self, + chat_id: ChatId, + message_ids: Vec, + revoke: bool, + ) -> Result<(), String> { + FakeTdClient::delete_messages(self, chat_id, message_ids, revoke).await + } + + async fn forward_messages( + &mut self, + to_chat_id: ChatId, + from_chat_id: ChatId, + message_ids: Vec, + ) -> Result<(), String> { + FakeTdClient::forward_messages(self, from_chat_id, to_chat_id, message_ids).await + } + + async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> { + FakeTdClient::set_draft_message(self, chat_id, text).await + } + + fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]> { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + Cow::Owned(self.get_messages(chat_id)) + } else { + Cow::Owned(Vec::new()) + } + } + + fn current_chat_id(&self) -> Option { + self.get_current_chat_id().map(ChatId::new) + } + + fn current_pinned_message(&self) -> Option { + self.current_pinned_message.lock().unwrap().clone() + } + + fn push_message(&mut self, msg: MessageInfo) { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + self.messages + .lock() + .unwrap() + .entry(chat_id) + .or_default() + .push(msg); + } + } + + fn clear_current_chat_messages(&mut self) { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + self.messages.lock().unwrap().remove(&chat_id); + } + } + + fn set_current_chat_messages(&mut self, messages: Vec) { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + self.messages.lock().unwrap().insert(chat_id, messages); + } + } + + fn update_current_chat_messages(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + let mut all_messages = self.messages.lock().unwrap(); + updater(all_messages.entry(chat_id).or_default()); + } + } + + fn set_current_chat_id(&mut self, chat_id: Option) { + *self.current_chat_id.lock().unwrap() = chat_id.map(|id| id.as_i64()); + } + + fn set_current_pinned_message(&mut self, msg: Option) { + *self.current_pinned_message.lock().unwrap() = msg; + } + + fn pending_view_messages(&self) -> &[(ChatId, Vec)] { + &[] + } + + fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec) { + self.pending_view_messages + .lock() + .unwrap() + .push((chat_id, message_ids)); + } + + async fn fetch_missing_reply_info(&mut self) {} + + async fn process_pending_view_messages(&mut self) { + let mut pending = self.pending_view_messages.lock().unwrap(); + for (chat_id, message_ids) in pending.drain(..) { + let ids: Vec = message_ids.iter().map(|id| id.as_i64()).collect(); + self.viewed_messages + .lock() + .unwrap() + .push((chat_id.as_i64(), ids)); + } + } +} + +#[async_trait] +impl UserClient for FakeTdClient { + fn get_user_status_by_chat_id(&self, _chat_id: ChatId) -> Option<&UserOnlineStatus> { + None + } + + fn pending_user_ids(&self) -> &[UserId] { + &[] + } + + fn user_cache(&self) -> &UserCache { + use std::sync::OnceLock; + static EMPTY_CACHE: OnceLock = OnceLock::new(); + EMPTY_CACHE.get_or_init(|| UserCache::new(0)) + } + + fn update_user_cache(&mut self, _updater: F) + where + F: FnOnce(&mut UserCache), + { + } + + async fn process_pending_user_ids(&mut self) {} +} + +#[async_trait] +impl ReactionClient for FakeTdClient { + async fn get_message_available_reactions( + &self, + chat_id: ChatId, + message_id: MessageId, + ) -> Result, String> { + FakeTdClient::get_message_available_reactions(self, chat_id, message_id).await + } + + async fn toggle_reaction( + &self, + chat_id: ChatId, + message_id: MessageId, + reaction: String, + ) -> Result<(), String> { + FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await + } +} + +#[async_trait] +impl FileClient for FakeTdClient { + async fn download_file(&self, file_id: i32) -> Result { + FakeTdClient::download_file(self, file_id).await + } + + async fn download_voice_note(&self, file_id: i32) -> Result { + Ok(format!("/tmp/fake_voice_{}.ogg", file_id)) + } +} + +#[async_trait] +impl ClientState for FakeTdClient { + fn client_id(&self) -> i32 { + 0 + } + + async fn get_me(&self) -> Result { + Ok(12345) + } + + fn auth_state(&self) -> &AuthState { + use std::sync::OnceLock; + static AUTH_STATE_READY: AuthState = AuthState::Ready; + static AUTH_STATE_WAIT_PHONE: OnceLock = OnceLock::new(); + static AUTH_STATE_WAIT_CODE: OnceLock = OnceLock::new(); + static AUTH_STATE_WAIT_PASSWORD: OnceLock = OnceLock::new(); + + let current = self.auth_state.lock().unwrap(); + match *current { + AuthState::Ready => &AUTH_STATE_READY, + AuthState::WaitPhoneNumber => { + AUTH_STATE_WAIT_PHONE.get_or_init(|| AuthState::WaitPhoneNumber) + } + AuthState::WaitCode => AUTH_STATE_WAIT_CODE.get_or_init(|| AuthState::WaitCode), + AuthState::WaitPassword => { + AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword) + } + _ => &AUTH_STATE_READY, + } + } + + fn network_state(&self) -> crate::tdlib::types::NetworkState { + FakeTdClient::get_network_state(self) + } +} + +impl NotificationClient for FakeTdClient { + fn configure_notifications(&mut self, _config: &crate::config::NotificationsConfig) {} + + fn sync_notification_muted_chats(&mut self) {} +} + +#[async_trait] +impl AccountClient for FakeTdClient { + async fn recreate_client(&mut self, _db_path: PathBuf) -> Result<(), String> { + Ok(()) + } +} + +impl UpdateClient for FakeTdClient { + fn handle_update(&mut self, _update: Update) {} +} diff --git a/src/test_support/mod.rs b/src/test_support/mod.rs new file mode 100644 index 0000000..6ce3c4e --- /dev/null +++ b/src/test_support/mod.rs @@ -0,0 +1,9 @@ +//! Test-only support for deterministic UI fixtures and integration tests. + +pub mod app_builder; +pub mod fake_tdclient; +mod fake_tdclient_impl; +pub mod snapshot_utils; +pub mod test_data; + +pub use fake_tdclient::FakeTdClient; diff --git a/src/test_support/snapshot_utils.rs b/src/test_support/snapshot_utils.rs new file mode 100644 index 0000000..aa83b9c --- /dev/null +++ b/src/test_support/snapshot_utils.rs @@ -0,0 +1,144 @@ +// Snapshot testing utilities + +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::style::{Color, Modifier}; +use ratatui::Terminal; + +/// Конвертирует Buffer в читаемую строку для snapshot тестов +pub fn buffer_to_string(buffer: &Buffer) -> String { + let area = buffer.area(); + let mut result = String::new(); + + for y in 0..area.height { + let mut line = String::new(); + for x in 0..area.width { + line.push_str(buffer[(x, y)].symbol()); + } + // Убираем trailing spaces в конце строки + result.push_str(line.trim_end()); + if y < area.height - 1 { + result.push('\n'); + } + } + + result +} + +/// Serializes only cells with non-default style, grouped by row and style. +pub fn buffer_to_style_snapshot(buffer: &Buffer) -> String { + let area = buffer.area(); + let mut rows = Vec::new(); + + for y in 0..area.height { + let mut segments = Vec::new(); + let mut x = 0; + + while x < area.width { + let cell = &buffer[(x, y)]; + if is_default_style(cell) { + x += 1; + continue; + } + + let start = x; + let fg = cell.fg; + let bg = cell.bg; + let modifier = cell.modifier; + let mut text = String::new(); + + while x < area.width { + let next = &buffer[(x, y)]; + if is_default_style(next) + || next.fg != fg + || next.bg != bg + || next.modifier != modifier + { + break; + } + text.push_str(next.symbol()); + x += 1; + } + + segments.push(format!( + "{}..{} {:?}/{:?}/{:?}: {:?}", + start, + x.saturating_sub(1), + fg, + bg, + modifier, + text.trim_end() + )); + } + + if !segments.is_empty() { + rows.push(format!("y={}: {}", y, segments.join(" | "))); + } + } + + rows.join("\n") +} + +fn is_default_style(cell: &ratatui::buffer::Cell) -> bool { + cell.fg == Color::Reset && cell.bg == Color::Reset && cell.modifier == Modifier::empty() +} + +/// Создаёт TestBackend с заданным размером и рендерит UI +pub fn render_to_buffer(width: u16, height: u16, render_fn: F) -> Buffer +where + F: FnOnce(&mut ratatui::Frame), +{ + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(render_fn).unwrap(); + + terminal.backend().buffer().clone() +} + +/// Макрос для упрощения snapshot тестов +#[macro_export] +macro_rules! assert_ui_snapshot { + ($name:expr, $width:expr, $height:expr, $render_fn:expr) => {{ + use $crate::test_support::snapshot_utils::{buffer_to_string, render_to_buffer}; + let buffer = render_to_buffer($width, $height, $render_fn); + let output = buffer_to_string(&buffer); + insta::assert_snapshot!($name, output); + }}; +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::layout::Rect; + use ratatui::widgets::{Block, Borders}; + + #[test] + fn test_buffer_to_string_simple() { + let buffer = render_to_buffer(10, 3, |f| { + let block = Block::default().borders(Borders::ALL).title("Hi"); + f.render_widget(block, f.area()); + }); + + let result = buffer_to_string(&buffer); + assert!(result.contains("Hi")); + assert!(result.contains("┌")); + assert!(result.contains("└")); + } + + #[test] + fn test_buffer_to_string_removes_trailing_spaces() { + let buffer = render_to_buffer(20, 3, |f| { + let block = Block::default().title("Test"); + f.render_widget(block, Rect::new(0, 0, 10, 3)); + }); + + let result = buffer_to_string(&buffer); + let lines: Vec<&str> = result.lines().collect(); + + // Проверяем что trailing spaces убраны + for line in lines { + assert!(!line.ends_with(' ') || line.trim().is_empty()); + } + } +} diff --git a/src/test_support/test_data.rs b/src/test_support/test_data.rs new file mode 100644 index 0000000..7ebf91d --- /dev/null +++ b/src/test_support/test_data.rs @@ -0,0 +1,252 @@ +// Test data builders and fixtures + +use crate::tdlib::types::{ForwardInfo, ReactionInfo}; +use crate::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo}; +use crate::types::{ChatId, MessageId}; + +/// Builder для создания тестового чата +#[allow(dead_code)] +pub struct TestChatBuilder { + id: i64, + title: String, + username: Option, + last_message: String, + last_message_date: i32, + unread_count: i32, + unread_mention_count: i32, + is_pinned: bool, + order: i64, + last_read_outbox_message_id: i64, + folder_ids: Vec, + is_muted: bool, + draft_text: Option, +} + +#[allow(dead_code)] +impl TestChatBuilder { + pub fn new(title: &str, id: i64) -> Self { + Self { + id, + title: title.to_string(), + username: None, + last_message: "".to_string(), + last_message_date: 1640000000, + unread_count: 0, + unread_mention_count: 0, + is_pinned: false, + order: id, + last_read_outbox_message_id: 0, + folder_ids: vec![0], + is_muted: false, + draft_text: None, + } + } + + pub fn username(mut self, username: &str) -> Self { + self.username = Some(username.to_string()); + self + } + + pub fn last_message(mut self, text: &str) -> Self { + self.last_message = text.to_string(); + self + } + + pub fn unread_count(mut self, count: i32) -> Self { + self.unread_count = count; + self + } + + pub fn unread_mentions(mut self, count: i32) -> Self { + self.unread_mention_count = count; + self + } + + pub fn pinned(mut self) -> Self { + self.is_pinned = true; + self + } + + pub fn muted(mut self) -> Self { + self.is_muted = true; + self + } + + pub fn draft(mut self, text: &str) -> Self { + self.draft_text = Some(text.to_string()); + self + } + + pub fn folder(mut self, folder_id: i32) -> Self { + self.folder_ids = vec![folder_id]; + self + } + + pub fn build(self) -> ChatInfo { + ChatInfo { + id: ChatId::new(self.id), + title: self.title, + username: self.username, + last_message: self.last_message, + last_message_date: self.last_message_date, + unread_count: self.unread_count, + unread_mention_count: self.unread_mention_count, + is_pinned: self.is_pinned, + order: self.order, + last_read_outbox_message_id: MessageId::new(self.last_read_outbox_message_id), + folder_ids: self.folder_ids, + is_muted: self.is_muted, + draft_text: self.draft_text, + } + } +} + +/// Builder для создания тестового сообщения +#[allow(dead_code)] +pub struct TestMessageBuilder { + id: i64, + sender_name: String, + is_outgoing: bool, + content: String, + entities: Vec, + date: i32, + edit_date: i32, + is_read: bool, + can_be_edited: bool, + can_be_deleted_only_for_self: bool, + can_be_deleted_for_all_users: bool, + reply_to: Option, + forward_from: Option, + reactions: Vec, + media_album_id: i64, +} + +#[allow(dead_code)] +impl TestMessageBuilder { + pub fn new(content: &str, id: i64) -> Self { + Self { + id, + sender_name: "User".to_string(), + is_outgoing: false, + content: content.to_string(), + entities: vec![], + date: 1640000000, + edit_date: 0, + is_read: true, + can_be_edited: false, + can_be_deleted_only_for_self: true, + can_be_deleted_for_all_users: false, + reply_to: None, + forward_from: None, + reactions: vec![], + media_album_id: 0, + } + } + + pub fn outgoing(mut self) -> Self { + self.is_outgoing = true; + self.sender_name = "You".to_string(); + self.can_be_edited = true; + self.can_be_deleted_for_all_users = true; + self + } + + pub fn sender(mut self, name: &str) -> Self { + self.sender_name = name.to_string(); + self + } + + pub fn date(mut self, timestamp: i32) -> Self { + self.date = timestamp; + self + } + + pub fn edited(mut self) -> Self { + self.edit_date = self.date + 60; + self + } + + pub fn unread(mut self) -> Self { + self.is_read = false; + self + } + + pub fn reply_to(mut self, message_id: i64, sender: &str, text: &str) -> Self { + self.reply_to = Some(ReplyInfo { + message_id: MessageId::new(message_id), + sender_name: sender.to_string(), + text: text.to_string(), + }); + self + } + + pub fn forwarded_from(mut self, sender: &str) -> Self { + self.forward_from = Some(ForwardInfo { sender_name: sender.to_string() }); + self + } + + pub fn reaction(mut self, emoji: &str, count: i32, chosen: bool) -> Self { + self.reactions + .push(ReactionInfo { emoji: emoji.to_string(), count, is_chosen: chosen }); + self + } + + pub fn media_album_id(mut self, id: i64) -> Self { + self.media_album_id = id; + self + } + + pub fn build(self) -> MessageInfo { + let mut msg = MessageInfo::new( + MessageId::new(self.id), + self.sender_name, + self.is_outgoing, + self.content, + self.entities, + self.date, + self.edit_date, + self.is_read, + self.can_be_edited, + self.can_be_deleted_only_for_self, + self.can_be_deleted_for_all_users, + self.reply_to, + self.forward_from, + self.reactions, + ); + msg.metadata.media_album_id = self.media_album_id; + msg + } +} + +/// Хелперы для быстрого создания тестовых данных +pub fn create_test_chat(title: &str, id: i64) -> ChatInfo { + TestChatBuilder::new(title, id).build() +} + +#[allow(dead_code)] +pub fn create_test_message(content: &str, id: i64) -> MessageInfo { + TestMessageBuilder::new(content, id).build() +} + +#[allow(dead_code)] +pub fn create_test_user(name: &str, id: i64) -> (i64, String) { + (id, name.to_string()) +} + +/// Хелпер для создания профиля +#[allow(dead_code)] +pub fn create_test_profile(title: &str, chat_id: i64) -> ProfileInfo { + ProfileInfo { + chat_id: ChatId::new(chat_id), + title: title.to_string(), + username: None, + bio: None, + phone_number: None, + chat_type: "Личный чат".to_string(), + member_count: None, + description: None, + invite_link: None, + is_group: false, + online_status: None, + } +} diff --git a/tests/e2e_termwright.rs b/tests/e2e_termwright.rs new file mode 100644 index 0000000..5a9503f --- /dev/null +++ b/tests/e2e_termwright.rs @@ -0,0 +1,167 @@ +#![cfg(feature = "test-support")] + +use std::time::{Duration, Instant}; +use termwright::prelude::*; + +fn fixture_path() -> &'static str { + env!("CARGO_BIN_EXE_tele-tui-test-fixture") +} + +async fn spawn_fixture(scenario: &str) -> Result { + let mut builder = Terminal::builder() + .size(100, 30) + .working_dir(env!("CARGO_MANIFEST_DIR")); + + if let Some(lib_path) = tdlib_library_path() { + builder = builder + .env("DYLD_LIBRARY_PATH", &lib_path) + .env("LD_LIBRARY_PATH", &lib_path); + } + let command = format!( + "stty -echo -ixon; exec {} --scenario {}", + shell_quote(fixture_path()), + shell_quote(scenario) + ); + builder.spawn("/bin/sh", &["-lc", &command]).await +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn tdlib_library_path() -> Option { + let build_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("debug") + .join("build"); + let entries = std::fs::read_dir(build_dir).ok()?; + + let mut paths = Vec::new(); + for entry in entries.flatten() { + let lib_dir = entry.path().join("out").join("tdlib").join("lib"); + if lib_dir.join("libtdjson.1.8.29.dylib").exists() || lib_dir.join("libtdjson.so").exists() + { + paths.push(lib_dir); + } + } + + (!paths.is_empty()).then(|| { + paths + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect::>() + .join(":") + }) +} + +async fn stop_fixture(term: &mut Terminal) { + let _ = tokio::time::timeout(Duration::from_millis(500), term.send_key(Key::F(10))).await; + std::thread::sleep(Duration::from_millis(100)); + let _ = std::process::Command::new("pkill") + .arg("-f") + .arg("tele-tui-test-fixture") + .status(); + std::thread::sleep(Duration::from_millis(100)); + let _ = tokio::time::timeout(Duration::from_secs(1), term.kill()).await; +} + +async fn wait_for_text(term: &Terminal, needle: &str) -> Result<()> { + let started = Instant::now(); + let mut last_screen = String::new(); + for _ in 0..100 { + let Ok(screen) = screen_text(term).await else { + continue; + }; + if screen.contains(needle) { + return Ok(()); + } + last_screen = screen; + std::thread::sleep(Duration::from_millis(50)); + } + + let elapsed = started.elapsed(); + Err(TermwrightError::Timeout { + condition: format!("text '{needle}' to appear\n\n{last_screen}"), + timeout: elapsed, + }) +} + +async fn screen_text(term: &Terminal) -> Result { + tokio::time::timeout(Duration::from_millis(500), term.screen()) + .await + .map(|screen| screen.text()) + .map_err(|_| TermwrightError::Timeout { + condition: "terminal screen snapshot".to_string(), + timeout: Duration::from_millis(500), + }) +} + +async fn enter_insert_mode(term: &Terminal) -> Result<()> { + for _ in 0..5 { + term.send_key(Key::Char('i')).await?; + std::thread::sleep(Duration::from_millis(150)); + if !screen_text(term).await?.contains("Press i to type") { + return Ok(()); + } + } + + let screen = screen_text(term).await?; + Err(TermwrightError::Timeout { + condition: format!("insert mode to start\n\n{screen}"), + timeout: Duration::from_millis(750), + }) +} + +#[test] +fn e2e_termwright_user_flows() -> Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("failed to build e2e runtime"); + + let result = runtime.block_on(async { + tokio::time::timeout(Duration::from_secs(15), compose_and_send_message()).await + }); + kill_fixture_processes(); + + match result { + Ok(result) => result, + Err(_) => Err(TermwrightError::Timeout { + condition: "termwright e2e user flow".to_string(), + timeout: Duration::from_secs(15), + }), + } +} + +async fn compose_and_send_message() -> Result<()> { + let mut term = spawn_fixture("compose-draft").await?; + let result = async { + wait_for_text(&term, "Work Group").await?; + wait_for_text(&term, "Standup notes are ready").await?; + wait_for_text(&term, "hello from e2e").await?; + enter_insert_mode(&term).await?; + wait_for_text(&term, "hello from e2e").await?; + term.send_key(Key::Enter).await?; + std::thread::sleep(Duration::from_millis(500)); + + let screen = screen_text(&term).await?; + assert!(screen.contains("hello from e2e"), "sent message should appear\n\n{}", screen); + assert!( + !screen.contains("Сообщение: hello from e2e"), + "compose input should clear after send" + ); + Ok(()) + } + .await; + + stop_fixture(&mut term).await; + result +} + +fn kill_fixture_processes() { + let _ = std::process::Command::new("pkill") + .arg("-f") + .arg("tele-tui-test-fixture") + .status(); +} diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs index 1eb1b9c..50722db 100644 --- a/tests/helpers/mod.rs +++ b/tests/helpers/mod.rs @@ -1,9 +1,22 @@ -// Test helpers module +// Test helpers module. +// +// In all-features runs, integration tests exercise the same gated support module +// used by the PTY fixture binary. Plain `cargo test` keeps the local copies so +// existing tests do not need the internal feature enabled. +#[cfg(feature = "test-support")] +pub use tele_tui::test_support::*; + +#[cfg(not(feature = "test-support"))] pub mod app_builder; +#[cfg(not(feature = "test-support"))] pub mod fake_tdclient; +#[cfg(not(feature = "test-support"))] mod fake_tdclient_impl; // TdClientTrait implementation for FakeTdClient +#[cfg(not(feature = "test-support"))] pub mod snapshot_utils; +#[cfg(not(feature = "test-support"))] pub mod test_data; +#[cfg(not(feature = "test-support"))] pub use fake_tdclient::FakeTdClient; diff --git a/tests/helpers/snapshot_utils.rs b/tests/helpers/snapshot_utils.rs index 29cda24..6ff2733 100644 --- a/tests/helpers/snapshot_utils.rs +++ b/tests/helpers/snapshot_utils.rs @@ -2,7 +2,7 @@ use ratatui::backend::TestBackend; use ratatui::buffer::Buffer; -use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier}; use ratatui::Terminal; /// Конвертирует Buffer в читаемую строку для snapshot тестов @@ -25,6 +25,64 @@ pub fn buffer_to_string(buffer: &Buffer) -> String { result } +/// Serializes only cells with non-default style, grouped by row and style. +pub fn buffer_to_style_snapshot(buffer: &Buffer) -> String { + let area = buffer.area(); + let mut rows = Vec::new(); + + for y in 0..area.height { + let mut segments = Vec::new(); + let mut x = 0; + + while x < area.width { + let cell = &buffer[(x, y)]; + if is_default_style(cell) { + x += 1; + continue; + } + + let start = x; + let fg = cell.fg; + let bg = cell.bg; + let modifier = cell.modifier; + let mut text = String::new(); + + while x < area.width { + let next = &buffer[(x, y)]; + if is_default_style(next) + || next.fg != fg + || next.bg != bg + || next.modifier != modifier + { + break; + } + text.push_str(next.symbol()); + x += 1; + } + + segments.push(format!( + "{}..{} {:?}/{:?}/{:?}: {:?}", + start, + x.saturating_sub(1), + fg, + bg, + modifier, + text.trim_end() + )); + } + + if !segments.is_empty() { + rows.push(format!("y={}: {}", y, segments.join(" | "))); + } + } + + rows.join("\n") +} + +fn is_default_style(cell: &ratatui::buffer::Cell) -> bool { + cell.fg == Color::Reset && cell.bg == Color::Reset && cell.modifier == Modifier::empty() +} + /// Создаёт TestBackend с заданным размером и рендерит UI pub fn render_to_buffer(width: u16, height: u16, render_fn: F) -> Buffer where @@ -52,6 +110,7 @@ macro_rules! assert_ui_snapshot { #[cfg(test)] mod tests { use super::*; + use ratatui::layout::Rect; use ratatui::widgets::{Block, Borders}; #[test] diff --git a/tests/screens.rs b/tests/screens.rs index f994791..7842f77 100644 --- a/tests/screens.rs +++ b/tests/screens.rs @@ -4,8 +4,10 @@ mod helpers; use helpers::app_builder::TestAppBuilder; use helpers::snapshot_utils::{buffer_to_string, render_to_buffer}; -use helpers::test_data::create_test_chat; +use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder}; use insta::assert_snapshot; +use tele_tui::accounts::AccountProfile; +use tele_tui::app::AccountSwitcherState; use tele_tui::app::AppScreen; use tele_tui::tdlib::AuthState; @@ -113,3 +115,114 @@ fn snapshot_main_screen_terminal_too_small() { let output = buffer_to_string(&buffer); assert_snapshot!("main_screen_terminal_too_small", output); } + +#[test] +fn snapshot_main_screen_chat_list_loaded() { + let mut app = TestAppBuilder::new() + .screen(AppScreen::Main) + .with_chats(sample_chats()) + .build(); + + let buffer = render_to_buffer(100, 30, |f| { + tele_tui::ui::render(f, &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("main_screen_chat_list_loaded", output); +} + +#[test] +fn snapshot_main_screen_chat_open_with_messages() { + let mut app = TestAppBuilder::new() + .screen(AppScreen::Main) + .with_chats(sample_chats()) + .selected_chat(102) + .with_messages(102, sample_work_messages()) + .message_input("Draft reply") + .insert_mode() + .build(); + + let buffer = render_to_buffer(100, 30, |f| { + tele_tui::ui::render(f, &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("main_screen_chat_open_with_messages", output); +} + +#[test] +fn snapshot_main_screen_chat_open_narrow_valid() { + let mut app = TestAppBuilder::new() + .screen(AppScreen::Main) + .with_chats(sample_chats()) + .selected_chat(102) + .with_messages(102, sample_work_messages()) + .build(); + + let buffer = render_to_buffer(60, 16, |f| { + tele_tui::ui::render(f, &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("main_screen_chat_open_narrow_valid", output); +} + +#[test] +fn snapshot_main_screen_account_switcher_overlay() { + let mut app = TestAppBuilder::new() + .screen(AppScreen::Main) + .with_chats(sample_chats()) + .build(); + app.current_account_name = "personal".to_string(); + app.account_switcher = Some(AccountSwitcherState::SelectAccount { + accounts: vec![ + AccountProfile { + name: "personal".to_string(), + display_name: "Personal".to_string(), + }, + AccountProfile { + name: "work".to_string(), + display_name: "Work".to_string(), + }, + ], + selected_index: 1, + current_account: "personal".to_string(), + }); + + let buffer = render_to_buffer(100, 30, |f| { + tele_tui::ui::render(f, &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("main_screen_account_switcher_overlay", output); +} + +fn sample_chats() -> Vec { + vec![ + TestChatBuilder::new("Mom", 101) + .last_message("Dinner at 7?") + .unread_count(2) + .build(), + TestChatBuilder::new("Work Group", 102) + .last_message("Standup notes are ready") + .unread_mentions(1) + .build(), + TestChatBuilder::new("Boss", 103) + .last_message("Please review the deck") + .build(), + ] +} + +fn sample_work_messages() -> Vec { + vec![ + TestMessageBuilder::new("Morning, team", 201) + .sender("Alice") + .build(), + TestMessageBuilder::new("Standup notes are ready", 202) + .sender("Bob") + .build(), + TestMessageBuilder::new("Thanks, I will review them after lunch", 203) + .outgoing() + .build(), + ] +} diff --git a/tests/snapshots/screens__main_screen_account_switcher_overlay.snap b/tests/snapshots/screens__main_screen_account_switcher_overlay.snap new file mode 100644 index 0000000..2c11158 --- /dev/null +++ b/tests/snapshots/screens__main_screen_account_switcher_overlay.snap @@ -0,0 +1,35 @@ +--- +source: tests/screens.rs +assertion_line: 197 +expression: output +--- +┌ TTUI ────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 1:All │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌────────────────────────────┐┌────────────────────────────────────────────────────────────────────┐ +│🔍 Ctrl+S для поиска ││ Выберите чат │ +└────────────────────────────┘│ │ +┌────────────────────────────┐│ │ +│ Mom (2) ││ │ +│ Work Group @ ││ │ +│ Boss ││ │ +│ │┌ АККАУНТЫ ────────────────────────────┐ │ +│ ││ │ │ +│ ││ ● personal (Personal) (текущий) │ │ +│ ││ work (Work) │ │ +│ ││ ────────────────────── │ │ +│ ││ + Добавить аккаунт │ │ +│ ││ │ │ +│ ││ j/k Nav Enter Select a Add Esc │ │ +│ │└──────────────────────────────────────┘ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +└────────────────────────────┘│ │ +┌────────────────────────────┐│ │ +│ ││ │ +└────────────────────────────┘└────────────────────────────────────────────────────────────────────┘ + [personal] Инициализация TDLib... diff --git a/tests/snapshots/screens__main_screen_chat_list_loaded.snap b/tests/snapshots/screens__main_screen_chat_list_loaded.snap new file mode 100644 index 0000000..087c40a --- /dev/null +++ b/tests/snapshots/screens__main_screen_chat_list_loaded.snap @@ -0,0 +1,35 @@ +--- +source: tests/screens.rs +assertion_line: 131 +expression: output +--- +┌ TTUI ────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 1:All │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌────────────────────────────┐┌────────────────────────────────────────────────────────────────────┐ +│🔍 Ctrl+S для поиска ││ Выберите чат │ +└────────────────────────────┘│ │ +┌────────────────────────────┐│ │ +│ Mom (2) ││ │ +│ Work Group @ ││ │ +│ Boss ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +└────────────────────────────┘│ │ +┌────────────────────────────┐│ │ +│ ││ │ +└────────────────────────────┘└────────────────────────────────────────────────────────────────────┘ + [default] Инициализация TDLib... diff --git a/tests/snapshots/screens__main_screen_chat_open_narrow_valid.snap b/tests/snapshots/screens__main_screen_chat_open_narrow_valid.snap new file mode 100644 index 0000000..c20400b --- /dev/null +++ b/tests/snapshots/screens__main_screen_chat_open_narrow_valid.snap @@ -0,0 +1,21 @@ +--- +source: tests/screens.rs +assertion_line: 167 +expression: output +--- +┌ TTUI ────────────────────────────────────────────────────┐ +│ 1:All │ +└──────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────┐ +│👤 Work Group │ +└──────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────┐ +│ (14:33) Standup notes are ready │ +│ │ +│ Вы ──────────────── │ +│ Thanks, I will review them after lunch (14:33 ✓✓) │ +└──────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────┐ +│> Press i to type... │ +└──────────────────────────────────────────────────────────┘ + [default] Инициализация TDLib... diff --git a/tests/snapshots/screens__main_screen_chat_open_with_messages.snap b/tests/snapshots/screens__main_screen_chat_open_with_messages.snap new file mode 100644 index 0000000..1fb8e30 --- /dev/null +++ b/tests/snapshots/screens__main_screen_chat_open_with_messages.snap @@ -0,0 +1,35 @@ +--- +source: tests/screens.rs +assertion_line: 150 +expression: output +--- +┌ TTUI ────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 1:All │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌────────────────────────────┐┌────────────────────────────────────────────────────────────────────┐ +│🔍 Ctrl+S для поиска ││👤 Work Group │ +└────────────────────────────┘└────────────────────────────────────────────────────────────────────┘ +┌────────────────────────────┐┌────────────────────────────────────────────────────────────────────┐ +│ Mom (2) ││ ──────── 20.12.2021 ──────── │ +│▌ Work Group @ ││ │ +│ Boss ││Alice ──────────────── │ +│ ││ (14:33) Morning, team │ +│ ││ │ +│ ││Bob ──────────────── │ +│ ││ (14:33) Standup notes are ready │ +│ ││ │ +│ ││ Вы ──────────────── │ +│ ││ Thanks, I will review them after lunch (14:33 ✓✓) │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +└────────────────────────────┘└────────────────────────────────────────────────────────────────────┘ +┌────────────────────────────┐┌────────────────────────────────────────────────────────────────────┐ +│ ││> Draft reply │ +└────────────────────────────┘└────────────────────────────────────────────────────────────────────┘ + [default] Инициализация TDLib... diff --git a/tests/snapshots/style_snapshots__style_reaction_picker_selection.snap b/tests/snapshots/style_snapshots__style_reaction_picker_selection.snap new file mode 100644 index 0000000..cea7a20 --- /dev/null +++ b/tests/snapshots/style_snapshots__style_reaction_picker_selection.snap @@ -0,0 +1,15 @@ +--- +source: tests/style_snapshots.rs +assertion_line: 78 +expression: buffer_to_style_snapshot(&buffer) +--- +y=1: 1..1 Cyan/Reset/BOLD: "👤" | 3..6 Cyan/Reset/BOLD: " Mom" +y=4: 21..48 Gray/Reset/NONE: "──────── 20.12.2021 ────────" +y=6: 1..4 Cyan/Reset/BOLD: "Mom" | 5..9 Gray/Reset/NONE: "─────" | 10..10 Yellow/Reset/NONE: "┌" | 11..26 Yellow/Reset/BOLD: " Выбери реакцию" | 27..59 Yellow/Reset/NONE: "────────────────────────────────┐" +y=7: 1..2 Yellow/Reset/BOLD: "" | 3..9 Gray/Reset/NONE: " (14:33" | 10..10 Yellow/Reset/NONE: "│" | 59..59 Yellow/Reset/NONE: "│" +y=8: 10..10 Yellow/Reset/NONE: "│" | 26..27 White/Reset/NONE: " 👍" | 29..29 White/Reset/NONE: "" | 31..32 White/Reset/NONE: " ❤\u{fe0f}" | 34..34 White/Reset/NONE: "" | 36..37 Yellow/Reset/BOLD | REVERSED: " 😂" | 39..39 Yellow/Reset/BOLD | REVERSED: "" | 41..42 White/Reset/NONE: " 🔥" | 44..44 White/Reset/NONE: "" | 59..59 Yellow/Reset/NONE: "│" +y=9: 10..10 Yellow/Reset/NONE: "│" | 59..59 Yellow/Reset/NONE: "│" +y=10: 10..59 Yellow/Reset/NONE: "└────────────────────────────────────────────────┘" +y=15: 0..69 DarkGray/Reset/NONE: "┌────────────────────────────────────────────────────────────────────┐" +y=16: 0..20 DarkGray/Reset/NONE: "│> Press i to type..." | 69..69 DarkGray/Reset/NONE: "│" +y=17: 0..69 DarkGray/Reset/NONE: "└────────────────────────────────────────────────────────────────────┘" diff --git a/tests/snapshots/style_snapshots__style_selected_chat.snap b/tests/snapshots/style_snapshots__style_selected_chat.snap new file mode 100644 index 0000000..0d0eb7b --- /dev/null +++ b/tests/snapshots/style_snapshots__style_selected_chat.snap @@ -0,0 +1,14 @@ +--- +source: tests/style_snapshots.rs +assertion_line: 24 +expression: buffer_to_style_snapshot(&buffer) +--- +y=0: 0..35 Rgb(160, 160, 160)/Reset/NONE: "┌──────────────────────────────────┐" +y=1: 0..1 Rgb(160, 160, 160)/Reset/NONE: "│🔍" | 3..35 Rgb(160, 160, 160)/Reset/NONE: " Ctrl+S для поиска │" +y=2: 0..35 Rgb(160, 160, 160)/Reset/NONE: "└──────────────────────────────────┘" +y=4: 1..34 White/Reset/NONE: " Mom" +y=5: 1..34 Yellow/Reset/ITALIC: " Work Group" +y=6: 1..34 White/Reset/NONE: " Boss" +y=9: 0..35 DarkGray/Reset/NONE: "┌──────────────────────────────────┐" +y=10: 0..35 DarkGray/Reset/NONE: "│ │" +y=11: 0..35 DarkGray/Reset/NONE: "└──────────────────────────────────┘" diff --git a/tests/snapshots/style_snapshots__style_selected_message.snap b/tests/snapshots/style_snapshots__style_selected_message.snap new file mode 100644 index 0000000..d8070bd --- /dev/null +++ b/tests/snapshots/style_snapshots__style_selected_message.snap @@ -0,0 +1,12 @@ +--- +source: tests/style_snapshots.rs +assertion_line: 47 +expression: buffer_to_style_snapshot(&buffer) +--- +y=1: 1..1 Cyan/Reset/BOLD: "👤" | 3..6 Cyan/Reset/BOLD: " Mom" +y=4: 21..48 Gray/Reset/NONE: "──────── 20.12.2021 ────────" +y=6: 1..4 Cyan/Reset/BOLD: "Mom" | 5..20 Gray/Reset/NONE: "────────────────" +y=7: 1..2 Yellow/Reset/BOLD: "" | 3..10 Gray/Reset/NONE: " (14:33)" | 12..24 White/Reset/NONE: "First message" +y=8: 1..2 Yellow/Reset/BOLD: "▶" | 3..10 Gray/Reset/NONE: " (14:33)" | 12..27 Yellow/Reset/NONE: "Selected message" +y=15: 1..17 Magenta/Reset/BOLD: " Выбор сообщения" +y=16: 1..55 Cyan/Reset/NONE: "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc" diff --git a/tests/style_snapshots.rs b/tests/style_snapshots.rs new file mode 100644 index 0000000..6cf26f5 --- /dev/null +++ b/tests/style_snapshots.rs @@ -0,0 +1,81 @@ +// Focused style snapshot tests. + +mod helpers; + +use helpers::app_builder::TestAppBuilder; +use helpers::snapshot_utils::{buffer_to_style_snapshot, render_to_buffer}; +use helpers::test_data::{TestChatBuilder, TestMessageBuilder}; +use insta::assert_snapshot; + +#[test] +fn snapshot_style_selected_chat() { + let chats = vec![ + TestChatBuilder::new("Mom", 101).build(), + TestChatBuilder::new("Work Group", 102).build(), + TestChatBuilder::new("Boss", 103).build(), + ]; + let mut app = TestAppBuilder::new().with_chats(chats).build(); + app.chat_list_state.select(Some(1)); + + let buffer = render_to_buffer(36, 12, |f| { + tele_tui::ui::chat_list::render(f, f.area(), &mut app); + }); + + assert_snapshot!("style_selected_chat", buffer_to_style_snapshot(&buffer)); +} + +#[test] +fn snapshot_style_selected_message() { + let chat = TestChatBuilder::new("Mom", 101).build(); + let messages = vec![ + TestMessageBuilder::new("First message", 201) + .sender("Mom") + .build(), + TestMessageBuilder::new("Selected message", 202) + .sender("Mom") + .build(), + ]; + let mut app = TestAppBuilder::new() + .with_chat(chat) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(1) + .build(); + + let buffer = render_to_buffer(70, 18, |f| { + tele_tui::ui::messages::render(f, f.area(), &mut app); + }); + + assert_snapshot!("style_selected_message", buffer_to_style_snapshot(&buffer)); +} + +#[test] +fn snapshot_style_reaction_picker_selection() { + let chat = TestChatBuilder::new("Mom", 101).build(); + let message = TestMessageBuilder::new("React to this", 201) + .sender("Mom") + .build(); + let mut app = TestAppBuilder::new() + .with_chat(chat) + .selected_chat(101) + .with_message(101, message) + .reaction_picker( + 201, + vec![ + "👍".to_string(), + "❤️".to_string(), + "😂".to_string(), + "🔥".to_string(), + ], + ) + .build(); + if let tele_tui::app::ChatState::ReactionPicker { selected_index, .. } = &mut app.chat_state { + *selected_index = 2; + } + + let buffer = render_to_buffer(70, 18, |f| { + tele_tui::ui::messages::render(f, f.area(), &mut app); + }); + + assert_snapshot!("style_reaction_picker_selection", buffer_to_style_snapshot(&buffer)); +}