From 066e891b7876a1e7635475f74ad999e29131bbe1 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 24 Jul 2024 22:28:27 +0100 Subject: [PATCH] Testing new themes system, ensuring tree cursor is always readable (auto) --- .coverage | Bin 53248 -> 53248 bytes README.md | 2 +- pyproject.toml | 1 + requirements-dev.lock | 6 + src/posting/__main__.py | 4 +- src/posting/config.py | 4 +- src/posting/locations.py | 8 +- src/posting/posting.scss | 2 + src/posting/themes.py | 4 +- ...test_loads_and_shows_discovery_options.svg | 170 ++++----- ..._set_on_startup_and_in_command_palette.svg | 187 ++++++++++ tests/conftest.py | 345 ------------------ tests/sample-configs/custom_theme.yaml | 10 + tests/sample-themes/one.yml | 11 + tests/sample-themes/two.yaml | 10 + tests/test_snapshots.py | 15 + 16 files changed, 338 insertions(+), 441 deletions(-) create mode 100644 tests/__snapshots__/test_snapshots/TestCustomTheme.test_theme_set_on_startup_and_in_command_palette.svg delete mode 100644 tests/conftest.py create mode 100644 tests/sample-configs/custom_theme.yaml create mode 100644 tests/sample-themes/one.yml create mode 100644 tests/sample-themes/two.yaml diff --git a/.coverage b/.coverage index b787d5ecfe31853b0b81ecd1ed3081a5e7dde8e6..66d545e893787c09ab05dbabe3bdf0ec3819bf27 100644 GIT binary patch delta 2961 zcmZXWdvH@#9>>qk?>4zN_mz~iNgrv_l0N9WO$+7K7LiBVw1A+rP#Q{&ZN)xp+7)-9 zsjs6e;5r;eVRpi{f9!{bGkWM~o9b5)KN3!a8BDkn)n<_N--5YB`{) zNN;OVPj{rRqpPhb(%;q=j`W3lBZWQpd&&SU`T1yjM_c`21w|a^K9< z(qDAh(i@I+_xHAjjV}HYK%IoEbCCUkc)2~?a_5ci#l<8&;3ZnX&K1$9gm?q#G!x0z zfWQ|3>X@37!si2OWXT7C8GIg~wKPc}lL0%QJ6-lcKp>$SLGJG9>*($>mMxt{s%!jw z4xpB)xq|Jk?%vK&_|A^L?p|6^oK2TaA{S~@ar8{up!;cTgm!>8O`{S?l3FH+JZtay*wtii0_vn^f01 zNl#GWQprFtgG-^)iuq(f>!(*#LZObXj=oTcZY(W{#DX5qMbhfhIVYX!;}hvku+pt{ zB9}mLoriY-ijdFhJmf@O0yl#U*A;N_M6ORwv;&&+^IPj{4|j$mFZt?6xvp{{6>0y{ z-qnt1yR~Eusm}}Zq*KCr^%gZp{ahN5Tb0d1s;X*T>Ph9Iwo03=J}M_DZzjjD6c7hQg$m3Dw~wGN{*5s`4mak9(><>7=+?$%PD2X5mFL6?2T``WqAO|W^&Uf)&LxH3-#5l7xSA@dwqF&V{qMwzA|VMk)4Z(k z!j-*tbaZIs^yNnv9DMV=bALO51Pi{6j@dHa=ghdCMrzs-btf|88Y52TM7*2^4p(#K zqGAhPYLq6P+GXTkFgNvcV(;(0Vmw4Pi&FP%@s z6(qRB<(OksS*#XZZdAo}nJRD@*|MV|dKdQp-$Qx*)Q=D4`i7)fx89A=(fRnRGtVDA zv$H>rJ(P*$vlAOePEWq_)Yp2}wtaA6>h=E6(*t?mexrvbP1|zbyn1cf%#q=Tl)(bC z6PMDZi%lwvEdCg$|$G=gv{692wk-(`M8Nn>+;E$ zJF}w#UQd(wyf3qPH(p1>1-S$M8#n*e)gOi<(RRy@o!m2nV;3%HxRH)~XALJIl>6z6 zue}B`ZlFqXPHgTFn#_a*yq2c<*|FKSm0cm)+5o&Hg!JFn|i-8I#_d;#PYkr#2}SBJ*-zM522 zbZiT^7%w(jn(1%U;omJRPsNMqg7fUnvFNAYdM-uv5q(mR4e4Y0^&$O=Zo^e{I75rQ zZ42rJP0f{8qApxXKHTnFl7<)1p-MS+YGJn2a9p0+-k#X-2=ol-ry&i`H44Om!@qqS=rwFdQ&wo+TJRcjUM9<5l*(Y%^VeNwY&0{z+iUVTg*SO2Y!s-LKX^!w$E z61C8^O^N75tq++^c9z-EIYyb27%kilz#K*sl$xpYO;Ey8KeRFyLNntmXk_%k8b%MS zU`&P@Mgb}q%`lrXDw^V;oC!RXF#`A*OJNgZ3A8X4!$!s;2r(AG2F847V$6f}jJdFm zF$WqLvtcb`7Tm#@3AZ!Ogw+;4N>85ww=p3dRxx^EC1V=YGrFOUF%^Q0DNxIp1OY}D zEN67WGR8z$%9sF47#(mc;|y5L7!S8F+F_B!u#L$E)lAT!icy71Mg+#`p0FO;iSh~;=2uU=EH&|7^(wF3QztN{HZtPt)4{eOnTI;a2u delta 2798 zcmZvedr;KZ702&y&t>=b+uv(hmSvZHuq?|PcELpfMZj1qEa0QjfyT#*NDwtZL2H;% zm!$URw9(*r+Ze~GVy8*dmm0MujaBI%PGYobUsJ0hq()n36qSzA7-f6+;#Q~a&dz+! z`JQvnx%YR^KKcfrZvYPBY6$W#5<|6jmQlVU|3uy_x5$;UQ+h02koHO&q(;dvVexbE zu((rf5rZNa|76@}>^4>!Md35ysPG+OzL3V>=0D_j@ZaKJJe+8{ZqPY{#8uQNaGeVQftP!6Uk0u4kh6nK&xrQNoIvC zI2%wib9zH6&YEcEB=3dHI1^9{i(CqsurKZy>r6N!?o6eQH(qU3orOy$kva$V0BU8e z57pVZG;+2s4ZGvJfqFA`0b0o7h4qP?lT_9FI0so#@5QMT3!UWM`b2C8v?$i!6b`p{ zw66_^>Dk;iKr>@YrM2CyvBP>&0L^B#91TX!N^7N%=?yYYjy-sqXKe52TDLZ~(n8iW zq~Rn$JuLllgGlx_Bx5t6@KeERPXsi*r@JN4)!DPQy`wF#uBWv-yplSHeRD>*RY86M zQm?D0)kEqJ)KnEIzu>#Y>pW3jP;!)O;@i?j`MdlAwL|$-5!LzX6lIGPkcZ@Vl;!dY zsY&{Su}2yZSBNDdHvZZ8V`G(35qXM#YoYfaeyOt9m^Nzfio zrw?&rWD4@&n@k)C@#J`|5$ESZBNIky6LDUA$w0_NQbR_Z8-&$Q(@mryB;p)DH2-IP zZ%Dz}@sQ!zkr^RjJYhs3SL|m^7DE&%@(x_C4E0sb; z7MI9ZMwA9BXOnIR0~ytEsMDu0!Z+p1vi+_m&**U%hL*^N$~tkBrh+heON2oKTiT zZhhYt2~g9Mb^?tJ*~8~{-{D-El`0kb(cs7U#;FYzs-f*nvGYooh58p_bG1ZU5z4_vZ$9o93qeBxO7}5*Zyj zHlF$LL?wqXS~>`N%|%0s%(*ou+1KwbH)xJn6)}`7XsOh)7(=EPc$a3Uc7?F%;MhNw z?SAEA)$YjA_g}e;)?AKk=-X0-t(uMe>J6JEC05K}G-%1OA_*P3GR;E%_C{HxM@#yj zYdH3Qui;gC4XcNm8rd~0IPm_G;rst}4Y8}Z_17~ecSgtWM4}Jk?~li$zYbj3V*cg% zNXgjk=@-$(!F zXw|X37mcZcRz)pKz<&)rG-#DE)jwviYtyM!QaJixS0ei8P-AtlE}3yNsHioU3+(@~#{>b_|lW zDO4%Q8h_IEbu<{+Jhbtv^>;@7l@@I>)$=kx+6sI8bl>b{>T+u(RIc=qOWPdPL29}) zP6Ty*U!K}=FC|f%L$$>q`ts9517 zGu}+q=To!8L7LwTM)IhcmpZ=DJrdqjwH0XDS<6&O%b{|r%?~40+#?XQZ0cH4mR8Ov znx|#OuGeE7kGAshJubU?{L4qtS$s5FT3TNbUEdZ!^ph0}d#*+9M27E7yC042EzR`Q z?(m`Q7@7Let51d>oV~JD-kB2q?M%%ZtEo2=OaJtMNlT;Myz2NVQ+>2>@zTl%5hJZs ztn?z~d&+CdKa~;XqH;m$RX$M$l@Ez^yDv}eQr=Tns>{?Sb)GUnzeTFmGPOu~Tg_2D zs!cU1+f|PJ8=p1OjIv?qER_0mJCB{Tf&nR(|7!8oZr~|W}eyVkl z$lOAh&X@ybj2RGQbc3H!1P7xLQW*uXGxA_F;0W!mhl#!kkjw%YEQ|n2jKwg6u?VUe z15m~2hf2l*s9?;8X^eR=l`$8}8MC33F$< ## SSL certificate configuration diff --git a/pyproject.toml b/pyproject.toml index 9e7a6baa..5749fe73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ dev-dependencies = [ "syrupy>=4.6.1", "pytest-xdist>=3.6.1", "pytest-cov>=5.0.0", + "pytest-textual-snapshot>=1.0.0", ] [tool.hatch.metadata] diff --git a/requirements-dev.lock b/requirements-dev.lock index 54572f5c..f2914dac 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -50,6 +50,7 @@ idna==3.7 iniconfig==2.0.0 # via pytest jinja2==3.1.4 + # via pytest-textual-snapshot linkify-it-py==2.0.3 # via markdown-it-py markdown-it-py==3.0.0 @@ -84,9 +85,11 @@ pyperclip==1.8.2 # via posting pytest==8.3.1 # via pytest-cov + # via pytest-textual-snapshot # via pytest-xdist # via syrupy pytest-cov==5.0.0 +pytest-textual-snapshot==1.0.0 pytest-xdist==3.6.1 python-dotenv==1.0.1 # via posting @@ -94,13 +97,16 @@ python-dotenv==1.0.1 pyyaml==6.0.1 # via posting rich==13.7.1 + # via pytest-textual-snapshot # via textual sniffio==1.3.1 # via anyio # via httpx syrupy==4.6.1 + # via pytest-textual-snapshot textual==0.73.0 # via posting + # via pytest-textual-snapshot # via textual-autocomplete # via textual-dev textual-autocomplete==3.0.0a9 diff --git a/src/posting/__main__.py b/src/posting/__main__.py index 59823126..550477c6 100644 --- a/src/posting/__main__.py +++ b/src/posting/__main__.py @@ -11,7 +11,7 @@ from posting.locations import ( config_file, default_collection_directory, - themes_directory, + theme_directory, ) @@ -82,7 +82,7 @@ def locate(thing_to_locate: str) -> None: print(default_collection_directory()) elif thing_to_locate == "themes": print("Themes directory:") - print(themes_directory()) + print(theme_directory()) else: # This shouldn't happen because the type annotation should enforce that # the only valid options are "config" and "collection". diff --git a/src/posting/config.py b/src/posting/config.py index f44769a6..d9b0d45a 100644 --- a/src/posting/config.py +++ b/src/posting/config.py @@ -12,7 +12,7 @@ from textual.types import AnimationLevel import yaml -from posting.locations import config_file, themes_directory +from posting.locations import config_file, theme_directory from posting.types import PostingLayout @@ -89,7 +89,7 @@ class Settings(BaseSettings): theme: str = Field(default="posting") """The name of the theme to use.""" - themes_directory: Path = Field(default=themes_directory()) + theme_directory: Path = Field(default=theme_directory()) """The directory containing user themes.""" layout: PostingLayout = Field(default="vertical") diff --git a/src/posting/locations.py b/src/posting/locations.py index 70169394..936537e4 100644 --- a/src/posting/locations.py +++ b/src/posting/locations.py @@ -14,11 +14,11 @@ def data_directory() -> Path: return _posting_directory(xdg_data_home()) -def themes_directory() -> Path: +def theme_directory() -> Path: """Return (possibly creating) the themes directory.""" - themes_dir = data_directory() / "themes" - themes_dir.mkdir(exist_ok=True, parents=True) - return themes_dir + theme_dir = data_directory() / "themes" + theme_dir.mkdir(exist_ok=True, parents=True) + return theme_dir def default_collection_directory() -> Path: diff --git a/src/posting/posting.scss b/src/posting/posting.scss index f6376b2b..b63a04e6 100644 --- a/src/posting/posting.scss +++ b/src/posting/posting.scss @@ -242,6 +242,8 @@ TextArea { Tree { & > .tree--cursor { + text-style: not dim; + color: $text; background: $panel-lighten-1 70%; } &:focus > .tree--cursor { diff --git a/src/posting/themes.py b/src/posting/themes.py index 9bc558b2..f2fac958 100644 --- a/src/posting/themes.py +++ b/src/posting/themes.py @@ -3,7 +3,7 @@ import yaml from posting.config import SETTINGS -from posting.locations import themes_directory +from posting.locations import theme_directory class Theme(BaseModel): @@ -33,7 +33,7 @@ def to_color_system(self) -> ColorSystem: def load_user_themes() -> dict[str, Theme]: """Load user themes from "~/.config/posting/themes".""" - directory = SETTINGS.get().themes_directory + directory = SETTINGS.get().theme_directory themes: dict[str, Theme] = {} for path in directory.iterdir(): path_suffix = path.suffix diff --git a/tests/__snapshots__/test_snapshots/TestCommandPalette.test_loads_and_shows_discovery_options.svg b/tests/__snapshots__/test_snapshots/TestCommandPalette.test_loads_and_shows_discovery_options.svg index fc0f52a3..178b46c8 100644 --- a/tests/__snapshots__/test_snapshots/TestCommandPalette.test_loads_and_shows_discovery_options.svg +++ b/tests/__snapshots__/test_snapshots/TestCommandPalette.test_loads_and_shows_discovery_options.svg @@ -19,161 +19,161 @@ font-weight: 700; } - .terminal-2132566647-matrix { + .terminal-2001626355-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2132566647-title { + .terminal-2001626355-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2132566647-r1 { fill: #b3b3b3 } -.terminal-2132566647-r2 { fill: #c5c8c6 } -.terminal-2132566647-r3 { fill: #dfdfdf } -.terminal-2132566647-r4 { fill: #cca544 } -.terminal-2132566647-r5 { fill: #b2bec7;text-decoration: underline; } -.terminal-2132566647-r6 { fill: #b2bec7 } -.terminal-2132566647-r7 { fill: #7da2be } -.terminal-2132566647-r8 { fill: #313131 } -.terminal-2132566647-r9 { fill: #fea62b } -.terminal-2132566647-r10 { fill: #b5b5b5 } -.terminal-2132566647-r11 { fill: #b0b8bd } -.terminal-2132566647-r12 { fill: #808080 } -.terminal-2132566647-r13 { fill: #211505 } -.terminal-2132566647-r14 { fill: #696969 } -.terminal-2132566647-r15 { fill: #503714 } -.terminal-2132566647-r16 { fill: #797b7c;font-weight: bold } -.terminal-2132566647-r17 { fill: #b5b5b6;font-weight: bold } -.terminal-2132566647-r18 { fill: #dfdfdf;font-weight: bold } -.terminal-2132566647-r19 { fill: #565656 } -.terminal-2132566647-r20 { fill: #717171 } -.terminal-2132566647-r21 { fill: #1f1f1f } -.terminal-2132566647-r22 { fill: #1c1c1c } -.terminal-2132566647-r23 { fill: #717171;font-weight: bold } -.terminal-2132566647-r24 { fill: #00672d } -.terminal-2132566647-r25 { fill: #707374 } -.terminal-2132566647-r26 { fill: #0d0d0d;font-weight: bold } -.terminal-2132566647-r27 { fill: #818181 } -.terminal-2132566647-r28 { fill: #161616 } -.terminal-2132566647-r29 { fill: #1a1a1a } -.terminal-2132566647-r30 { fill: #306f43;font-weight: bold } -.terminal-2132566647-r31 { fill: #cc9434;font-weight: bold } -.terminal-2132566647-r32 { fill: #afafaf } + .terminal-2001626355-r1 { fill: #b3b3b3 } +.terminal-2001626355-r2 { fill: #c5c8c6 } +.terminal-2001626355-r3 { fill: #dfdfdf } +.terminal-2001626355-r4 { fill: #cca544 } +.terminal-2001626355-r5 { fill: #b2bec7;text-decoration: underline; } +.terminal-2001626355-r6 { fill: #b2bec7 } +.terminal-2001626355-r7 { fill: #7da2be } +.terminal-2001626355-r8 { fill: #313131 } +.terminal-2001626355-r9 { fill: #fea62b } +.terminal-2001626355-r10 { fill: #b5b5b5 } +.terminal-2001626355-r11 { fill: #b0b8bd } +.terminal-2001626355-r12 { fill: #808080 } +.terminal-2001626355-r13 { fill: #211505 } +.terminal-2001626355-r14 { fill: #696969 } +.terminal-2001626355-r15 { fill: #503714 } +.terminal-2001626355-r16 { fill: #797b7c;font-weight: bold } +.terminal-2001626355-r17 { fill: #b5b5b6;font-weight: bold } +.terminal-2001626355-r18 { fill: #dfdfdf;font-weight: bold } +.terminal-2001626355-r19 { fill: #565656 } +.terminal-2001626355-r20 { fill: #717171 } +.terminal-2001626355-r21 { fill: #1f1f1f } +.terminal-2001626355-r22 { fill: #1c1c1c } +.terminal-2001626355-r23 { fill: #717171;font-weight: bold } +.terminal-2001626355-r24 { fill: #00672d } +.terminal-2001626355-r25 { fill: #707374 } +.terminal-2001626355-r26 { fill: #0d0d0d;font-weight: bold } +.terminal-2001626355-r27 { fill: #818181 } +.terminal-2001626355-r28 { fill: #161616 } +.terminal-2001626355-r29 { fill: #1a1a1a } +.terminal-2001626355-r30 { fill: #306f43;font-weight: bold } +.terminal-2001626355-r31 { fill: #cc9434;font-weight: bold } +.terminal-2001626355-r32 { fill: #afafaf } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Posting + Posting - - - - -Posting                                                                    - -GET Send  -Search for commands… -╭─ Collection── Request ─╮ - GET echo  theme: posting                                tions - GET get ranSet the theme to posting━━━━━━━━━━━━ - POS echo po  theme: monokai                                ╱╱╱╱╱╱╱╱╱╱╱╱ -▼ jsonplacehSet the theme to monokai╱╱╱╱╱╱╱╱╱╱╱╱ -▼ posts/  theme: solarized-light                        ╱╱╱╱╱╱╱╱╱╱╱╱ - GET getSet the theme to solarized-lightd header  - GET get  theme: nautilus                               ────────────╯ - POS creSet the theme to nautilus Response ─╮ - DEL del  theme: galaxy                                  -│────────────Set the theme to galaxy━━━━━━━━━━━━ -This is an   theme: nebula                                  -server we cSet the theme to nebula -see exactly  theme: alpine                                  -request is Set the theme to alpine -sent.  theme: cobalt                                 Wrap X -╰── sample-co────────────╯ - ^j Send  ^t Method  ^s Save  ^n New  ^p Commands  ^o Jump  f1 Help  + + + + +Posting                                                                    + +GET Send  +Search for commands… +╭─ Collection── Request ─╮ + GET echo  theme: posting                                tions + GET get ranSet the theme to posting━━━━━━━━━━━━ + POS echo po  theme: monokai                                ╱╱╱╱╱╱╱╱╱╱╱╱ +▼ jsonplacehSet the theme to monokai╱╱╱╱╱╱╱╱╱╱╱╱ +▼ posts/  theme: solarized-light                        ╱╱╱╱╱╱╱╱╱╱╱╱ + GET getSet the theme to solarized-lightd header  + GET get  theme: nautilus                               ────────────╯ + POS creSet the theme to nautilus Response ─╮ + DEL del  theme: galaxy                                  +│────────────Set the theme to galaxy━━━━━━━━━━━━ +This is an   theme: nebula                                  +server we cSet the theme to nebula +see exactly  theme: alpine                                  +request is Set the theme to alpine +sent.  theme: cobalt                                 Wrap X +╰── sample-co────────────╯ + ^j Send  ^t Method  ^s Save  ^n New  ^p Commands  ^o Jump  f1 Help  diff --git a/tests/__snapshots__/test_snapshots/TestCustomTheme.test_theme_set_on_startup_and_in_command_palette.svg b/tests/__snapshots__/test_snapshots/TestCustomTheme.test_theme_set_on_startup_and_in_command_palette.svg new file mode 100644 index 00000000..5f817c27 --- /dev/null +++ b/tests/__snapshots__/test_snapshots/TestCustomTheme.test_theme_set_on_startup_and_in_command_palette.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Posting + + + + + + + + + + +Posting                                                                    + +GET Send  +anothertest +╭─ Collection── Request ─╮ + GET echo  theme: anothertesttions + GET get ranSet the theme to anothertest━━━━━━━━━━━━ + POS echo po╱╱╱╱╱╱╱╱╱╱╱╱ +▼ jsonplaceholder/││╱╱╱╱╱╱╱╱╱╱╱╱╱╱There are no headers.╱╱╱╱╱╱╱╱╱╱╱╱╱╱ +▼ posts/││╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱ + GET get all      ││NameValue Add header  + GET get one      │╰─────────────────────────────────────────────────╯ + POS create       │╭────────────────────────────────────── Response ─╮ + DEL delete a post││BodyHeadersCookiesTrace +│───────────────────────││━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +This is an echo ││ +server we can use to ││ +see exactly what ││ +request is being ││ +sent.││1:1read-onlyJSONWrap X +╰── sample-collections ─╯╰─────────────────────────────────────────────────╯ + ^j Send  ^t Method  ^s Save  ^n New  ^p Commands  ^o Jump  f1 Help  + + + + diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index dea9a3f7..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,345 +0,0 @@ -from __future__ import annotations -import inspect - -import os -import pickle -import re -import shutil -from dataclasses import dataclass -from datetime import datetime -from operator import attrgetter -from os import PathLike -from pathlib import Path, PurePath -from tempfile import mkdtemp -from typing import Any, Awaitable, Union, Optional, Callable, Iterable, TYPE_CHECKING - -import pytest -from _pytest.config import ExitCode -from _pytest.fixtures import FixtureRequest -from _pytest.main import Session -from _pytest.terminal import TerminalReporter -from jinja2 import Template -from rich.console import Console -from syrupy import SnapshotAssertion -from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode -from textual.app import App - -if TYPE_CHECKING: - from _pytest.nodes import Item - from textual.pilot import Pilot - - -class SVGImageExtension(SingleFileSnapshotExtension): - _file_extension = "svg" - _write_mode = WriteMode.TEXT - - -class TemporaryDirectory: - """A temporary that survives forking. - - This provides something akin to tempfile.TemporaryDirectory, but this - version is not removed automatically when a process exits. - """ - - def __init__(self, name: str = ""): - if name: - self.name = name - else: - self.name = mkdtemp(None, None, None) - - def cleanup(self): - """Clean up the temporary directory.""" - shutil.rmtree(self.name, ignore_errors=True) - - -@dataclass -class PseudoConsole: - """Something that looks enough like a Console to fill a Jinja2 template.""" - - legacy_windows: bool - size: ConsoleDimensions - - -@dataclass -class PseudoApp: - """Something that looks enough like an App to fill a Jinja2 template. - - This can be pickled OK, whereas the 'real' application involved in a test - may contain unpickleable data. - """ - - console: PseudoConsole - - -def rename_styles(svg: str, suffix: str) -> str: - """Rename style names to prevent clashes when combined in HTML report.""" - return re.sub(r"terminal-(\d+)-r(\d+)", rf"terminal-\1-r\2-{suffix}", svg) - - -def pytest_addoption(parser): - parser.addoption( - "--snapshot-report", - action="store", - default="snapshot_report.html", - help="Snapshot test output HTML path.", - ) - - -def app_stash_key() -> pytest.StashKey: - try: - return app_stash_key._key - except AttributeError: - from textual.app import App - - app_stash_key._key = pytest.StashKey[App]() - return app_stash_key() - - -def node_to_report_path(node: Item) -> Path: - """Generate a report file name for a test node.""" - tempdir = get_tempdir() - path, _, name = node.reportinfo() - temp = Path(path.parent) - base = [] - while temp != temp.parent and temp.name != "tests": - base.append(temp.name) - temp = temp.parent - parts = [] - if base: - parts.append("_".join(reversed(base))) - parts.append(path.name.replace(".", "_")) - parts.append(name.replace("[", "_").replace("]", "_")) - return Path(tempdir.name) / "_".join(parts) - - -@pytest.fixture -def snap_compare( - snapshot: SnapshotAssertion, request: FixtureRequest -) -> Callable[[str | PurePath], bool]: - """ - This fixture returns a function which can be used to compare the output of a Textual - app with the output of the same app in the past. This is snapshot testing, and it - used to catch regressions in output. - """ - # Switch so one file per snapshot, stored as plain simple SVG file. - snapshot = snapshot.use_extension(SVGImageExtension) - - def compare( - app_path: str | PurePath | App[Any], - press: Iterable[str] = (), - terminal_size: tuple[int, int] = (80, 24), - run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, - ) -> bool: - """ - Compare a current screenshot of the app running at app_path, with - a previously accepted (validated by human) snapshot stored on disk. - When the `--snapshot-update` flag is supplied (provided by syrupy), - the snapshot on disk will be updated to match the current screenshot. - - Args: - app_path (str): The path of the app. Relative paths are relative to the location of the - test this function is called from. - press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause. - terminal_size (tuple[int, int]): A pair of integers (WIDTH, HEIGHT), representing terminal size. - run_before: An arbitrary callable that runs arbitrary code before taking the - screenshot. Use this to simulate complex user interactions with the app - that cannot be simulated by key presses. - - Returns: - Whether the screenshot matches the snapshot. - """ - from textual._import_app import import_app - - node = request.node - - if isinstance(app_path, App): - app = app_path - else: - path = Path(app_path) - if path.is_absolute(): - # If the user supplies an absolute path, just use it directly. - app = import_app(str(path.resolve())) - else: - # If a relative path is supplied by the user, it's relative to the location of the pytest node, - # NOT the location that `pytest` was invoked from. - node_path = node.path.parent - resolved = (node_path / app_path).resolve() - app = import_app(str(resolved)) - - from textual._doc import take_svg_screenshot - - actual_screenshot = take_svg_screenshot( - app=app, - press=press, - terminal_size=terminal_size, - run_before=run_before, - ) - console = Console(legacy_windows=False, force_terminal=True) - p_app = PseudoApp(PseudoConsole(console.legacy_windows, console.size)) - - result = snapshot == actual_screenshot - expected_svg_text = str(snapshot) - full_path, line_number, name = node.reportinfo() - - data = ( - result, - expected_svg_text, - actual_screenshot, - p_app, - full_path, - line_number, - name, - inspect.getdoc(node.function) or "", - ) - data_path = node_to_report_path(request.node) - data_path.write_bytes(pickle.dumps(data)) - - return result - - return compare - - -@dataclass -class SvgSnapshotDiff: - """Model representing a diff between current screenshot of an app, - and the snapshot on disk. This is ultimately intended to be used in - a Jinja2 template.""" - - snapshot: Optional[str] - actual: Optional[str] - test_name: str - path: PathLike - line_number: int - app: App - environment: dict - docstring: str - - -def pytest_sessionstart( - session: Session, -) -> None: - """Set up a temporary directory to store snapshots. - - The temporary directory name is stored in an environment vairable so that - pytest-xdist worker child processes can retrieve it. - """ - if os.environ.get("PYTEST_XDIST_WORKER") is None: - tempdir = TemporaryDirectory() - os.environ["TEXTUAL_SNAPSHOT_TEMPDIR"] = tempdir.name - - -def get_tempdir(): - """Get the TemporaryDirectory.""" - return TemporaryDirectory(os.environ["TEXTUAL_SNAPSHOT_TEMPDIR"]) - - -def pytest_sessionfinish( - session: Session, - exitstatus: Union[int, ExitCode], -) -> None: - """Called after whole test run finished, right before returning the exit status to the system. - Generates the snapshot report and writes it to disk. - """ - if os.environ.get("PYTEST_XDIST_WORKER") is None: - tempdir = get_tempdir() - diffs, num_snapshots_passing = retrieve_svg_diffs(tempdir) - save_svg_diffs(diffs, session, num_snapshots_passing) - tempdir.cleanup() - - -def retrieve_svg_diffs( - tempdir: TemporaryDirectory, -) -> tuple[list[SvgSnapshotDiff], int]: - """Retrieve snapshot diffs from the temporary directory.""" - diffs: list[SvgSnapshotDiff] = [] - pass_count = 0 - - n = 0 - for data_path in Path(tempdir.name).iterdir(): - ( - passed, - expect_svg_text, - svg_text, - app, - full_path, - line_index, - name, - docstring, - ) = pickle.loads(data_path.read_bytes()) - pass_count += 1 if passed else 0 - if not passed: - n += 1 - diffs.append( - SvgSnapshotDiff( - snapshot=rename_styles(str(expect_svg_text), f"exp{n}"), - actual=rename_styles(svg_text, f"act{n}"), - test_name=name, - path=full_path, - line_number=line_index + 1, - app=app, - environment=dict(os.environ), - docstring=docstring, - ) - ) - return diffs, pass_count - - -def save_svg_diffs( - diffs: list[SvgSnapshotDiff], - session: Session, - num_snapshots_passing: int, -) -> None: - """Save any detected differences to an HTML formatted report.""" - if diffs: - diff_sort_key = attrgetter("test_name") - diffs = sorted(diffs, key=diff_sort_key) - - this_file_path = Path(__file__) - snapshot_template_path = ( - this_file_path.parent / "resources" / "snapshot_report_template.jinja2" - ) - - snapshot_report_path = session.config.getoption("--snapshot-report") - snapshot_report_path = Path(snapshot_report_path) - snapshot_report_path = Path.cwd() / snapshot_report_path - snapshot_report_path.parent.mkdir(parents=True, exist_ok=True) - template = Template(snapshot_template_path.read_text()) - - num_fails = len(diffs) - num_snapshot_tests = len(diffs) + num_snapshots_passing - - rendered_report = template.render( - diffs=diffs, - passes=num_snapshots_passing, - fails=num_fails, - pass_percentage=100 * (num_snapshots_passing / max(num_snapshot_tests, 1)), - fail_percentage=100 * (num_fails / max(num_snapshot_tests, 1)), - num_snapshot_tests=num_snapshot_tests, - now=datetime.utcnow(), - ) - with open(snapshot_report_path, "w+", encoding="utf-8") as snapshot_file: - snapshot_file.write(rendered_report) - - session.config._textual_snapshots = diffs - session.config._textual_snapshot_html_report = snapshot_report_path - - -def pytest_terminal_summary( - terminalreporter: TerminalReporter, - exitstatus: ExitCode, - config: pytest.Config, -) -> None: - """Add a section to terminal summary reporting. - Displays the link to the snapshot report that was generated in a prior hook. - """ - if os.environ.get("PYTEST_XDIST_WORKER") is None: - diffs = getattr(config, "_textual_snapshots", None) - console = Console(legacy_windows=False, force_terminal=True) - if diffs: - snapshot_report_location = config._textual_snapshot_html_report - console.print("[b red]Textual Snapshot Report", style="red") - console.print( - f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n" - f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n" - ) - console.print(f"[dim]{snapshot_report_location}\n") diff --git a/tests/sample-configs/custom_theme.yaml b/tests/sample-configs/custom_theme.yaml new file mode 100644 index 00000000..86fcb6cc --- /dev/null +++ b/tests/sample-configs/custom_theme.yaml @@ -0,0 +1,10 @@ +# Use a user-defined theme from the themes dir. +theme: serene_ocean # corresponds to two.yaml +use_host_environment: false +response: + show_size_and_time: false +heading: + show_host: false + show_version: false +text_input: + blinking_cursor: false diff --git a/tests/sample-themes/one.yml b/tests/sample-themes/one.yml new file mode 100644 index 00000000..d0ac929b --- /dev/null +++ b/tests/sample-themes/one.yml @@ -0,0 +1,11 @@ +name: anothertest +primary: '#2ecc71' +secondary: '#3498db' +accent: '#9b59b6' +background: '#ecf0f1' +surface: '#bdc3c7' +error: '#e74c3c' +warning: '#f39c12' +success: '#27ae60' +panel: '#95a5a6' +dark: true diff --git a/tests/sample-themes/two.yaml b/tests/sample-themes/two.yaml new file mode 100644 index 00000000..3778c49e --- /dev/null +++ b/tests/sample-themes/two.yaml @@ -0,0 +1,10 @@ +name: serene_ocean +primary: '#1E88E5' # Ocean Blue +secondary: '#00ACC1' # Teal +accent: '#D32F2F' # Crimson Red +background: '#E3F2FD' # Light Sky Blue +surface: '#FFFFFF' # White +error: '#D32F2F' # Crimson Red +success: '#43A047' # Forest Green +text: '#212121' # Almost Black +dark: false diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index 4f34ed11..2f216e6d 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -10,6 +10,7 @@ TEST_DIR = Path(__file__).parent CONFIG_DIR = TEST_DIR / "sample-configs" ENV_DIR = TEST_DIR / "sample-envs" +THEME_DIR = TEST_DIR / "sample-themes" SAMPLE_COLLECTIONS = TEST_DIR / "sample-collections" POSTING_MAIN = TEST_DIR / "posting_snapshot_app.py" @@ -410,3 +411,17 @@ async def run_before(pilot: Pilot): await pilot.press("ctrl+l") assert snap_compare(app, run_before=run_before) + + +@use_config("custom_theme.yaml") +@patch_env("POSTING_THEME_DIRECTORY", str(THEME_DIR.resolve())) +class TestCustomTheme: + def test_theme_set_on_startup_and_in_command_palette(self, snap_compare): + """Check that the theme is set on startup and available in the command palette.""" + + async def run_before(pilot: Pilot): + await pilot.press("ctrl+p") + await disable_blink_for_active_cursors(pilot) + await pilot.press(*"anothertest") + + assert snap_compare(POSTING_MAIN, run_before=run_before)