From 0a77bae9a42f419d27b432088bafaa60202674dd Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sat, 14 Oct 2023 10:19:17 -0400 Subject: [PATCH 1/5] 88: Add dependency on Google Truth for Library tests --- Library/build.gradle | 1 + build.gradle | 1 + 2 files changed, 2 insertions(+) diff --git a/Library/build.gradle b/Library/build.gradle index 8788eb10..cb823852 100644 --- a/Library/build.gradle +++ b/Library/build.gradle @@ -69,6 +69,7 @@ android { testImplementation "io.mockk:mockk:${versions.mockk}" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:${versions.kotlinx}" testImplementation "org.slf4j:slf4j-jdk14:${versions.slf4j}" + testImplementation "com.google.truth:truth:${versions.truth}" androidTestImplementation "androidx.test.ext:junit:${versions.androidx.test.junit}" androidTestImplementation "androidx.test:runner:${versions.androidx.test.runner}" diff --git a/build.gradle b/build.gradle index 356b3e4c..fd56fda8 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,7 @@ buildscript { 'material' : '1.9.0', // https://material.io/develop/android/docs/getting-started/ 'mockk' : '1.13.5', // https://central.sonatype.com/artifact/io.mockk/mockk/1.13.4/versions 'slf4j' : '2.0.6', + 'truth' : '1.1.5', // https://github.com/google/truth 'testify' : '2.0.0-rc03', // https://github.com/ndtp/android-testify/releases ] coreVersions = [ From 8a723e257a6bdf194fdd4b9c0340a72dac9393ed Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sat, 14 Oct 2023 10:54:20 -0400 Subject: [PATCH 2/5] 88: Rename tests to match test subject package names --- .../testify/{ => core}/DeviceIdentifierTest.kt | 2 +- .../{ => core/exception}/ErrorCauseTest.kt | 17 +---------------- .../processor}/ParallelPixelProcessorTest.kt | 5 +---- .../processor/compare}/RegionCompareTest.kt | 3 +-- .../compare/colorspace}/FuzzyCompareTest.kt | 3 +-- .../testify/{ => report}/ReportSessionTest.kt | 3 +-- .../dev/testify/{ => report}/ReporterTest.kt | 5 ++--- 7 files changed, 8 insertions(+), 30 deletions(-) rename Library/src/test/java/dev/testify/{ => core}/DeviceIdentifierTest.kt (99%) rename Library/src/test/java/dev/testify/{ => core/exception}/ErrorCauseTest.kt (79%) rename Library/src/test/java/dev/testify/{ => core/processor}/ParallelPixelProcessorTest.kt (95%) rename Library/src/test/java/dev/testify/{ => core/processor/compare}/RegionCompareTest.kt (98%) rename Library/src/test/java/dev/testify/{ => core/processor/compare/colorspace}/FuzzyCompareTest.kt (98%) rename Library/src/test/java/dev/testify/{ => report}/ReportSessionTest.kt (99%) rename Library/src/test/java/dev/testify/{ => report}/ReporterTest.kt (99%) diff --git a/Library/src/test/java/dev/testify/DeviceIdentifierTest.kt b/Library/src/test/java/dev/testify/core/DeviceIdentifierTest.kt similarity index 99% rename from Library/src/test/java/dev/testify/DeviceIdentifierTest.kt rename to Library/src/test/java/dev/testify/core/DeviceIdentifierTest.kt index 3083a92b..488f24f9 100644 --- a/Library/src/test/java/dev/testify/DeviceIdentifierTest.kt +++ b/Library/src/test/java/dev/testify/core/DeviceIdentifierTest.kt @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -package dev.testify +package dev.testify.core import dev.testify.core.DEFAULT_FOLDER_FORMAT import dev.testify.core.DEFAULT_NAME_FORMAT diff --git a/Library/src/test/java/dev/testify/ErrorCauseTest.kt b/Library/src/test/java/dev/testify/core/exception/ErrorCauseTest.kt similarity index 79% rename from Library/src/test/java/dev/testify/ErrorCauseTest.kt rename to Library/src/test/java/dev/testify/core/exception/ErrorCauseTest.kt index 037e6902..073eb350 100644 --- a/Library/src/test/java/dev/testify/ErrorCauseTest.kt +++ b/Library/src/test/java/dev/testify/core/exception/ErrorCauseTest.kt @@ -22,25 +22,10 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -package dev.testify +package dev.testify.core.exception import android.app.Activity import android.content.Context -import dev.testify.core.exception.ActivityMustImplementResourceOverrideException -import dev.testify.core.exception.ActivityNotRegisteredException -import dev.testify.core.exception.AssertSameMustBeLastException -import dev.testify.core.exception.FailedToCaptureBitmapException -import dev.testify.core.exception.FinalizeDestinationException -import dev.testify.core.exception.MissingAssertSameException -import dev.testify.core.exception.MissingScreenshotInstrumentationAnnotationException -import dev.testify.core.exception.NoScreenshotsOnUiThreadException -import dev.testify.core.exception.RootViewNotFoundException -import dev.testify.core.exception.ScreenshotBaselineNotDefinedException -import dev.testify.core.exception.ScreenshotIsDifferentException -import dev.testify.core.exception.ScreenshotTestIgnoredException -import dev.testify.core.exception.TestMustWrapContextException -import dev.testify.core.exception.UnexpectedDeviceException -import dev.testify.core.exception.ViewModificationException import dev.testify.output.DataDirectoryDestinationNotFoundException import dev.testify.output.SdCardDestinationNotFoundException import dev.testify.output.TestStorageNotFoundException diff --git a/Library/src/test/java/dev/testify/ParallelPixelProcessorTest.kt b/Library/src/test/java/dev/testify/core/processor/ParallelPixelProcessorTest.kt similarity index 95% rename from Library/src/test/java/dev/testify/ParallelPixelProcessorTest.kt rename to Library/src/test/java/dev/testify/core/processor/ParallelPixelProcessorTest.kt index 0a1fcd6a..4fb6caa8 100644 --- a/Library/src/test/java/dev/testify/ParallelPixelProcessorTest.kt +++ b/Library/src/test/java/dev/testify/core/processor/ParallelPixelProcessorTest.kt @@ -1,9 +1,6 @@ -package dev.testify +package dev.testify.core.processor import android.graphics.Bitmap -import dev.testify.core.processor.ParallelPixelProcessor -import dev.testify.core.processor._executorDispatcher -import dev.testify.core.processor.maxNumberOfChunkThreads import io.mockk.every import io.mockk.just import io.mockk.mockk diff --git a/Library/src/test/java/dev/testify/RegionCompareTest.kt b/Library/src/test/java/dev/testify/core/processor/compare/RegionCompareTest.kt similarity index 98% rename from Library/src/test/java/dev/testify/RegionCompareTest.kt rename to Library/src/test/java/dev/testify/core/processor/compare/RegionCompareTest.kt index 62a85896..6c876ed8 100644 --- a/Library/src/test/java/dev/testify/RegionCompareTest.kt +++ b/Library/src/test/java/dev/testify/core/processor/compare/RegionCompareTest.kt @@ -22,13 +22,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -package dev.testify +package dev.testify.core.processor.compare import android.graphics.Bitmap import android.graphics.Rect import dev.testify.core.TestifyConfiguration import dev.testify.core.processor._executorDispatcher -import dev.testify.core.processor.compare.FuzzyCompare import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.DelicateCoroutinesApi diff --git a/Library/src/test/java/dev/testify/FuzzyCompareTest.kt b/Library/src/test/java/dev/testify/core/processor/compare/colorspace/FuzzyCompareTest.kt similarity index 98% rename from Library/src/test/java/dev/testify/FuzzyCompareTest.kt rename to Library/src/test/java/dev/testify/core/processor/compare/colorspace/FuzzyCompareTest.kt index 500cbeeb..fd2e463d 100644 --- a/Library/src/test/java/dev/testify/FuzzyCompareTest.kt +++ b/Library/src/test/java/dev/testify/core/processor/compare/colorspace/FuzzyCompareTest.kt @@ -22,11 +22,10 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -package dev.testify +package dev.testify.core.processor.compare.colorspace import com.github.ajalt.colormath.LAB import com.github.ajalt.colormath.RGB -import dev.testify.core.processor.compare.colorspace.calculateDeltaE import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Assert.fail diff --git a/Library/src/test/java/dev/testify/ReportSessionTest.kt b/Library/src/test/java/dev/testify/report/ReportSessionTest.kt similarity index 99% rename from Library/src/test/java/dev/testify/ReportSessionTest.kt rename to Library/src/test/java/dev/testify/report/ReportSessionTest.kt index 97583e4e..12307bab 100644 --- a/Library/src/test/java/dev/testify/ReportSessionTest.kt +++ b/Library/src/test/java/dev/testify/report/ReportSessionTest.kt @@ -22,11 +22,10 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -package dev.testify +package dev.testify.report import android.app.Instrumentation import android.content.Context -import dev.testify.report.ReportSession import io.mockk.every import io.mockk.mockk import io.mockk.spyk diff --git a/Library/src/test/java/dev/testify/ReporterTest.kt b/Library/src/test/java/dev/testify/report/ReporterTest.kt similarity index 99% rename from Library/src/test/java/dev/testify/ReporterTest.kt rename to Library/src/test/java/dev/testify/report/ReporterTest.kt index 74d2fec3..9c22cb85 100644 --- a/Library/src/test/java/dev/testify/ReporterTest.kt +++ b/Library/src/test/java/dev/testify/report/ReporterTest.kt @@ -22,13 +22,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -package dev.testify +package dev.testify.report import android.app.Instrumentation import android.content.Context +import dev.testify.TestDescription import dev.testify.output.getDestination -import dev.testify.report.ReportSession -import dev.testify.report.Reporter import io.mockk.every import io.mockk.just import io.mockk.mockk From bd5c6547054279d3eeb25660f450f18648c5cb96 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sat, 14 Oct 2023 10:55:23 -0400 Subject: [PATCH 3/5] 88: Refactor LegacySample tests to differentiate the Espresso elements --- Samples/Legacy/build.gradle | 2 +- .../FullscreenCaptureExampleTest_withMenu.png | Bin 58726 -> 62101 bytes .../Legacy/src/main/res/menu/menu_main.xml | 4 ++-- Samples/Legacy/src/main/res/values/dimens.xml | 2 ++ .../Legacy/src/main/res/values/strings.xml | 5 +++++ 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Samples/Legacy/build.gradle b/Samples/Legacy/build.gradle index 693621be..7883a322 100644 --- a/Samples/Legacy/build.gradle +++ b/Samples/Legacy/build.gradle @@ -100,7 +100,7 @@ dependencies { androidTestImplementation project(":Accessibility") androidTestImplementation "androidx.test.espresso:espresso-contrib:3.4.0" androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" - androidTestImplementation "androidx.test.ext:junit:1.1.3" + androidTestImplementation "androidx.test.ext:junit:1.1.4" androidTestImplementation "androidx.test:rules:1.4.0" androidTestImplementation "androidx.test:runner:1.4.0" androidTestImplementation "org.mockito:mockito-android:3.8.0" diff --git a/Samples/Legacy/src/androidTest/assets/screenshots/29-1080x2220@440dp-en_US/FullscreenCaptureExampleTest_withMenu.png b/Samples/Legacy/src/androidTest/assets/screenshots/29-1080x2220@440dp-en_US/FullscreenCaptureExampleTest_withMenu.png index 5edcdad3375c768ba0bcd5fd7fa43fe08e71e0fb..9e3110cb4675ac72898a3284f5b3a5dd148c5576 100644 GIT binary patch literal 62101 zcmb?@bx>T<(!35p;I@zazHe)H ztM-qr+E=N{8}i|DM?XfIJnnbaBzrah%bSkkdgP^ z0Drx3RF)8is~G*T4}5s#C@iIlh={nfp|B1I_YqD?R7lk=?P$f-OJ(N~;RO7>(Hu0@ zX1<@3A4ujY6;F=z^Oe0WLRir{+{S9DKEj;$L$m!0q!6ksme=2*T2!@PC|5i=Yo$h^ zQ7UNrQYi)2lLfHz_N$%4ZQzoUJ-XR;&wz>qk~5G4Uk+T60drHR|G8ZkR($dAF4#Zo z*<3g{Gz6hH|872@{d{%_4vy=?YutY~c3g-w|8CapFjW8D{Mq8jP-Ipi zA>24KA2E3}BDrC<-`LivJ0G#}I3WyPyKstDO*=vyFzEI3K!jfuf9U(=Q2p=&lf`rK zzFpBxkHHc)yuEXGm(6ucbd>2T}gkH!}%*%vPFNE`#o?zXIO>0wyi; z%iTJqa^(cin?1cXGK5D(2@O5N3m@^dx$qD%cAomttu$$%gfAz33vU;(= z>1ll}difmK<9!aeI;qcC0r2kf9ke&q2*EP>hO{Iz(|HSyOh(R%0*;v1| z|M4kU3{569jy?|J%Js! zTp8S)1wY_1;w@w+tAA6LR^V^(KlB#rl_EnyIT<%gkr^Un%IlZrBffwc8RgvGI5Cut zvoXKldyuGrEITGUai7$clIJhSx9;8Wisd9k73td+*{a4Ta;EWk^3y+f+y?&KoaDJA zC5!$jK=E~Z+$T3!LQMR+shQ{07FPYY&G^a3I85^IljW6Ips>+g0)RO`J@Os|dXLA* ziSAq;OeI+*9B1&cz3ZyD_uD&a^ zOc8i=m@A++RYFC9gUd>kmO^?sdO&!!nDlW~j6B~NIk7^4H_sk1d;iLzIlXq;*R<4EQIOiSMdPt?1!86+VphZ zr>q~O8`-O~ACe6ejc@#l5HsJLQ~V;tx8~Ch#n}9e=~7{>XUa5AJ&-Lcy}xOT4)4DK zp_Vf6=~z>@W(6G2U}kqeMaB*k79j2Unng=P_;^k0eZkm2L_@IX?G`IsYqK@5a>q*S z^L73et8#0bZj#3F&i>i`@=~n>&1eQ=m1`6RfV=tS<$m!FkR6t(dI{>A>z1dQvwieO z+kSM*$oYNhUkhG68iguIq&4>Ys?V#QQKWaotigv!{=acB6qz!&y zQr}fDH-le=^1wOvBd{gMUD2~L(cW>SJN)9!^IAT(ZMaWjPFJB0f zGT3d@7RyRc9HQp$Tv+<@o<1l9M-=umSqkt-iOPDSu$52jO&^$;^tE<=Nz~w=GqC^2 z+ql_ls^b5OKWQv9s?n;GwXx%RY|Y{j$Hn_OUw;)btX|J;G+kk5o}lP%qr*6HfATPv z-t^1rwY91{#F|V>G$fZmry*f%iISr>2cLD^`aJ#kG?$bLEc*RGrEj z-`;7Q!hIEh5takwm0*(h49@Q`h0Rhb%4}}GT|{U4aQ6{+gHLt!$ORncI$w!kBfRbv zy}=SrNxews-r2`PQb->ttx#jaR*IuJzFfh3p4VC!WKYsSI7IO;3D?K#34~yJ19!#J z-j|nvyKR2KE$2}Nf;K!geezUcAWOrXWf>0N2_D{un$S2y{$MnkC;_gt2VG(=2WvP(8MnSSW~YQ^f`yC&N4{y zx}Dy}cP01qtS^bli1~22-qwhlIP3E?wXuRwp{mO99>vMA_VFn2r$YPU#l%u=64$}* zfzui+&Zy8qKC^8#q7qnpa6J2mQD7$>>2HcS>{gOJb0mV_qs$4@;%dx?7P8=?Dt;}3 z>K822GUb+e>5~`CEGF;I%hyI`uWwIkJ1X&1S(kvSLqyb%``*q`K>eGN93=rh!O=x! zVjxxC&SPmO|ORV2jd zuHd`ELfBX0=L&sDe|ODTU(lSIshV%2e|c)aAy{rMtp&Sj zI|-#jPJEP*|ISQE77c{5Rb*@vywzv5cM&w4b|znU4xrzd0c#hf^5JSM(q$jU<^SW+ zc&qsC@pME);o)TZ=L~AZ0r3FM%NLAfvI1sb5lI6I_774DGC9+}BV&Y%{jC0U+tCbz z{Qjsg1jY<~-H3|hd6?-M{Mwz_^WY#}@X3XRw=t%9|1%~98lr=vv&G#9z=3rt1_OGn z``bQ4-u;#I(T`(hLI|fO$NEG5}C%PqmsnoCb;4H)y$1 z=UxZT1bgO@Gbj)2Z72;l$eaEhW-wio6^O#a+-*Bqm&wi-l+kG`<)4S}B`e2KurK?+hx-q(-W%Ej|5BkrB7*s=&g?w<9p<4aNszc!7>oCq}?TwE9`w@M$xh! znwmOwq}3=UaXZPjGF~L$TU}MqkUn6IRc&V`ef_INI|!{rb#cn{JjJH$k}5t-0yUy; z>=3XWZs~3>yko&Z+QoVOZ+%p|neRj^nvnE98({lFGcME@U4q6y}WgeZX4-ZiWSY!NNn*(`?r(? z92T~xU$Du|&6GAk_@0hv1)tXDJQqi305+)tk^G8hR9vi8}~p zqmz|JqzE{gcPy@IN^tZ`G?h^-wpDC9yHCgN>!}hMHA@*Cs^sxt0UzQb3=STS2;BM2 ze=*sDE$}6WZ#NhAtuYI5@J*-6N$kX&nR*>l!-BKcp{&GN9 zQm%65HVt`&e(8u(giHsk|1@OgAZH?<+mG)`+P~EEG=lx=*DPPzDC0Row!OEUxOFd3FwX2tKf7|?-3Xt-^eSt3?A!$ZlI~9%)LXW$>q-JD|B`Nri@`TCvz6DI%r4e%b%27;0j*x>a8}X` z!SlqX&d2t++Dil~hC*uGU#q7})0BnJSyTJyBx4qqC+~SJdxfDtT2jw}Je$H{{?XJ| zSodQK|5Y`!taP?HsIKhM!bHn_VMPADuV5odyBi%%SAPoL3_pP6L)5z{U7#^aq4*Rm zzRE2yI@ceMp}dxLYceCWbm^(x2R@&tL0mLxvv+x>~RA>rQ8(Mr{-Z@3Gyec;)`^3sQz*; z776xACizs7Uv#?8DddUgzSubialz-6|BqGAlR*GACcAN*93|btZMGyt{D&ExvR65h zpWU`*T7Y89N&IRT3huLEZR3%MH1)n5S*>%VcO(r$4+S1DAD%`QB$?|)rVx18Od0L? znwn$o05RN{8CFiAz!6i95g8ghjK%)x3Tcx?)VezirQH1mJ9DbOVSHzzghP&XnrsbB zp+@C*sB%#q&{ha|umtnR^yk7nURCE91df55o9kaIr4(?NP9QT$03C4_o_6)Fq-fHW zNNv#KLBJtL1`ARA6s{z7!=#RXSXARtIJzn8MLQe83IMhoWmh-mER*zaoiP_8DqUMS zuK-5S?tVA2*(F9^4|roe?`6^pj$;1Nvoy3*EFq3x`M9;eda&uX(ii*W16J7L?^gf| zu-NwYW$p&TdtwSx{EBF^w!1E7btkee7PWtI$l;oBW*;TgrQ|Gn*R?SzSk~=CR z<(7cWS~u;ei?u>5`i3W1Uraa4ghanxcp7%Nr> zl+8afa+?#Az=L2|{gF@AW-(SoDXt}Ts!r-v$E{nWJzX^uylm-A?GARp%l3P5Y75~I zptCDi!2(>`3xs)ePcc@o6Pv7Y1AWU_Yn}SqDwg%^R$iP)KTEVSC`SW6o>9OfM5`+e zqsHUmlw^*mcFmE9%ON6tZ4-cCf-ptE`?*HF_RTLkrt@_>R$5|1dEra&flIm3M8Z>S zV$s;<&5I^Pqyb~#6%HyZ*vVh!?%cj#AqRL2rf9qlWG&^|rW}P62Q?0Z$$=`by)vS7 zcOkPDy@V9`vTdxVnT!gT(L*M(ayFbmy9!i9!@J5JL!s#iUspCc+Ia2ZJOsdvHh|hI z&|gcH@1OV)BuKCDxo#XRf?VG5RS{F>V+bcy?zKD9*h;lA7J0!g%K+&k>6plqq3z~T z-BWN&DHlSp^%mBbj*6=mk!PLA5|)g*Wlql@l2fF2$3i7M!A7(PZNE*WRQEcaM>pUD zZHD)^Kfu0)<)FZWD)eve8ekA}^m8YxVxW4CUwfN~dofe@E=Y+YMoae|o8XqK`?A-T zWfcW+`-iLYF$sh;%jC`&gVmgN1g)Q8wZ&2q&){Kg1SXc5f^E)mn&{Hjs~O3vAUWwV zBGXhdr2S<`aoiTY{7HV;vLRIo)~H_UJ69$kR6F8ue6f;nHkd$+tC1X_HPDX0%>9|S z@uwnCMl!q&>BN-S&$KFba|pK;E@oP9>fp(+1F@jnw$`{rYQ@ zna&Ouy(%@<-^xdMoNwYON$}aBr`5xk&EbLP1~*!TQ?A|T1WzyqjFW|9>0I^?3vbkh zC-<#5E+;75_$(H;f5>Bfb1?kdHU)p`hI8jD{8!K<>NijL@=98!$9L66cB$e?{U+U> zY0(5}u`O{i1o%2S>D)O&B&5=)^udXGVqpu?(rx|9<<Dbrlv+G3S>;hS zF%b`!1_YzF1d42rK{8_G?gThJ88hM|!-|Ln4^2 zqqH#jYYAQ_g?xuW|oMcbbuM>M$XO2iRHaS8RwkV0f*Y4FaI)PzhxA7r!i1=1feOV-2U7esF&`Z$`2I$I*tjda2*x1cg4@`uW z0C8+2!##DMThCa2iUKu_qu6*kQ9GB{wfbZ;V)41zu(SL-ROzwX%bq~_hK=(O+*Zdi zW(Kj>_?YkX3eX<1JM|5oY|&T)+uDULmN%U_%Etagn*G{`UY$I24-=o?FP{$oq!0N0 z5ZvtF;-l#uKwUzM^){i6BVDU6!)QM|cHZWP0Y^rS+Lm>=R@1{x?;*w5t<_vRN}w>S zDFmIB_=bnJaq~XEYaiX0-*tnaAgx+&2_SAw7Awqw$})&R)8`*6Y~T)&H)y8it!@Vr z-Okk=iQ8sNqrmd*%+;Omf|Er*Bs{gT(6{y=YhRm3)^#xLE{Fx%p>O+lp!6P6D6&$^ z_1vO671lZ4ybJ4`WP6xJ(3u)tC`dt5mcFvv8mVs!7yG#z`c{lMTz11{T_wdKm3 zmCvO23^_3zXfk{O@iyRh&vjO2f?zwWe2ukMXSd?oZRqjt7R5z%e$`)tflD?jjQW)v zSijQ3N*S1V%R10xjRzpNk7ohwes+ddM*0wT$@n%))#8e{H6{+j3t-wneqWGcb2j`r zEOvI&=;>IgQ8VR<0{}!1Z;3w@NSyK06%WVTfLhkl%mauS-d9`Mv<=80APDPC!_{Jx91z3dG?Q5@93FvUmu2RQC#$~4|Z}M?m z-gq$7_D0AXXfS~xCf)eY!be11hN&z3q>~V^oHwUONQ>Y--RYEAa zun8JSPou1|#OC%WwXK37=B<%Yui+0-x-=#ZijvGRytQLq53&|ONX;ey)by7x#>8ki z-tXPQSP=wVgGolQTwg zyt!*mt{9MUVR^Z!L6<62 zY=RZMF07^ZE-aLlXL|!yD7U~yQ&au^MNpxE?%iA^W0i)}f3dR4#?C*#BMai@4BASw zg^X{K=FK`P*bmfq$A$6_(TIk4T&tO=Ns};YjK=c2HnpdWeAYZJVGFG{3Xv_2!mXzs zkpURx>aIW=a2Ah1KL=(gHA}jPvenPbv%Ftf_@Z)%UA%CnQM2wfZP;7)`tWh4?8O5qN3jZmhKI}Fr}R6S=xfW z$J0=naXT^Fz4%!YGYN{R@d#AL#@gElAHE|#h);VT^PWT$vA9$Nz)+MRiSeKZ(BZm5 z$EsXwpJyI(dX_pv`(&zseN4JXI3DB<>6)o`=4e7W6g7}eQ`*todrd=UZ?4eT+5Y=lJ#!@GGlBVH5H-Ye=&!F&y%S{?grwOGi&(uGmG z%E{i}`y&8qgl+7PwH2n#!&=)*ACQ+qA&Jk%p6{@_bB(xBERX%RPP&i9UGs%M63hZn zYNMPyb>@NpD#464;Gw^8<+F#c05(k>O^}10eKK zhDv^1KaIH6{MXGAl(_?Rb7SG5)fH9kHl}(m<6o)OEbNG-!%|v#w`(F z-&bK&PTN-lrPN2D0;s^*zsJDm&ka&;X^|BbRoMU_mbg=lMGt*>)+hbgZB7SZcCG%fIRDh4e!EpotR>sR0jxm-&_SK(%;-}|S7W3S5 z06USKU;ghqqG%LTui&@{K)}8zz>QIR8 ze(xWIT9Y-O_gpENY;P5(V`sOl zm5{&-I6r}ahwgqkI?m0ouhT^>pcW-KxJC+ADgT1<5E6?1k9E&)kK9W#mCVS)fG?aE zEmW31d^HTlw#-2TjNW1$B;9RMNfLDDL=ypq-UCGz;tXNF*#JDU;ZN(Z!4Vs#NFcO- z1MD|vh4UmS(~06h^mY}1cgJOt@Yjr|OD9X70&e`xK4?6 z&-V`xIaCyRc9xj6zFWU|G?SWs0(;owxD@B><&xBCA%kMTFU0KS}||tD-!`N zFjr1LW>%Fooj>Ypw6N|-K7P5(e+ zCC_X}Jmc^lz=Ex~3ZJl9m?9^SB2~(GKmm1F|7EcqNd4I_o_^5DxJl5Zr}uxK1(0Sw zkYtXe5*KkBnT7)}xff3r_8yUxEgO*(r}q=v%H)tRA(b#@nBkr3dAa|vJ0@PuB7pC`Ov4mQIOnS69z|Q`cLZ^>{b(zZA zVmPnN(-q8qy?6!Zr~gZjGms)c5vS6)i;qwlF>0K%2I<=tlGr~)rSCxEW;yBea9)vq z{-cn>_J@~Yk(TOD@0x}MI0&$FK3ra6j?=8h!Lrh1Mi-=mMkAnWD&- z@;=G^lC5|6YxY9fzd%WFQe?eXBeO|Ywh8#)U%Bjl6!M3;`_;~>JpYU~urhQo5vdpz zLK6$0s&JlSCJVOA^Mj-C@Diz$JgEo2Obz$oQek80JlmRZz0z*5-9WLX@%;~iSpiV8I~b?W3B>S1Xb z&PR76E!+k(W--!dJcrx64n_=|!n>prow9$NJ#6vrTj^fYAlQQ{tCTX7 ze!WveBvhu#UGJKHv2~#;@&e9A>u8c{_j2w1t?^goFu<|!Dv=!T_%QOV^0K|@cNy+L`@M^52z`y0kfRU?Vl6g}CD0N`)Kwna0i z?MeF%gH|Ld;Zur7eT_JQiS(1x($CD;mnR*zm2~pUJm1@Rv9iDepqbe9ZtwT+ulU*K zDIDW$cc#$~v{C?ygrb0Vh?1x;#cW1p&6~xh2Hpw?td6d%KEIQ(z0V-^E4KEWeV@>% z!^Z4}HtZSEzHHVG;S>QqqV6zRPr!W-)h8d~fN}9xe{Ak!AHt>n!6PC0EzNEz7U5c+ zZQN6iJ0I2us#=3{;OP(QejUgwEYyL&69kJn5xkj~p0gImEQp4=x!Ei1lr%sw;OntF z0QA(CUV4b%EWE2NNowDiY1@zi^PpMTMqsc@G7TyjK;`i+90ii!_}$yre6bx-X;L&v zvgmN*>1H+^NI+;a#T@%(r}~V|Uazcu1QJ-+ULskpL8!LI%jZ?Jw3qNy9X_?@M+~eO zUMAMD49AXvN=d*#@3+>A*$(Fd1c%S_9(nV$m=YKGoZ<~zaafpB3LI(+D4rutU!N%c z`}vH*KlqN1n6V;P0iR&ZY$|EjeI?WdOv%J>f7%!KjD$3SYSi?jz;?KhGN4YjSmM8G zLH`7c`E$a1zCe01Z9XZAyU_j*&-=B2h1}M^g2eyxq+iB&j^gLTHS;7Cqk#2J82{&K zoIU&H4TOK1GQhk4`#?_CT|KKm_Wyc<$M2!OIu02B3yHW^YbY?8&sXIC|3g0iZ>NPY zVNS39WQzrWKBJzXAV-5hi0Nss;rYu|4{2y-%)zB&X_fo?vqDgcs~AxUkb{c}m_0$Q z$Rm#*Fn(PTL)FH6RNhM8NDSV54WHwd*k@wys;?0~TT_%h%Z z$^P9Rm?H659%Zj&?dXKRwApXi<%Ewv7T?N>s5P;Zf|<9`dEihz@8vtZ1ge&5=jZtw zN0WF<^VQKP=*A8t3W!Wcb_PR2y-PBDcdK?o)PWe2%d$&Zp_inNm;z3=^C6^XE(UK% zd*moS@DH2zVB8eLN-}*$j#K$zSF6RrpydYjzU~ z0e$)_)y26|IYV$3j&l*A9fm;}ng!~cRJqD(GHY~?y1cTpZNcmPAdpFrL57a(a$_T> z?xLTY{;&Oqn-x*qCt7tQk7g}?I0+4d&VJW&3t7Mi6gX7U&(y2kPT-rNdfGYTxoPty zTeB$X5NBUVs9CWirQpnCyF8-+_T2_vB61jRgEde_fmMJrtJG|KysV+Dvqqy$rFL8H zi>Ku#DVa4rq%ypqrCf1)bXOXgsocufdro|~rVeb1DC<#&Xq-{?BG?q+E+_q((!juap}^P6|7;_tNNRy zQu?Y<@Za!RT1rV>Hu)TAI?C zwTcEdzF*y97Q5$)x{Jtz-);uU{oy@QAcF1<2TL?+n|Y$G5|HfBS}0s>h^YqLlp>Vk;#PL2%H`S$wLdn5X$@R1gUHSU!6) zGia&Ryu5HO{K6K-M6%TQkGhrxWxeD~W)NP}34oW9bAh%7M>hwzeY+KD+#G}DzJeO)uFwGW(Eaj{{0ZY=G^(Xu$6SZB%TyoQ7v(j3rn>PsnliRngGtVeRjk|dd(Lvt)X8m;9A_*)tY;Pk@%t-BCv{O z`gZLLcAzrwFQDH_raN{5QxxpfDe)z#nVCOMi6s}RF~3Y zQ;x27fC=);n3?mJK7$+M?Z4|61-*T3;(<-v%*fZFSbg|q|FkZW-U3<37*FW;?%3bXVbK=BZdZcd(UIau^1Sx5G)mgio5MLPq(mLoYir<>!D_ z7C{ESp1|7kT4-fKdTGP`p9Vy^Gc3|PEz{#22jlt_Y*jZtnm=;`4#z(>sL!fDdF9+C zKb>F*PZK;e%hY?K#p@5*m^Gq~@e#0pEKuV_)A`{6q#GcUcK#SGkkMRqbv0AB=`OH- zgP(lSch?_vCPQ?LXIPPIeu)y}*mhdU-+SD?R3LZjD6s)NBur}Cx4q8X?Y%r)J~K5x z?X@X81NFsyUr6U!HI;}5fbfIp8Y&IMCdZc#gPCe#sLPEq+Yb+)Asi(!f_V3Q>-Gb! z>G2x(sPC>`_Ncsx2c;&Cn6Q6-cISps@ZBTK5)xnLfsDXg_KA|#hGeJT{gqM9GM(Vy zkcySZ3q766V<&0S0IFdGLT*^_HtzfH94CJ=hmk$pH8XfH*sK&B0ztwO=35q15bQ#I zpB5|{H1v)Sw-&6pf3W7jJN-F1oQbc;_;KrjK0qfq{3Npaao}ynTII-KlSv|a!$W&3 z;PpD!t(oFmbbI&50khE%3S=z96$5vU%iK!Vk`nfWs5Ecec_8Goji1NY+$S9E*UCqE z+#3dOCAdS5TKhIxjDP!>pM#d|5!{?JO!@0_@-F46-`TpAt-+s7jk!LKE6CY#THJM5xwB zlJWIb3aP2o={z_X#@Cb$KKSSSB{F)b*Y3U*tkhY{TmJ&q$cSKHitgGtrq<`;+^vOH zIDkArqdo@u&smGg+n&W67<=g4u)JgZ+v{uYj(gd%1yb>{;^H4f($px?zptjdqwOD- zZroRy(>qixL9E;pviV=ty?!NYcx=ihIxKQKHdApag3Nrf2I*Ok(2(0_B-XHmjQ` zEv@qA-`^-sG@+^<&c^lHklr?*hzJLr1~zl>V@$_YqNZSF8@-pnMHIqqvcCHvs*}3S z*2T{P3b9(v9`qB(uqc;A$)?Oqp-_E)EPlEEB(2}YbFy6}qt2LeB$l++o>)1YoWoOeY&FO`O$J`ykhm>w=V&|og(866&p8EFRsHh2%@5uyRK*pO;(=|QzcGru< zPuvT;_dD7q1C(?>h|;AXe6IJwun$ho-6PgIG$*_>kEMs?LP=kGO{J5 zzAv?8U#lDdEqky8Ez6vBsZ6B(d;y#`=)ZPlD`Mkdhc0W>Jej#0smP$xqPtGSP5*hD z`w(C~7ub@rQe|%WsFo{r5Ae;gBrL1NwHOSrjj=atg1Y6rW0TX`Mh&pZ>2?I6AP}5% zmA1$-7k&@0HE6c-kn!8H?;VVcKpeTU+S%U;L&))2hZAd6maMKljCY?i;uwiy>4h(Z z>fYX)VdeH#?(^n~K$s3V>>KDz+$Y;`2V^xR^GC(B)Mv@cre;XcPQi9q5R31SRPfsLok595XbW{07<%tW9k_M^8{Vd{0xVerfWfvdy>eXr$W! z$yEGOYR{F1OXwM%!eebB|B5pCH3HAwAbFo42wNEFEYfzQ5lQhFmcB0IS#HhVmn{cL zK*E_&aX3JTawP9b#=nV8`t+a6vAFyy!^v*)l8+? z)eFL>Y{HVG7k%q+5XB0kLRxwpbq-!4F^dbYD-ir~=hn_UwE5Xq{yT`Vrd#e#-pE1hwW(uA- zNodADgmO^ub^SQoUk_ALDvWI&nL17UBE@R)_gN$Ra38vHh<(m zmKNoTm(Fd#PadyxS7Fd3qwzDWt>a%I^}8J(uME&+^^aySsq-CB*6Jn7#lQ#8Cbbdc%Y%?~m)+K8<%t)wvsxTOtN$r` zU%ZPX$p1v~N(L2#2Dg|UAwp2}(58@OI5k*@%s%kwN8>;TxHTck40|COFz}s0*yUYQVGGrC-rqCs)X2NdB3rA~CU#!{698jd zifP--S&>7I#6Lg7>0o1Y=^b0JI<9w-`h5Rl0s@e<7e1rPls`0wvBR&AOutwuzZ_y- zVBoP7cT{?E+1@Ka9lqA7lK8EdY!zG#iIL7uMT^PhxPpc(kS2pGTgRa=6)N1 zfmi&IyOWi>^Bl;hmWTCo$SI=+75w%EgI&5m^}$XaxRga90j=5otydo`@EL#Eltt<(aZeu(h6Ij!O8u7=58sFw^62$9>&v;)ig-=?2YSr&;KIKKzU1Fljg<60yx&5Ec`Vl9 z>s%fvwnMncWl&7IE#sK2-m(sXYoB>i@lr3Ns zbO%sTIy~y#^P=d+8_}Q^pT~!ppN2~cv`~4;6M7?nsynyBa%z?U?%9DZsUX zDwxb5nVCMPXe`Fh;8|KSTlPrrdg^$Z=F9#dZ0se_D~{Hemi(=NI=@QubckebWYip= zS#*`tKTLDDElt_7No<2BFSVof&-memPNR)py~Rv#INk{H$9Lf*?%UD9BtBf9Kj+aZ zz!pq1y`LVgh8sG+Hr+v5S{xd5-u}lQWku%XW`^~1_42XmL)$i>#H8OzC#~&zw~_<8 z5cU?I;a$sHS)?v_VBY0WpXG`Nf+n^Knby*3{E!$S}t=7^taRxC0b`nYF4xll>NY){&_XiVq{34=9vM zJG4;T4`s9^NsN!bxIL`Q0Ib6DYWwxrDzR#0 zddc={Tm{#OYb76n)$#!sC8uX->DvZezbNv!bZ!h8!31H8nN$(wfU;pdns|$iuE3d@nWyW1L7pooDJiMB3O%ES+}zxj*-J>3{Z`*hsRrv3W5@kj zll|5aOmyXP)M<(KCEVU`z_$t$&b-JA@X?2uZR1$Wbf5~QNrCcrN@|eKRSv?X<4C;Y ztF6pqDqClETJ~i_^(k2!vzt`$=Rqm6zoLOMYTbqKn(FKx*-3^p^mrp1iR_d4!5e-DM~3nLkLVtIrxm@v9pcj4i`1 zFZEm9P6OUz^YPn0rp_E*;4$j!wtL+`R$7wzZ95)ZI(#0)qli~RO?6w{stXIj7l(5Q z=)_)Y-dD~KD?XKaV~`1yfAEcYg?#hi=+^7mY@bck7fP&ocF{umbXQ)uv_0S5!spPL!*mO%y{ zt+`S5AMIG0#tqVAobHJfT@Lm`6zhB({~xmtfc^$uanc;pz0_7gVN=?Pr=@(6Ot|hW>JB|3dO01#0m$-aJfP$4B>(m{O^}_fUc2qV{g)JVOz=Q@x-4U zYQIci^XsqO!mnbJ62bK5ul{JyJ@b!z^-_&8&CXS?ix~}I(=e#E_WXQIaBOwC>f0+h zo!fMP1^@BzJ{)ra*yhy!Ou@~!`LED*I53dwqzFk6Lah-!(}9&H~Lco8nuVlrw4Z`ZIaq;gdYWaN#bx1ehZ8fA@W!nTVX>Nb-RsSG2@0eamvH*9I;uhkd~IVuqa`VBH;I^3l5gBwx%cD-s}xuSXfX}QX=wL zjfsf?ff6#|fByWbtJfST&V>RmA}-#SFOw)OBZCU>UszZe6*ZP6TugwG4 z#>Nm%OH1qO?oLj(sJ|UfW^Hb6j*pM$BOY5?;$>j4S^x9HOW-OtEj4vWewB)`<0hJc zj&Atx->(h%%I0Q8$s__i_+ z`1>zGw&SWIk^&0AThy2od2>XVtB-^H_NOZ`pw&^eXsg9uQ=?#MJ0J_V9!y`>6-%>B z-&EdN6){fZro>|Qovj<^?R)dxgfmXPh>f64wCgICw^y`zP?mzvEEUxgJ+WctC`UPP zxm0aHBaAH820e*O;7k52@@rKimGkh*e64ddC;x6r8c6a7tIM!fscc^RhQZx2w^Qkr zKI=DEYeW_)Jonz|1#&1U9L$nSR*J9BosXq(JscUhmYi0z0 z;L%~y(kEp_s07$;n<;{$dyjJuBhBJlhqV0wxBPPsKsK2rd++n z=izot@Ts*RNGFC=K)`1phCEk1f@Spv(gKAnHIBjRplw&gE#6lvuD-YXh1Trt*kxX( zDXFQx4;OQ|wCdYnaOx`IhY$DLu?hm$>;7a)rOXw;={w+HLf)JKt8<)i@tuv_EFj3q z<-Q~p{k)m1504LI`?c}`mX?5fTb;A+pL(*C^i2Z+A~DTV;d0h%Dru=}yCKEl_|7J# zx{5f=oVTV0U0X(9K>k_+ys`}e-M-wp6DLj(AxYMKie^vZRfz|OU< z*7GqPWzP0RS5u*0fc2!+g~u1PQNqH&p_7dv@|h!C}9Sf5E^$ zti)E`v>rDf*wZk00$%`Wteg()%-)i1P!wL}%!71tXeal%KU?{bouI8n>i9)%i6z^R z@8EPQ4(T2e?{j#RwY3K$?_vhNcXlq9Y`&8PoOj^ZW?%wF=4qwClP41+vtLJJn7Tf+>GTKwUfcY8F@1oVF;fg<3fErPCN0xiNv(Z z`F`XSoBI4Qxy=4*^ zj8~B&#B0BG2LTD6<%%Hjtv=9V@IqU=tpp!nQU}{ zc2U%62L_>{V1U3tKtMp+LM5a-6(uC4JDfIQ3`$z*M!LZOloC-8kQ5jwE#0us$N9c% zeQU38uf2ZkAL}^w$MGJ{R~-(<^E~%`U1wc4DyEc23*p0lR-SIxXG^bd=o6Do@NAuR z98OnGo`&IbS1V)1SWCK=kw&^RbZ4QbIro0PIzjfg$3-hD-^K2UlLqO!eJpKALn|(D z{KuUel#s%m%F1_@$R)BLItO>!0VZ&Y_`IO5qtv;`DeT$ijE>ws*J^%NMUaD}xVlto ztE{}?gWq(ZYe?bl)Z|9!CS{cIV@vKQMyDyGeW9V2yToQDO+9l=9ffWBw-js1*x60X zORQAfdIf0m?*4W&GqaXd%?n!!Z74N)7VSp$q0Qg6Ze0I1A<9D-l(R2Fl#!dCf2Py1 zJ3`QkG28gVlfyDWCtn@1mt+o&Z%Nbs(6v0@_tX#aC+2|U!vy?3^p*a8x4kZs$#dCy zcBJh|(D`*#-@k91H`8P|q5o zmDas1udR?e?_f`Nlv%WbMfgjz>IpjsQ=v4aIBrgLz3SAA!Fj78P^*TrV$vLWhwt3) zIeVB-h)2F^qNU^D()nTJ1sSh>l&Pn+B->=_Q}mO(WX|*xM@K6TEIB0XH&gnRH_q8r zmj9w5!62sN@AcuOq7?=_`fn1N4wg!%WmtV4(6P$$P`q-3#V~oi#b{;TOzwQDtJ96t#i@b%B}%aVi#Fc!mDzTnnD(0e+@;fdS>s-xkbTkf^QXF++NCY*Cr_T_ z=AN6%&T&wnHZ?K1R%=`N;REZ56QN?}H394#>glrb@~rpz2L|Zq=^x&E!G3%fRgag8 zvO;DG46>1uPdeFJLF%7xIx9YxOH5T87iuzcKec(!p`JSSXR4|(gU^}rW?UA(Gz+`h z4V)Ha_@tL8#vomM;i!Jv{Z!9C)iDP#}4v4%9o}!;Rzv)3Y)!By;!uH;smMIMP zP;R%JKpD--%KE;IWN}sh`u^Z5C6a|iLycNTM~9o68;L~Pa&%!~0c`M)pk;X5@$r(W zPnH%3sJ#5nd~PthUHk0HeE!Z}t~$q(?RVX%UN#ij4el*I%)nr_aINpAfzG>>3%&`r zTQk1fv`j24Z)SAzDlJ4U59Clk$lbYx>T~<6%NxzDW_?dR{YGo_uSN^U}i0B3Sfls%xy2t)vU}+YdrnhAWq!8 zjz@cr1N`>(_J&8eT8r7?q$S$dEb!X~{qJ7h@!WQnN$ma@4b{Vep`jW}K>^{<;Y>y* z`_O3)f0Ow9O7DI|K~d2vwkVp->VusOtr3Knbi0S@=*5$C00p-u`>StM`DPya4QF}Z z-`nh(5vmaRu(0p^O{G`)!7o1aeHutjZys@&Z(E~s54VoX=r!0(iqchWt~R|>MOkNg zosp4|$?f;sk+y8lrO|we)%goCg_`yXF^%S3CC*s9uU@?ZR$HDP{Hpk3Ka~qTVoIbC zpJ&QFJ~!Ia4RW=tejo*AJz~&*@7Z>GF1A4F%?J3gOsjVN(#kUrnW3#-zhT4ZH@1iV z0mN6%&1&-31bM)HCNY=kclX0Ku1rl$f%Lio&Chjv>SLbm3^kS41uT*!;RwCa`O1KG zZ)$7JKrKh=X6owdVq=*(n2HT5y#WMIwPXI;xJ5kwv6mN>K%#7faG>rLMp^KLxOn1= zORuCLIhn-CX@M@~IyYuGwlbVi4uVQREJlmtRA)E89HMV+q?p-DEQdFLO{1bp@jb}z z-6`%i|9Xa6)Oqq|+sZ+*dsUF&ik&lFM5(9=~= z__Yn2RJv}s&-o51Z>bkx>KQS^3DKJgvd~9;EX+xygNWBBc7RP{z zZBG;Aqw+?pU30NUq8Us=Er(w(jhA;jtsLk(dL1U@t_$TaQ__u^$2;_U$NQM=>2{w# zcra|rhvvgql1#0tSm)_qsU;lWoM10e57bwOG`^$nzWSE^?ZDIG1pmhn%4$(Q4&#C53|Zg2GkZ(=nl|G`W@sq zU78)OyV|$y6Y)vTZ7d>{UwK~6laZjkE-zU9bJN$WUY3=86OqmXA;yu;4+~|kp2A${br!9OQHQb;(V;%RwU1acWfrQAgs*eSCi0qKldFsd2chI5rTLaCX|=e2ezk zULS6c`5zB6N+!KmigxMf=(LV9o}`%LWeffOdEa6)l9Cosuv^27J`4yE4ABtd?({VmN|A-gYPwoHm^=t4+&8%y0*Bc#uPA2ai-hRMQ<;s;U z#ieNEUn8uA`}0{pq1*F^FBR*~;=Vn71lPRZT7E{Htr!PyGCV)a3nwU+1K?wrWU7 zlxJn}p1&d`RUIy5`(gz|hM*~<)3Nb5lV&(Ae{yPykC*pF5UMk9-qaI_0v9jd(@HCI z7!92`lm7YhXV0~zZXH7R8XR{p_v7wf5s7)4-~P>l`D!n{l%|Q#YgxyH$3`O!*JQE#I1oxxguN_3N~M+~X5Y z6f*fp^xxj8FTJ)DFD=~=j~ve_KAFGnlAGAtZ)+om*^%pxMiFzLLTsy^3TkJx409?= z&y96LO2vgI=SXpJaiN;+*uGsk9W}@I@na$D-g~@p_Z~d3Y<>L$A2R(*m28+my^Z@! zcjpfZB^L#$r{@|)x>Sz|Y42yxUYg6Jh69@^f9YxbzTiW3_Av*p(ROTX43Zfh+cMrH z&#Iei?OS^P;9y(K<)^PMJaiapO7eDb3$eDd3&$FMb7zB~dFyMlmh=?$j8Kc+G&HA8 zn-a0uRXZ)F-{EhEo06u`g`r|U4!m?@=MJBreSK4v^!m$S;9%)9kW!5b&joYo_V4jN zeE6_MQ%pqp7OKBG6CZCjE80nQJ7d1xoOZxp{e>)e?bl3iImFS>(hgj~YcK|Atvw`XDWdaBoz zZ{NN`m*wyDF30^chcIz9|2*ExWwM&ptcZ5)LuY3xd9)Gl)+7FbSYT9Cryu@F6Q8yH zCxra}yWd_8e*XMAZ;0#(e`cvkjMb#nl`&UFQ`8eRZt#XcDbo=+=r>t@DW>t-sTZ>G zUS3{9W{59q?cKNUhOx1+m6g?;qi}>A*BLy18M?L3ZNd@ho3wUPg{lO-G@w(j``f$j zCNFAH_3%2X0tq2u;UPO=S65e|5WK$DP>S6D_g(BTN`}{Z#J%rjZKkD^2c4@lIgOWb z8El{$Gv2jpmyN=n7NveS0}n|_qlNMl#0$6mMeAS9Ty~}Y?``UsKAGd&qf$XjYuLga zAvfoFu5!rA6$2%^N&8g#`HPB~%dV}XniHgqvK>o&#E;eU_R}Z1ElU zJ4;HQfI2-)Pjy@3tL7cVbS#D^6*Rqu+ZQ7N=L}^*(QJ-K+V34!W$f zmc*OV>EbE9Rv8i_=_n%6MfFmMcpoaNP+~mkzkd7w)!jI23zQnxt*WA;^0j`Q!Uj4n z9k#N~a0v0+q;)&dygOglAw0&rDC#wC~^X0KlGGJ8$G?lxI+j8z9zGvDsFLkGdN;H{I@jiOoYb zmn7miR`-JELTms&2yyZAzkk#O9FroKmb#xk`-aRSbII=@i`u79ehxqkt+4Gq;>1et z5ud3S>|REzvnd2ASz9V!Tl#!~Ixr_QCZ?}=ybL!~+nEPK2mXJ`6SegP`g>3)3Ya>} z++8U(fd)LHq8*o{pBEMslmL|(ScBJ`@J~wqzPYE2=nP(@o2L)%+qVyw3ebW&2!4X@ zg`3MDU~W=qEiLU`a2W5yVc_oK;$mPBt>a*CZ}B}R$%&1fT}oWt0o|)9@p%dF@#Dt@ z1O)Ia1VgmxuCA^-ckWOQtIU0glR>+~YH6si=RfFYX=zy>$`9sa)=^+xIKv0+SY8o4}!1(0H|X{SeRS!h5&%eM~O$kKM4yP9vB#)P@3bfhJ8!Z zZpW900pw2|HIHBLlSLdxTA|WqWoPT^>N1=*`3>Z%VeuXYY+rA$lCrX`tu2s*n3!05 z&aEjBQ;0|{Rl{nXjH*BK-^pITe%-*JH_CI>d1Y~CdD%@}y#+qu(&A#?t*=$HQAY7P zAB-v!o1453`{r8FlT`WL;+uU@1{3$5@V^Bm7vfZ=aIZy%UTM5+h=GI6DaWynwdJ1@ zK@1IHg7N{IckKTSm)yG&QXoW#E97aQ`A;Eyte|)ky_IUlj)R@WjsTjx{Q>8Re^_n!B5+^RhXVS+^S-|HQ4f>?+aJ6Gi$%>Kyj)NLT3+6j2G+*;Gml zg!`S_w=houX!VYY3Ko#8*Cmvp2USZ+yHhrHWMDVCK|Mk~LA3>`w zojmh4JoU=37`INrZZ}(KE-Y~s$*nP@1+^`bl9Dj22n|R^CfjZL`DMlf5Eeji0RL_j zM{YY5Mu}5X=CD2WEnYWKrTKTrw=G2;>!_N7gM;ZA8O5)^yGI_N5aEZVnPI{WNSg5` z(kpKBw@pk;q-+(&+Okl`UXfzGL}nfZt^+d<6R^nYjJ$X6 z-s;NIWzf5;>}2K@q$`eJ@}0*MAboQkYR$ZnQ92uX24O$@p%*+iYF6$1tL=XGoXh;P zoFwMJ5f8WpH=5)WU6qsjgoo4Z)VDJwVSvmW7Z-Q`;lrnlVp>8cZKNhACkep=-$`4n zGJtvT_mB77CBNU@*|3>eN?J~iQOM?|ib?>5J$|`JIbK%t^|fdUOfY>hWS1rR} zUlO(hvMC9xc;AGC1~>ws-;*=xWR>@sNz%%yqWj}q-LUdzJiFBx4wuTTd+A$OcB(R3 z=k;Pn^-Xd^fB)atPVGkxc=}YHe{E}^<3L?Vnfp==g=`ex(9l2)O0asZ8@Z&}(e{=k zW!B@zZ=j6zj#RvTdmRu|UTh|yWWJg;DqO@-jtjs%Ldf4gyCsX*`zM3fc#f zVP!QQ9Ua}&)TCSN&{^WFEV>B9KRwi3YbrMRqTFC?j}*Dcet6*L&!7GMc4#hOeUb|b z7cW-KPxRHBZrQPere553z_-n66lo=hN7bJ`1<@01fn7O1Amw-)v_Eh_QgCNS$CJY% zYUy2<^QsdDYQAQurD>s=RDbCT=x_fS!YUIZc>!n$X zh4&WC$$=Re8HjG=nza}h7z8k-2M4!65$N_1wn&(DzRFC;^@a9h|Uo!dkz<`Tjo~i@9%FY zMk_8*E0-3pwt6jfWASIm;$S?aV61T_8k(DhS5ahCS^4>!KYjXyZwNR@16CoI;JVSXxw>GSUA#CqZe<^KfY0cW1sC>nveZKp6SM=75*RK^8Y_JYgECr0}Ui9`F)F$JmRS4K@;%gtlyZN8Ey0?A$ zSL1PR?uM*Ll(HZ1ANth8Q4ND`)baA=%d-nD{AS-ya&di1C1Yj!TpsTzjB)#g>NWHw z&d13Pt5|LAP`g}|IH#%8U|ev`k{yHZr5h-fiK1uD%)`l1&iia~8oMhNeZnAYqVI$D z>ekB2%KBByU!PyWEx#0_Xzm{zJlCwQpKIQh6>^_NCI~_wNq@S(+W-B7o%yf;{=9HGfuL<>CDHiQ{xfbne=Gm5=!YNTtNFU-Yl25A!-p!;N zIShF!U?3I+1K0%O*jP$^&@b&ctdNDFI)GhZA6A@X$>O&2;1J+I) z?0I{4xFsXFwydm7-+iVTl1x&^o61VVFR@af)Is$AWHK7ss~Q>fpi7r8&;4q=&^FPD z7xaIG+4S6?9(5(4Tznfk!fpyeZ}3~YM~>aax<%z%Sy`b}Vx#S2mDBi^Z9n`C%EzB} z+aSJ#_Fz=AbLd+d(J-+%HBuG3>~CnhxGVx~aa*4=`uOZ9L|c!ITo2f+-e%~RdpJi~ zz9+I-Kr$Z$B$PW9UYzPlav#rhh8wN*gRDlPCFGMi!Gh2fn3?E z{mfcMR#x5Ro@+oDK3FEHJo|ST%@74qO}SZkf8bap`Ud~JyS5m?i{)-lHQYCjJP;}AZ)u(XtK*7C_z z?C8;>z0CY9EEE7VqtT=XHI#Z!^9&Z3@aU z#A*i;ITC#1#tm+r0)+*2?!nY6@uCTBu;5Uj0-k(qoqM8Z~1WvJv^cFIKvKPIqITR}x^$-Xy} zeSFFxV$zlQ=bhjFKrdm5FdVWg+Pd$AY;o<$w`vVUL;Ua0f9c=P{}CUb6#4x5^X68m zB|IwO9qJJS^J~z^JG-}F!}?5%iQ z8_XTN!cw6#PG(NxU!KPD3>4G=VDw>1Avp#eOo;W7i;{S=Jg-*}{PX2W;N9wH2P^Y#Ou^7Z;ywa|U#gGmur?^tOt_<-p48OT)=Gg9 z5f(R+&3W=eeSBmJjjo+6hRTjDd>eNrFzKSR%T0j+rTfswCVmFHGxMmct1AK$D8;cf z$H$5#le)H1Q$O9foepqxJ0S7*BKwp{3SfdK$S&f=uMjM@0>Om&a*|tC^=GDLb-Xm` zumrN|QX!C{RbHG~_kDmkP03L&V zw&N1AOZG4^S@m^%{P5v&St3#qsv{qlp2 zK`XsXVKnn@ka=r9kP_oSLEH8`Bpcw))=*$?kc`8B&&~#|EfaQXcsO?MX9rc_&JO#Y z{{EZJ?~<(rW)n&79Js-*2rs~T!j%9wR901afGp#V`ZKLSM8G$Z09c|Zn+~<6o+V1) z_*MZF{6CM#cX1=tTrrZ=+y%qS3sZT5YOAZp3v1Dn4}~cfl!Mk|9YTs8zg|5IpeEib znu*wCBsYWD@YZu_jz>r&>B#=;6_559WEJ8gVnwJLUHOJC?VaT(Bzy9(kPY)xIsgm@ zlad_ItCFx1W_m82kF~Y_wesQ%H)-vIQiO30rmh@tDI;CS3W2Q&=9{Wh?2y)`b3p5i zKy52g{#^g|Lut{PmrXecnQ_b!UfFzay3)_fqxmN^CcopZYEyZ=?V z?1i=l>E!f(^;6TRQR36CmZ@(4^J!}o?PSFA)#Pp%3vp^a+;#Y(Gd!o7T5kR^qRnYJ zV#`tzUw-Kpxz;*1WZ^pd3me0G(y`m!*wykOw(Qd)8KHGPJ0Z8hrJ)h*N!Q9v)`?kR z+qh|yxBuYu^fbU`AVsr1--@0B@)o+XDK)$cx?R!Bmv1f+D@N#4fX(?L*S|-J?r4B| z+c}nHy_O@FpsdHD+%<7bwdG&EF}Gp%KeU-S5lVwin4neOM7ge!(EyryW%OjZR2$K* zO=V9$@Wz(0N%o1@Hw{Tt)BaR~JCrV>-?X>15bAIP(F? zLPgAPe1JaXlU)bk(l*cDd6Spj`0~WuT)twO62vXzx)(?3pdsi5MM?&Nejqt=gpU5C z#_tw=&o&QfBxzytR`hP?F1eQXHs-0g@U=Hoh2cR#bntv)@<1?r@`X>G`fTHvY#rDZ zqvFN^#getA#+jLuz3-V#aFB|afQ3R_lIdYzErk#~sphGWZzpkjOUJmd!^p3}LH1x} zB_-I1R`#3D!r@|Myvh*>Bob)j#R5m-0q>8e^sQDe9qy%F5Oe&k#LJc;-1XJ0G7d3JmxsO`oL{ zIUp!V08$j?aS!gPWym%t*_CZddU{>ZlJSM^_ADwY3ee!$v)ABlXs%%^SIl#NO2O}Z zxZ}XFU~avVGcMD=K&T&ku{Pu!#?!6hx3RY;^0zXJF{zLO!+35y_~qgV0!m1XI{Y8) zggenXzUNtRaGzjd*$MfqF=I|t zP#|ixuD_HN6kdC-t%40`>g*f-n()-nze)Jd0wyu~@~4xF6_S#F@TF-Aw|5$^qx!pz zK3*eWyV@Fmh%7iP7XvL0(rYwP={ls8?d{`Z{3(0^jO;~P@Q0bK<%QAr)DkMYq|Hu1 z>_2wwyffcjyZp@#&>w7Uvd(b&Gbcgd12!W%J|_njTf(HLqbB4Ag6z_PFIHCmB`@DF zGGabqg8&7>M`X>Nh&O3Ls%XA`7Rgaj@O!1=5PLvQ0Rc5GiOG-0%U2guC;~2}tGO+D z%3WPUJv}|c!^0yZZWFzdhW_CZ5tvD6jFX|yK1e)U-#hroFg$bI0{vO#cQJ|UoIXP$ zB|DCHvb^8MHx zM(_9CfA9cYyW3{d*1KEuBuZ0@9K=z zjCi^!I+z?r2O=UON=r-eD;r_?%CXXqg~KASEhDB>;`d;!SF0-J%s2i7WfwYy9n}(HMc87{!5o0P>kT^KqIJWRnXB1fA(xQ z14D{AIU{2n?Hh4{17Z^u`)ZgBj~_cGeK@B+dDG5=NPwJgyKy!CKg0A8JS8{%Y4%X0uRCN)X0)IIvoJHWQG283rE6wanMyxb z!Rl6yOCnZx%H+RR_c;PeGD|68ap}2^=0r7fT+btM`89!iFWin)Hi2D zj-Whb+3dAD%Eo2_Nd=-*9?zaGLP;W1X&epa|+`jWpBk-J1m;2Gv~R_nQrb8ls{9CGjODD}Wb#yuGoof}7)!Kyn43 zuL*m~(F2K+#?gFuX7SOZN8WzReK5-Q?175VqZaTZ^U$mMJSi(HTLP4Y^@_X#Zc@PA_3W;ij|S->s7XuL06JQAl?)CIeFqUhAPMGo zaA`f1K-7ASx^24q_UqSdDgP5YQ6HDS*8iGWgo{(E=Sk?M^+a%D)A-?F+W$FhvEooO zvx(GMXv@mNBH6VLI9g0p6k9pIErm@r=W=zE3kNValrtM!+wI%7p(z3fhw+;cg`mrY zdL6{yRXN`bb4(f)Ln$_S+W?uQpJ<5sQWL0I!0-b6XSRh+-nM!%YWK=^5;?yoGC}=WwJtC!qJR^RKEMJsU?_;zHkz&#^C37-EH6m@J zf*G-M;xsUu0(8tB46=9T=ErBD7SzmAizC?$LEQRW!G4$eyw+{VS=MT9)1u1VBykHT z#}a{lLJjDJGOvBt@6PXbtMbP-=H{tY`YFQN+h2Yo1RC=;h;G)_nHoeu4X)SDyvXU(sU7%(p;{FpZK9qt1EwW0>N>Ex1ho3dsHpLwA+2%`cYXbC z2t5I%`a1VH3f&eaA@BCv08kC1$_E7(+fbzOOqO*71qETty?OHnruugewMxB|ll+Ln zK5AElsC_I?9$&Ju0k!Gr>A;WmH8n*pGsz`Y)zyQU9~iS&ypat!;V;D{vG^;7Hf;HF z;8P89EXHP7SXd$?Jc(4D$>}2WCun314H;-r0+A575E<^zT0ZfS0we7(TnK1{NTJ|c zps`>jvLb(dOY{HPxNo;CaT-R4VBj}p&cN8@BR{|Tj~_qc!K!COmi&aE89QQiUQ=Z? z@ggUY!O+OJA66^u`SD{94GnMyG&@A3q`siXSNR@Lp8byA0}PW1DJVSrQ0?Vn$8knF zx^EEqO%p3_@{YonheSvOUEFd&-$2STUB0u@(9%j?zMP>`$jixDKiPPh(fKA45yvk- zNd-}8R-K$_(dX7JQsKfg(OdO0$LE{JBLc1AWO9MmfVb;{5krpD?rI^q?gW-w0U*6m zxPs{VMQ3Dn0gG-S;qt=0vblK%)Eg**rBnygsu(cu&w|VI_U`7|_G_x*22y0X@H3f+ zaY9+uazD+?%zW}>Djt>^6Y?5Zcs7Ob#ei;#ohA%!-i&Rg&ct{7@#BYqT`sm0mv){x ztky|)LuvdQ_o;e8#GQ4Nl9>d|HO`&8!z{&JHs6b+?3XWJ=)kuu+68Jkp{QWoOwsf+ zr%w%2U}E4e2v4tj;a&pfWa}{U-h8xt*A7msR<0>aMn}#q|3;IOH!NVfLLPnU>zi}% z6lw@=5cC!dnCm~%f18p>20H%z`!}|vh8Po>c@SBgF(_(aZ7NDTT}nM5^nqjNxNPH) zQi4^5dxvl`clu>jMHWMPCMF4J@+qtZ@crBKxlf7j6=WFkg{w*o0SsqD=xb?erxf1La~e(x_jAUJ|F@xuoX{-g{>|6~(#CLL2%u@EgP zX;Ct0A@1#=pb~xD=<2!z&`}>DqE3zl>jWVpQrvNBf#KnzxY)Wn+01yHXaRizXDMLW ztswtgabY{%$=BK03=$r0w5ntn6;%vY%SbaATm^SWJGBY=K28QIFTwD;Lou8i%2%vGk3$pzB^=lLrB+66=?$^{Jn*a7kdBQQ=69ex^ z6mx_w3p+}jZKcTI+*rYt^OaaQxGly~&6w^~53n#d3e<{{yLq!eTGFS&AVn?h@4I(_ zO;IBe*Kr*EZa+0}Rf-I%i_;TyLDLXSYBNzqAO?{Q%J#!)TDg-mGstiO(`unUZjnL! z1ve67Obci_oR?1u3g$z=ba8d1r=h9%@WH3cj?CB2^>j=Z%kKWJ62l8rnKfMJu zFIMbOOGZ>+p#A*s>l4wyy|A_L=QqF}EL03HP@YOR84#6U2z1kfV$oGn$m=&f(x$wN zgir}oqqt3hNQsrqb-5ms{A=^(2E@hMYJWiuhd&-J=9*V>2JjlC7JrhW_n279A16Z8jmJV0eaeC;?tzbZKvFGWmX4zw{V zKR=_+EYQcMt`}{=NB+0|+F~$8XqpdF&$#EBHW!-c3gp%7?@Pnzh6ZIHK_P=#WH^#0 z2M;0d5le%dRH5z92lwu=b+=;wQcEl==p?<6@LZKxouI~nVjo$Du_d2AdSudFR{AR! z0)IKU8hZZ6>?{b}JCQ8(F!^T|XphzN>YKvwPa%NAeeLJR1b(T@OxA>T?T=YxhX@n7 z2>Fi1LdwsdR_LKn%55GXj~#*>5 zHpFDmWX3xIq^|i18N>0{=N}-{e8u$9uxdc-hzD62N~lF7l91Ey9`G?r7PzyzzPq8r zw&C7hUi24)9cz8i`_Q};EU)o~l&vnBWkN_iPhn4GmV&p2N(WP@Oal_Y!Mkr?y0B0v z0|SI3fVM(v_zhl-9RwSlgXcv%VJDBatB24hD_spey>-{Fpju>0YF2fnYi0l}9PI2) zF3BX$3=$+gfH-EO+|x7L-wDVEcYbkcsn!R6DrEH6oq2eyL`yn#O2OF*=lRqExnRzG zL$;p;rp*y(H++U1IuvUkUtc>%$KV!Vp5)jvi@!56tnBRU5M&B)>?Y>*p`AQp6!A6P0vR^F0IgngHbdulI4h6&J2S|Hwf>{deNIZdxUbHXiqIE7%yf7v8 ztQNgQB~l_u>2dWmbm%lXSD|1-%u@^7vv=wP-WXp!#1o!5B5JKF3rXf@#soejbdx0!+*qzh%`3yUEJV!WCrI)Oi`|C~jr8;!apZ3+vL_&` zOfm^sN+NJeeem?hs%Ke~hqv@jbMf#f?qG?PW+B2yLEVtbPzlJSTbRlKhQ@5N$U+<> z&m)+wz3nmljT2K0_8rU-a)Q_2{Z%Lt$Sh@4{wqaE%6TV~ct=$XZsHP_bbpyF;0k7I z_O@u`5d8wpVp6OZ%hCxW(DjH@Xy;dOw!jxTRm`YFQWb~jGW2LS1*X%DkNANg_A86_ z3_EQd9P%de2`G$PY%uLZ`0&5($CZRs%=$=^Rg_fxOA+FQgc($eT?b-^ zhP_%f$ceK%u* z>_tWy8ML_Y_}3>@k}lfIi%#5QixP26-=wXS4B^o`%W#~VVxr4pxPu{i@hikmL?UVm zWkU9m+9IS5RGaI2DqCt+yGAif5uqqH4(gz+KcBD)sN&6<5CWXPd>a8Z$sU@BPh~@Q z5@q`gM6c&Qqnh&b5)NXD*127N9l) z=|S7ycj(X|q`r3VCZ>FY4t!Qkx~Th9bn+2f3a_n9v*MU59$X)^HtvB!ZW3v)e^3rG zDJ?De>t|Ay2KW0_fk0qT2pyNmm?JtzYeEiUf0+C6(sq7RXBUNeS8ADvoa zZV->B0O%BKb?>T-jEGJD75oLK;JyvpL?EAwh^VJZC952B9C*&YR;3iO(@gEZ+3xyd z?#%7wp5<;E^8W|Rz3t4fQrup9F=%|zI@M!mRnv&?@DKeOd$Pj96#+h|HNts!)6Ohw z+ebS|9>V_w1gxcE{F`?L4ql1>rubNk2>8xtynA<^QqlrTD5r7z6=!lLFmTE_$z_3Y z#ELVsFXfV+dwOM1IoDCK{7)vk&Ez-&PjJ`?o89Dl&LD$T36II=m*lbM3UPaB*|{Gf z=v|zwpH6m}8DiN?D@8_6JN~yc%aZ%nz%@HqZ;;6a;!O733U} z++u3ZSH|qkrG>opTc3xArzWX8aEiHgc4e*`&Xe}+zNv|c=NwIhc=yVHwIf-jcWjKS z=n0hW51-Og^GyUyn?4RLVqOMc5WWwiY9kXee82zpP)p72vP$&w z*1UItt%$16bl5fkQM;kM7*GJ9h|pDipo-Uvy%))`4;ft&rS)ZKaRoFK{BnhC2X7@a z9w=FLBHD1-4x5a+G3gdIXzrP~E1pcp(@R|C_zwjZv=AdUN2JI(j^lcbQ?7Ucl2L56 z*~AD!)DcDr4^eUPRonx0@q@c}A2Iql4{-qFf#xbunmHSD;}v5r8Jt#9C2d8d_CgzC z6geeRR5e`sW#*?3N>W%lPx0aO5bWSbTw-7prcic^0u#re1JN6{XWurthzkaM>36q6 z%>*k8=mQtR1wj42UYV>)`Kq`pl4195h0LIieVEvKp|85QWVOxkRSllAA2Cdb0X+;M zNS!2krsrAUJd|j2AxmQFP;vod5ZPkN70}i%cgBT_NCrv^+x93nQ>g}68qB{DNoI`$mSnBGHXjAEmu^&MEq zd`%at_=&eSZ`iI3pqgkmHTBSB#jmnGRc!#I3mu^DCT|*0;YlcdIf&0t zusu-?7>H_+?{l=qhdB=U-@Srd@PgN7+pUZke3>Nl0^YxW4=Qh>`B*e4r68~z?9#~n z3Q7NQL_{azJCy{JCn^AWH4*Ae2@Ze2nHm$w$_7dV%?*CN96-;hrSjz-1)2?r4ULbH zvLyjbEb#Opl&EFbpw9=gKB6p6H^+t75&^|H%WgOdP#6nOZmQb3b?e0Rbm~8sRJsq7 zrf4|_Y7b)O{fuQtK}E&I%=nTUOKMO5qvcNLm@7p-eY@w)q+0r)WrT7uS6ose%99^) z_wEH})|U#iIeP^Cw?M67f&igY<}}d@9~mr?NUCQ%|2evgnwma_xv;1RSgTl7v6|pG zH5?g_DjmC=jsIn6CyZZ;nLq&~rtV>j_h(nZGUX^9`)lLI89;O*HAW4~7=j6KI1N8~ ztIu@My)MG!LqOQ41Ig;3{r?-xq3KRFS8yVx>EH; zl>;^HqF7S&aQ|RaA{j*f^7qZO;9w^!j!S^D0P!V$AfYgvpFDX|26FL_7y$l@%mk6y zcBs4**7=5)j}JmhCImgo8wv_GkpYKbsWAm%K;4`^ZJF40VT0>>JRE!8tm8>CxN_wR z`d-b<^gpD&#_=4%IU(?#Djr<|*^`l%mvEU*{3CB5T>$Wdj56d)T(U|e75cF!D72bu zQHoMEkhm@A1Q=ee53)(f3YT`|m%V&>1nBo3lN1?qD^pm%q{}*(1tJbH`ugQdKhrw| zZbbjdIk`GHown*)2LFLKtT?M_iXQ1YUTXYplPZaw9#aSCix@0$0`A2BY;WKGJvmuZ zQ^Px~*9_c*fs<@rZWSpMlRdTr#5s&{!FuEt$H6Z|(p4>L@~P+Y1tI!}DJdzxSTuN& z-GqUU`HbrJ^BMV;7^7&0i{Qp25Am4rZ{X2I?28X7Nba6pq0;t~jp1^m45j}Pnw*idhZ zE7r<1xZftp`rk&hMemOys{&|nP|(u6Z3k{uF=3uwy5ub)2)P7KalMlCwQD_VtK)0m zMlRAJgpC!Qtz*^a0edbOB*?pR-G)snt2AnX@(KSkk~?33;@T5AXQMd;k)R_<3+UNn zhvPhr!awr2k59HNdzAaEreW7CA^cSm5fXizH}&`aqfLmP8c1B>n7ROipVFEBKRBwC zuTU9*Qbz#NIjjuxz=E)(A=Pw2`GDquzHCu=4zd(#1y5#6M->e?(X+*Hg!Va@dYx(SRF_@EK^+_9SD|$Xl`YtPn)57 z9WodOWB|{y^YiC^|2AoizVCOg0UE_H5p*ImLpeZmPZYnYNs)-aKgB~BnsK@Nwg=<< zU@DWd>8VCYyco#x@v!>BdytYU#5TmU`)9BY^x7x6)t2O`(#uE=4K@8|z}ncD6;st1 zbYIEGu@hdr@Ml8}ctbAv?%zXC&noo=Mei6ZoeH`6<$9I-N_Pn_8_p}PQA&8S--df?a)_9xJ`>w%4ni^x?%?vBe%BCQLMNt#N$4TMJ3u>p}cM%NL564%sthV?H)Z~w8Uss775 z{x2u}|EwT^SKdjTg4YYWfVRnl162I)|M89gLc9Z>vbR`1!~u9vLwTF2ZbuP6f{+=$ zQ>5qu5R}SDA!><@5_S+3)qg*#B3&QHhkzdFVB)bY-xJg2$>}^)m=Om| zy_OExinJ1<7gTTD{fL+Thr=$2cS=0}PdOum6x4o<9-|7O4vY^Eb z0?Awr-3Z)fvIfly`Y(P3l|$)rc6EhAEO6^fg#!apjq)*#5Ncv#Zon0T)Q%#IJSKbz z;1^;y&$;D8FKR~Wzol!K0#H*wOx8iz|U2jiOH zC6j^)rZCc)Nt`@rY+PJaBw+Hn=P_&KGsmrbfLBiuq(sH3ueZl(dehV9U*qdxH5pn?X3v@i|>qSSf`_g~GO=r=ttCAhMeO znm@+T8AHR#1z&@7DUK>W8)s zlIqsyCzu)!PZ9bh4kLoJgtHP<8D;{c12={JXRhNLc&~xqWAXm~@*UL(d+~qEd30;5 z*hK57)lMppS+5n(F>`GL)yMxh7c6qPdL!`2e!okY+%sL>XWk0E>_vz;Gc!&!W1&qS zB0K{)5%7~MLotsDEY;E0wt$9;QlJ#cMfh+FlN4YOqriEX4H9y$&dwAHrJDE|0hmQg zhi{JMkI$+WIZ{+yjLdN+WQL$ohzkMvIEHs}Vj@%YA(5FznpXok08An;L3ODNMN9-S z<1$nL%-Rr1&5Yrmp4U+75+YqNv$9T=^n5Iyh9YDQYY@~Nrwuv7u!KK>gH0!%t*sf4 ze~O7^qb^;i&bMWU&zVPCERS4&E_wXrUyofr+HYVPQyiB%%@fKHQz(^Q7+S$x)p!2% zL}E)~y)%8g0G(%B$w%g43IId_BP4v&Au@vV6o zl0I~~5b-eLi{&JBlsE?|q<}cSf@SuD#41mr_9z^6`xae6=uG`><^oHtH=gR+RI_0H zKaKu{F?#}lmQMy|oOZ~8#C1tA2Uh0Vh_Qso^ydryTo({ceSyQBXw`N0bni;nD_w-> zzi`;GL-AT_B}#E``)5#_2)i#*h;^!ig%ScG-XX!U7^ijlhm~WA>04LQq5#Q6;$-Ic zRvuGfgdpl2{r&rQ!uovmkgl6iAA#im8a_9|J)Wp0e{Y;i+`7XoYwJ78P7 zU=Fy7-QZKW5boe0PFfu{+M&rVb)PsUj5*G3sOi?bds~6I%!_#G_1z}G&m(hGbP!&R z!jTLxM_M(~dc$Tw*4BPqmN*D{`Nhl(pUQK&j|r!%)!@^xd8?)CIDe61t(14iabFU{ zso6v%+U+6?y`o-k4iNd7`RCd&*zuG}zxM?Gj^=zxp(p(%2hTv7J-BuSWLI7Q59<4L zrzwpjehz~EaxXox(fXekj}>u*)zh6BK#G<1YUni6+62?!ot+R{MEiXCEXx)K`k()d zbmdwZwz-R>x@)I>ODE3#L&l_^mP56ET*xN^TFH@e^fmm!LB4#{ml^g`gBT4ld?)Cltq-_U=^&#QFy_ zS%@pZo${E?j-re%XGg7!{Jov)ijp*qWQgp5*M?mM_^APK*$+zaLh`9v%wv&uY7|Js zZM%naiH>xmk(R54+hx3J1+^u6@LqVZL#W4V!$HwX0O+s|bxxgv-_R#G6r#-13*$3C zT>%piS{ljrudjOW7c&=z+xE$eW@Z}O7IqM8;wB;QbYQ&?j|WxXBu;b7vsi29_;F`5 z;}ys4C#zW%$agF5ijnJUxr$RWtK%JTUsBSGNNpvfdF=-e#VVeaB9)-q)muK_KBj&Cw3QnR}Qfvx0C0v1+*S$O-2z8CdiZ0ST zIkUK{HMo44f)CEeh0_lgvTC{74TE(LwhJxXDLjW;DCHvM2SS6J?>E{8e-7nmRo-(^ znMRUWc2g0Q%w+8ed!Lkh%cX!btbjBNVE15t^hpj92z~;82LD}GR87utxM0E1w z`lX-PNA0<0b4|+fmBe_Tpg^G-jU*_NObvXD>vz1Nr~S+wjl~?mb&R6pKI`|@E6a=J zp5;!RzD9n>>L=~e_)VR>o%S7TB9($P0L>`ua#3d$Lqke8G$5WERi_JnLSWG)^Wps+ zL5PVMP6=_EDYm3wo-H)zZHGvOX(&plP@HaE`1c>?JQZm;HC`VnT;urjO$Q8$Rc3!8AvV-0%(O}6pMCr<2FoJ72s;W3lqG*#TaGUkY#)%Qfi z5*vlvnp1g~Q2#Td?y0!?NHGz1!MsbCkGa3k#|6ad{3%`wsJG7pd=_NMxfi3^q~*I* zeJxhjBI}LJOijrSVRmvR*B#^SJUBYCg1fSRCng)@^ha}6-{1CQp9m=@!O ze}>zltkOL_q=9=Fh&T=Wpkd*uCduf6@-T%bL+Em3-DO;24N zP^Sb{#}al5F$)TNPk-4=Vvm+hW6;p~`){_Ar~+ECiQHOr9d%Jksm157P4)nF5g$=f z5$ziSz6bx!oUD{vvi&NJ6t`=aC=rv)FQM=iZquwp)w@Algi-YhMj7PG!S z$Rmn|%|5Bhl5|^X6#HumCq&981i(yU#I7L9p!%vs$_Bo7Eo7g(d(89aHUFIjK&_JX z*+$h~gDT<#DznVL znE-a>^nzaR0v$a)56~duY-7}_`jllPWtbo@45)JN<}5Uk@pyIq1>}yrG&Ei`3)~s! zeo1=W9OO>WG)7U{rHz7JAZ`M>v=CK~C1D;Z00m~qNJtxx<7U4$I)1se^TZGPL-v3nUT^rHer z3P(@l9G}lF*m2_Mw$L1P!+Bs7;js69ke;>yQp@E0`Ba=O_QovxAJ?|hU zI>1)BV|;a6%D)CHgEDFC)~r~jj;5ZB3bu~B{O4Mg%I-_{GI!N^)7l6i+?}y zY}9jfOT}#U4E_VhdRZ)Z4bMAWvo&I$3#ctshmcvgKxD}(^)|+~e6BHmNu;%``x-;E zglq~Y3%iudWsUOT?9xu8Kl2KD$ixl}+&_TC`0Ks!lP8{ySpU~!@9aXc>O)bIa7}#1 z;>BJO6om0dcc|v6VpzI1_HIUAjhHpvgy4=LzRiKxG^L;Ukdu)iF_PElwb0g8Q!owR6CsZ;4A955guFqy>KMF1AaVB6SVn&Z#NohvduaeOInjTI758@R-HtLAEesE8C+8mJzWqE@ za$_}{<9l3%Hb;=hBTsE`d)j`W~Kt$pD4qx9+Aes9#jK6>;p~Ri3X6Yx}FKb zjXZ1$w0OcmOYv}%k)slRdT&7MMZg@n)^91}R zWCkOy6|XSN5E%}WRIKYdJKV?-Saq)#wpapa_eM!!uf4W*6R5$n%Qr{K59&X?rlgN^ zlKdirV9`#hG5$SyN(gzAZI_7OGLnt#r8h=@ZxF0v=qQ7T20MAZ-jC6seL^YsF*L{u zg<_t)HC5!3Lg`n`rIPe9@4jM0S1c+{{!$_MJ;TWTP3B}lBmEYedLgYf_YqzOr@pmL z7T{_+)ocKv=GZD{@vnD}X5$SIjdL*H2GaT%J|b6%U;N5F#jOB6Y!`uopq9-un+u(H z`fQmBUFZT4E9+LwH$gh^vC9PcNFmY=oY`HU`27A}_u*k-*j%nqI#solPv>LE2Bh1d z3qkFMCm{C-LwiO0nJy`=^f6?DRC1BzH=0wDI9(kGgBkcEj0GzT4z?Fh{-&_wo)mjG z^Rq064+UrfdUtT1PVEs}>2OOyE>angAS8Fd9~LiCO*vPI zdz$4$-Ka(J<4VhWxN3B6r#YR)G7Cx%%q#|uiAP_ssN(D$ODc1<9N_kGm4xjF? zLMbaLyZ(X)6)JQJoCOlr8s@3#H>=a|gkRC3BxpT)fd9^8g18>ZUM393PH|F|wqeKr z_gjB`{C^tnf9H|O>*!>m7%LnkGnF)gYgG3C-TmLtO^IPur9I6c0BH5c34? zgiX!Odtwl<8gNQHV;kNSP2yASGCVaK3!JY|oOY1JFU?SJ20(hh^ej9$VXk($^dq+{ z@)i^tI2m>lZ=bAnm4U~QM+e}h-waf?*B%#$-am2PwclIuI18zl$Pm((AO_I>)In8m z)`-lE3}8JeBf7j48RAC6T7dHfIrvUNk^ytaI%>ccpqGFoW(!RWKB{!sW)`1tv>Kc_ z!xjPMj1iLOq4d;Fq)F<`D!5MHI=pCtqcO($E4)f7raxaKStNM>j|fu;YmwbTVxfPN;l~w2Xji^UJ-zb*Ywu}pt@yk2503<}~NH+No>I!TutHXHw z;yfnma^Fg1rq>de%>c1Zai~q_RmsI2@w1NP&`sCPa$6zQ>vZW2KsiCqvNa%lp`z|< zn@)+Fq{cc~jrD<7z!BIA7w20l%5jt7@x!&k>*cOnj;PBhDRIMn6<#WL zoYT_Ni;*&Z$0QbgNh%jlNxn6)eygxCKnmDf616k|?VxY13PM2?^}fT~C~>=UxeKW* z!jK678k$<2ht>dtC!BDtgTLEuK{#&|ejT8j)j~{u8S%zE#N-D~8I{y~yMv_mGx4c2 zOQY;fg`N+!mEWKZ0W{;(ZdXeT5)QS1?w&vzOWY;YnX_;*;e~KEIaBTS6dQhzzL>43 zp+u$%wtC1?x!wEiiy^<5LRJ~D-2kbz10c>Nys@B^S;{uSlE0FYJqBW(&jLQRo2ytF zu7HE0!Dj~=4=bF9vgPD&coNZlJgX`!$;+h!#0;r9Ki@s3vV)u9(6OC&KIb<#1B2q6Bp|65q~a>AO|b zN(%?~gcGzoC-=)e@Jiz8RyLZ~xOyQ-@_P{|gz7$QgPI4YH~x31i#i($g=?DGEnr+o zSRJZ$H~PpTlzF_y64cbx+!UcIF_=6riKQf9sTFn5Mt?uES0;TB3pCR8NK9g__jtZL z2QNxD4N{&}DDjQ+%p~S1|K&g4JxDQH6A|HA6r>U=uSX84aoN0?39Q0SA4w@FaKcTND}5Iwt44y4vRIFi zZ>X=S08Z;l&1q;DE+@DnU=4sGD6hPF!Uze8_SFlJK;7T#qM@!n9`(*+5A_?Q&><5z8iTob zw}-wlM{@k!%mGRF;D!2EuYv>3klg|eIC}LcE_mscxfE+jyuk;Yswe;t6Tk=%3k5d;@#_*U=v z#`rxkTMREBq0;c-hflmB_{a5-EttBt!?VxVvw9v_4nMolhYiv;v_xT6RHK>ALuT*U zpq_!00mOi1Lw)10+|t}TZ^5!jLSr0Wo;*xmq*MzRO$4@?lHu$H-0j1@O-|)_lhb_4 zND?`ezhDWEU`ul|p;5%}sIYbqX3NOP_+Hc-c;K!0?rq1tQNSxU>l7$4l=gGClbc-} z7F3xVuI$Ls@>Q|M}|v z)Ny`#3Q5#`$RF(HP?}cp<>vP29}`IQUNA}S-R4b%)IXjrz?rgVoD67sECnWXt{o|4U4o}6KoZ6n2lDCcl_qU+SDk+j%oA7bIIN))x59b+a0T@SoGDgt z?G?IQU&46**bTTAqSq5T;U}5m9tj*c|0)zr-T>PLY#5SmxlyA&?YgyNg4|r6Yp&QJ zk2CL|T%F%1(T-Wpq#ma7^1|x6JY5KX%MB6bq1K4r`OiD%r$@-tb2xo688^qqL)Lm$ zY1_VYRRn~uAtcg&yAA(%c^6Xx6EW%kx)3uwnj{!f{}tiP^z=GqUaTi^@M)})a*v2T z)AWV|XXz4rSF-r1=EEh1tq1C&s5>aOe@$i&I! zu`T*?bH3v>2y910P||q-;LFSHja>=I(Z2lM>ePAuc@Y<*k9p%1LxP^9RZC8DM13DY zexPtbg6s%M2o&KcDiq@9zc^&~k2~qm&=C2T|9LMJc<Ui_j6e1U&?eP``b+QC4yX{SVjDf9Gq` z|LL=j?8SshL-T0uFje6}(+x?y_YS)MyqCZK4Djd++kaiuAICP?8vnfN>uA3gIdF}> zvLLL^jP=`1z|1(;(KMb?P>P$s$h6jkf~FeHC#D|TFSJIDl*cRDo}O2pzg4!%`vb_L zf*8m$ZAbYil}?XwG#9qli_d^Lo@tTrJv|M01?!B*4>q0^R6aUNmI;0fMC98(dmEYT=dt-|fY5bH+6pW~ zI(ID%TA*+Q5vVDfwlXjam<^!Q=O1_(-}M^3KF3cvHl>rQba0Zsu3}IwDLY$mN$fXU zz(`lH_#uq3F|g^Nd{e_k=qY{@Zeh4`&5D&eIw&=a@()s31~z*$(dZvtjE$-9!(cDk zHZ-seL|Gp!#f1&JG1U1c5-Rd@euy4r_A3ze@o#6JVDPx-IBnN?Uf+G_K-8UzYm_#n z`-%;|dbl)UcO`i{Q~ey2_pkp1sKArjE_K#3-IMP`a+k!oWw# z;tu4QYSRv08N8&8v#5I$_vCnW{{cqD!c36@2hKDKvrvwSS!U3Hbj*zqmjfNy8*Lw& zDq^yS#NsxZ#@dANDIB(s zEx9b^71tZK4n1d7pxNo6IQ_>^b?|(Uf~$bASI7#B;If=I^Bqqn=v|i&7M%7TaB+D? zo=uKxMc;5?=COcU76CpAmrg}~a^)q)>DB&R@A?Iv1b3RWJ!7Eo-2RiTa%efG^A2)8 zk)x&peCi(p_|$tD_cAUS%qCMdM3*hqRj&-2U#91ysOWg;@X9=4o-KSHa&R``DnBLf zX<4xuYkTVRT|5TMy4VXD=3*Jg;omL+&=e%E)ta4WFDyk znT8}Q33* ze9lbkspqSL0Kga;jI5Y`vYN`W(xLzpZFT9A<+tu?sq?%n6n%v+JJh>JKj_5h{0{lF zes~Pq^V%$2Y~nTbIC0Sz}~+REIwA2?Axxhq6Z+iwrczx1Kb~ z7~jJL%T+4`n$Jvq9=c>srA@fPJQ7fqLltbsA-RA%&a+HN$2&~YP{xc zQBg1EWog5&aje=D3%V3)X7cbB*^3sPk6nyjQanC0Tj`HG|7L8!vpAyv+Bs+R0>w$H zI!K>{p~Nh&+WC#Ed~VQj(CDkCKb)lXVbgxG$$PGG&3pRjALoZ-w)wCbsIyfr9$IN3 zT5eC1Z~{Mgak83$;(L1RH)Hn+#&s0SRgEiF))wxg#GeWX(8bsO`S*C-%lx=}@q#xU z8Y8~XYXxvdp}d4n;Xi@b|I<^V``5Ssy92Pjl~<|%PYdwRfcsxF{O>-)zh?CprSwlZ zxnEoRf4Q|!wT+yip+xVDsK-rauOP}Y0YVE_OUZ}iEry_Iy%#uO&jggx1&ya9!=8Fk z;+b#};s$ir!6??gA%d{X5Gt}vGOIL9s7~PijgPkVOb`tTpy_9!Q!8d2ff3WsEp#oH zqmqEfkKD3RZnb$5g=x`05kbJA@N>4<-Yi4`&}9fzL90q357jJnA>0G@#XvlOq$i~O z4#_UCqPOO`>daGcj0J0ul07y313)q92&m3Hf`-7gI~-n5XEVtrAn%hyb1NcarXUAsNJ=jgFgC#X{je(cyKzL!1~~<0!HrV6|Mu3Qd=7H&TPM(Nv-=u z3fEWujXvQ@HTuLjr=XzVxo7Y?f;^WgOrY%$rz(c9)GUA9O%(K0lb=u?!R*-eD2+R8^|*#I4YhT-UNqLKsGW|3j~1%Xbs=gb#j1XOD8wT? zVp~T7<_zpT`T^m*Q;_n=^42FhaH!oT`K3W=^A$PnLS_F6B za@HlWpkO+yu=6uqy&5WmPq_U7wkGij%Zcb2L7By|7eq|k2Vf0q0VmvOr=dTM`Z3yOVYHEV!D-H%%kn%p@L20T1eiyD1Pn(X2>VxWsE37pKuE25o&%c6}DN5=o zE}#$~kUDw@5QxJD?8}C`stVr|_7z~7%cb^XSFVr_kPBeVM&YH6s&U(z`^CWB;^$sp z=uvzID98g6v%ZnXj)M)jhRdsegtUpY;sI<4!sC^BE{MfJw7%#h+@qZdyqOq}2bx#- z)t% zwCJgdwW^;tK=!7MfCO`-AGwMZIWAK*U%OE;O{l-dwI)DUfsp)BKebR1Hg={;@gdd* zU{NB|2E*ZTKg$ga5UBKx5hyz#_6F$M^&OD}tCq-YAPriAnd+WKeleap?rMeIKB~I_ z#pO$5%Etd7?b`@X1`{-;xQw*4tzwrzngEu*ki;`TY_u%lF*}BBUbvV{W)_eC6Cx!V z-g&0-vN9sTCv-6Buz}9j$JBp7l{_xlHFpX)h~Hr^x5!rz`NlKxp}fY=wjHAHLSXdA zel9J9i4kxodVpIHq@5rF{Bf~Y4kh@F@vjS9wva#wJ|Q@9q#6{kJGNoSfjG#0K#AND z!xyz+g`v0#jQj#7O5pNc&NPw1k3gevQBx4bK`WKzd0klb&3+fDY#-F2$j!;d>h+meW@o z1^D0auh03z7Y;A9u9_jrC9pQ}K<#<_P@3N|kr2G@=HUCp7x;g-w~ZXYzJ`Kq1{+IW zowNcy$eV_NhtH09TGfN0O?8nWSSWke6a76CAN0v@QGpgx+l6r}QuFpvvh1n`;;!*J z=2eK{3WN#hAdIg{0K~Y1C#%%ul+9H7?ub)knbv`Z`^5ox%}2g5H*jeHiy|qwZu`N# z?CTmMzxKQ1M6P~!jYaupx|WU(jsO`!+t#OC+h*h5vV*z_l$3dczDt(Z^??B5;OzWR z@S)VA^VsXUzm``pi6?*zeHUr$o&I%b~ZWM}R~s+NQYtae`)$OARKhACe~@8eytgf?|Rqcy6nn zQCGkBP3`Pi88Gyi*yfKP%OI|_s}2s`=;`rZ`C_?0EwJ$W9}}1K_fg3CRoougSHiVh>H*%?hh{a8pv+;*b<;P;1`~(03i%HmGa^oa~=~u z6h}suM}uQm?y3}L9x3BzN9tY%;7rMaao}0OJyvG@J^+u`u}S=Z7A_#k0y_zWNL||e zeELJtE_u_2D}Su+6bT|HI}!+eRVa^fAop=wS0IuB5sk=GDY$LMoRGUB#z1u z`+JNsmiVG^r9gFN=<;gc@TzAh>%~DlUUTF;QXj~*y&gkwLynO^j1VX%Y^aM;0iU1) zjrP1x9v%AQAiuv}tQxa?_+eYZ6xpsUOsVmON%nW%TS|o1DEKe3U1DSu4f!bqchelh;tbi5YCN+s&DY{UR1JD`$8t~`E zzG5dCz>H*;^C*U{OWMIm*0s)RQ)b@N+@#SX-6_Ji;K-=$!()nXo9y7M8w1G`v7u0Wpv?g*f zaOvglphpIU+0Q$$$UX4xgrkxcu+4>KsTWZ)8WQ8J?K_O_)%OtvjhSOD;(U9V1J?_! zV7fsD=f=-SVtVBEAJ#Kf4$bSw@4<#A_=pe^YqR}ndq!~BlWG8E_p^VWJKCu20XHWj zsH?5rfi5aI+w0pdVBO*O7En+I(px>)pcBku95`z`S1b(@WT2S}?KA zcX++;&G&tT|1C4wHApu&TtCdxj6xjA`JZJwKJ%c;=G|Zy*_i{0Mz8 zZjXzH*J$bLk_IDi%HV5HUNy#hL0DS$;&vGL5)H$VuNXvde^gY!Ea0HV5<)=H*3sE> zbT1V4c6gRei;iBfEhtU^TH8MR{aN$vl#i6PJom;_8NES8U4w>7A6$^4 zqtSya;Rr5WPS7{Q4f_BLPf5ijL|1n%bf|noE_!Q+{Y&U1l#29BJQ|2V4C|TDtVD74 zmlyM-h}{0r3k0L|a}4Q1Gbcs2!VpYETBF1PVawg9c~Mk{GDkGdkZ!J@c`t$rDwq2!-`#`$;IVDeUkwwDv**h;L{` zUiN5i&HJr?%VbZ(gT&_-DErS<$lc5N&O#OH;maJiB&8YG%~pRp&g6HqO6xkO@g2?) z`s%Q}?|VyVbr;uHJ~cQ!6unZ8_V(>-s{)d?vt;d)UmK@(r|FuKiodwDvzPy##Wg%T zL!)N$TJo(g^e=teljHh*pqS>5-9+mwE(am>uYdeC4!;(`ug&l)68wsYzm9`n$KtP? z;8$++FP|8eaYp7*D8GGEJa$Cu*OvXYWq)nifB7x@Yn%SsroXo7uWkCj#+#rJ{a5+x zS5ItSAv*#JnbdnFCYl%<8@sp^HTOc7sHOJ<9*hv>>+^-Nva-JFn*cbzYW3=i(##)k z$tAvrwH^LoOWEG;(Kp)I*oY!XbXdn_=gUL7Hz`pWKFHPyc>?X)&R&m zIy#!V78n?gYic&aXkFYWY=3G>N=j~SZerpWc;aVgQpRj=&@*T%C@A0|@v9f5*L-|} z_eBTNxvWtBco>BqI(X3O$6x9*_-qh9s}dG}d_99U#v9g9z}xqa{`^urg#Zj87ltx( zq*kx<8D7h>IW#jUYV{?9L^W{t)nJsqfBile!?)QV(vNmhGY;P+2_rh6i)&am{{doe zYI@r3hwy9vb5BT{B9uL~kN6S;E~tBmYbDl)v%3Ig$X_k-d&g{~Yj;I->HP+@&NOCr%zuazKS24OIO#+RR{bd@Y}zHU8{u0?J71_s1a3nr}i2>$n{fN5b=FoSa0S1s|9 z%MW3#Bd8N(VV}P846o}!oobPz^Wncie>2=!J&m|~{AIfpZE)^A9P@?T# zp3~Xc0VlR^Q`a&qt*p>GKz^zP;wyJwdWvpl!_hO{(`faU#+>$0E>X?5S2J68?`CZ5 zsTR7O;cB}+gA&vq{*6LOqe+y@yL$Dieh|f9IYk3q>eH7>PCZ=R%_`?>asB#r_(dpY zrTWODsjxY$dFO{==zmI8RoD9r<=k49h7m}SKt-dvT{&y@Re2NGz=A`}Tq;5D5xhnw zKBCPR26nOlt@WUZFf}#JXJ5U!2;Kb9`(P+XQba@qX2aAgG=FeurL7NB`dR4-I+57Y z4WBmN6@xyRD4m|2brIddhW?-4OJyj#vBKzT2u1|$IMmS60_9CtliOy!NWQRFm;i6o z0b2>Wp#%<*sWgh6JjBq+6pd{=cC^FS8R9W?Ou8t|v{x1nL#0wN>rJOGp*31XMOV!! zO7TU-qdANn!ctZZ!gqYPx74|O685i<@1o7T0fCeP!$VV9Hj zXDSzV@F%yLDn@nvo-0(5*6uE^Ri zEluow&|W}mWbI4J#|QoT>nYCl?Yr;5fcqaO)39AaAjndfgg|VPqPMs6i$XvUXlJiQ zOjvBhbBbPNw3Rfa{S3|~bOqVAbt`&2XydTRDyweYdpSTU#U7py#O=bc*q#U!0}35P zz3>R=4gyD2h;EA6j4sQg>q1tzj9pI^P9>{+iS|&$+ak#4p!*~ZOu!f;2yCy=ORJnR zM|&K^0Epp0vUlN7L8KI5rY~ITgch+Jdk5pk#>%R}dpV!=<|`DxUd$J1r%(LzcYB<3 z#aYvkPuf^o&J{+iWtmmLy1@d%79nN0(0AdyCH_ewVqF#t<7l5sP6qfW?!fyJuQN!% z+9PONT+l#(NX@8MRAVKYy1I<0SH3iN42_yRzMF?G4%5@nj*Y_|3O~j7ZpBoJ8V|F7 zH4Y{ez9VuVkd$O)jX-M^TY4Sq2918rupVJLj@Bd~ik*uM<02z3>(MVhK#0RO!Xcg) z?H?ErfCj2((1s}5|4(l&ExlxVUpXB92p`$Sc70HB5JSzZhu7vcc$g8e$e+G`bWF^^ z!Ry$*I4H43ux0D&6g_FhuJp#g>+e8SH(GH&(s+q=`Vnyu0?E?S(gCkmh??#&u>6^f zY@ztq8x9b`h}g4x6Q@G!`-+d&EMAj6vw`ou8ebUd0+G2?R>~F|W>1O#tK?mj#VyOL Y*M7@qUvp*6LGn|@Kp-FTi(t=g)cqAK^zJiFa}yU*$Fn?MCQ2^1teBse%Y6v?lmN^o$mx!~Xsix6J|-;k1b z-vIx-04Yfb!K!Jl-AYn-rL`1}eHThLIxKD7BqCzUp$p=eL+RExr2q&P*SH8{Q zPXXSasMNZP>xxw^{HKnl=W_@(??S@B7c&p%&`DH@!m0TJt75nHR4N9A7ebfIMvhI^d!NJWEzQ+DHDC0$>`8UvaLRa}WXgm7!|9m5f zu_;QpvCk*Y(W0c$0e&w9!h3g4ouhTZGf=Z)HL!yDq`8%P+9%*f-X6eflwk*DzJ8e+ zmDMFgV{aSV)BEP?aASXMd-s4YM#?wT&cP}rv#9B@Bj$IA1vzl&;Zu<>IWuK~0^C3! z2VEsSDT7Li^ut#&Qsk={a|b&=%~G(Ht6GmU-@sHi3C)DfVqeRvTZF676U<;)uO)jP z4>8q^jI5cO<7RjM!m1j%{3+5B;9gb!!If3=b>X$(O8>^J%F3z|IYpf;s?ws-A%?<2 zb;paj1WZC`oNaSg5Xwl4jlOqUVZVRN{gF3zTAyQ{uC0yZ1vlKcpiC;01f%nlKHoZh z=}WL#LuSSIr?tX*mzb0}4U+e7gdGFg7T{@YcSC*Y}@Dp!1{ra0%!Ub)$ofKMrBqF1lEm|)^8k=DZ5n3sJSjD1%; zyWOJ-L!kVV=uTDjBv^DUTH`~6y(^ckw6?nzLEOX-5i&Om57Lz%O^!X8ezM|^Ni>9j z3<3QNNwSoA^$m`OhK8fCEDM1l`}VHJH}qGH*K$Dg@LR*9{Gs2j<&f#<{ce7J>1+& zM_O^g*j?&)U0ir1yM!eq%CmgFep+Sa<>d}ES>o028Vf)Z zSgGbD*U9*z1}p_r5)O)!Et6id@UR9$F6JJB_q)e6C-t_Bqr{DjTYH?e$_!wf`BgUh zj_l|XS4ZBNhu=X%lIuJBN?L_frIux`8w?{##5Oy(u0PCNJk+c8k@<+&pH3q%nwuBm zR813iFYE`(EgG-DQK$&1ml0CFB5(Yf@3zlZzg+zhpK6JICmisNEFkC{cIwCtVyMkk z8`*)qykE+^y~bUZ4M78BAh0~bFoMcnZMTr}9VC?yTXvoEmQ)eODGi3quu}~V?2RV3 zCL}tt9x8b%O47Rpd?gEj?h9J}JjM|D+|k*jlbmy}(b1PZFdkgInTs#F*)SVJETgZ zoyB0{idy-UVV5V!;dBa-A^j3X-XbTtXV2vI_3S5g90X%&hvNH*m=2VrQKp$9Lgu@c zUstk6#F*Ht+w+TcN>kAeSqG~T78abTMfs}s!pIyQ8;Zd6ELuGHIC#o@R(9{eXxA%e z9xEk$qKYb%CfJ?P!id!LrCtv!Nr)sz)?uZ5X?%`GD~xIftZtnOBegV=U>+yHL8Si{7mO;1S49XNo*~i4#Zr&XZ z&Br+_TByUFK9qL33w6^6(t^B(ew@~Hv)*I$-%^hPBIjM4axv=#FWz5(nK1HfcOyP6ahNjHNQ9?5d zFMc!070oQ|=I=152Q@B?2=8ocNK_t;a+`Rta|xPb(rdg#CFsT6D$P>?xSfNp%J6V! z!*Q~ab2-|xE6{_(#eMiwt;JS@A%`oHh#K)JV5A*oWyHC#IX4B2VEDooPj`KH#6~d& z^%>5*#YF-#0}>M7%)A81d$RXy$fcz=78@QN9@Cyz(cy8djX4c!x_YJ2UqzQwS%|_( zeVttf9vcr{2&7%jgt2K=jlJz6eGzWrCxD*IP^K!^Z?{C6gZTP>cGp{Ml#-QN3=D~j zo_aXD%O~~{Fz2HEqXAQ-!C8j7FUhgJmLVi%(R*okgEbGK9Q#%Fyu zw4e+u;_}k_4X30qtygexWm*R1tB@;W`J|5~F>AWUh}TVCEdt~vdaIGOoD25!#zWHO zf143vX7m!tGl?G#Zf5-1wif8hoPx2v(JP3Wol zn0-2ijh{hfKHtT~h&G2D%sIPDDV&WpI9G5tMDy}tiO9ng5!6^#XS{qL<8}De@Q*V) zTS1FX!rA@$MQG?SEM!NJd7@jx++{<4gzy74w0ChiF7osQB3Op&gC41_a4~xIq+MTA ze>^%owFY(REMESdl5Um=wJ9%b7X$Q&F5E>bIU(;%AsXkzT@{ziv=J3BjcL~r2f zexdmSWq4nyo=ciIK4P|2kMe;N4-T%)lkUmV8k~dmMobZKuJlg_7+P#2lGIv{0$x1q zGauyboyzvpS&rLDhOQZe`@njK%nbG(2S(Gig^6I9x<;On1ds5oQ2CFT!;61u*=)SZ zrZxrio}B;cbWJ*M>h)@sVe;^&fGSx42di4WC|4156Wx(Tl`jf7AM*DZ-lJKFz_L&Z zR3OC&6n#0OlaxvcNjW=jS{wSSQ7o61qV@QY*g3TDRWaJfhYFgyObgil!NzAkei=HU z?hQqu7e`OJd>4RiULjo?RRn1k^ScQgU$u^uvz7vSBub7j-Dj`?R;pX&K_wxLgEFOH zzS2|`Fjljf=E|Y-w1}EY+p;#85&F@WzD;|VbffBZ%)*cR&^YBk*FVsC1hsP6So^*_ z6iSOLRxIljJ2;gv_habrix+2Mtv=eh>+Q_236b-9qeQC4%}_`61xf+KD2=-SBTL7O zj9ySymh;*5Z2yK~l!O>OLcMf$N4A@*JP|SPcT0P1d$^qvS$Wk@t9}<}cw>U`=)w?t zRfmmRS@7J;E`tbt6(wS69zRjicK%nc4?DyVpI7|x^wkbQ7p-X{Bl-GAQsf%#CZR=6*ueOwPRbRiE8;t z(^h=_n^AxqXlIC?VTX>J8YxMuQ(jy`u$!LE&J%-M+s{GTQOcxx3tqXfNFad5FK8AE zur?l)65_-+x)U)L#qDJ7MESg0GE1C>n!)^}L`j7Vg!}N3DH%MRq(!oVa-l&5e?q;I zJxri%045Bwb8!5Mx9GOBJjmgt3TW}t=q^>7+2a9+kG%ktdUVGw;dsz&w8RmiG~es# z6O>f=ij_A3T}fNX#CWuDM!K(g*uSgyjZU3*n?UqOV!(>qhrAQ~Mnkn)p=^!W{eq7$ ze{%5J=4?|-ol?Kfr=|`1SszG#{aihEt*hehR4aj(`SMS79BOe~jSdY8$%d)$nNRA3 z0;&BB&g<)>DXe#?eLsm4bNBI8=D4>qNK%0aYpfX+;OB!WVj?H4pIb*2lUTU}!rwAa zi1I~W-=!iF;39Zt46f<&t;>+w@X}KQY&ECd*$d#%@ZUU!J^ek)k$K+>v&n7;5#pjY zp1~ZL-TUTlF5znx`b=1T{IrvE4(x9RamB27rZwqjQDq@`q1HDAB9>$Tq|cZ<5VWtB z9xYs0g*}5GkGpFYr!BjS@hFWUE240~fy&AqNkHtaQtTFvf=63h9=!&y)MpkmJOe5# z?FX*EIWouY;klS=!UmL;0+~1{{@PUDo&utGlr9D1Ff7G`Dm!vV?LOBUtWAXekw&&B zP?D0EJr-)f; z`cLRioW1irTVc&{0{8CfVm~_5cqfkHRbRX7dbe7v1Ze~QCQEUtc?^CLj$1s7@P zXYtZT4SD5%zg$f9a3^DpKRmcea=4H(Nmv@rE9V4&w;iqUzWr@b!JA8xd{HcHsJiZ4 z86){d=cp62G|#}+o}g1UhOn*&$=uLKCIkdU17XJ8r{a7`^=~S}0%TZc&3s1PwG{h6 zJU*x5=A`|=uvE9V-Z4sUC`s|o+9;6tY7f(Wr+_2tf_HHn z7@)YiQ9Hz5#N|i{_`Nkjc@ekcW4_nw54i^K*(*(Nr6R!n`Au2;_Fc-^TuVtf3stzvE zMFqkq%r9uY1RB1>aQ)gB$)qcS6xyzur>rKjr_|L}j()AdsBmAB0khEe57T*Cg{1%< zd@9Ilg6*MwhZl`_w8|jqE4AA98KJGsIRc8juU#EDskpEs2`~I(Rsfhw5Y;ui2T4th zNG+5z|JtxA4(^DgWlM9fU-)?{kW@34V4A31r~Q+@(l__5SVWia--(I_-`Vsfgo(E# zt@n<5`SqQAiz!8q!QD%~nN$%0ql?qf^J)jAOOkq}N=EXBYbgFfDj!h}1R4mh;WxTd zznGXV24OQ{cZKD20t?~DwQ!mcd4KDs@1T9!{AcGjYoQmnb@4Q!QqaZhh1lpHS#V(i ze!>EXxXz?!^7RO6R8KeE)3J8V#&2{|^>39Gu~|O5TQ`P!u8uM|HTs{~WJ|w!d@Gz{ z)u9buzPB3i(K{6QJ0c@%7G0i^Hr&~Vz?w(g2_G|8u!`uiVvLUrYLuvG{JV}=*E zU@kINY>ByVWufP;MB($*PXn8$52lIYDeKd0>`hDJ#ROlLy`tFindnUp)A6E<;rt8KOFLyfBezR8g5XWp3KHXx_K!u z3h?{Ab^Te%j@)RkCXP8FnnZ5)U+4w`g=1_wE9rb7hPZm#56Gm}s*&ktni*sI6#RgR zk!0tpJq~rG)fTxe|1^?KqEeL|Wzf`8cJq&UMb|4y{X8*Lt-{mP{t|yr=4EFSSmH%5 z_vZ`Td^1jB7vmp>S&8)vV|gsv$~is^+CZ%GV~AYf z+o;2#lYH^*?P-jjU=83-TAvJv#1vimb+o|OhHjebY56*a%j7k}0V<>+p{V-AKg8Y3 zsr{ZqSt>VsJ5%h(@D~tM`xzE#7;<64hh&282g8>M{*!oCj@I>3OCuWJfay3TgzS?C zKQ-5T^iHrfZx#>Z&PW$Iu=G&F^OWGf#un~#OS4S79h(_cC**P7oUccA>6J6Ky5Bg+ zLRJ$z>AHf>1budOV{HUDiq@5j^x%H&QB&I@>f5uAksqgT=)Vz0iBy0F-ikT%?cIRI z6|ux1zBf;QKZR$vH{ZAuiABJ0V2!s%hbdCUKc4V+HmPvgi_p?$?o-xJ^8%IyO8ErC zB&pxQG{j25!BXZNmIA|kWauXyt}ZBeW5M->3Z}ES@7Mz1hD&N)`A1<$NEvimy$ zz@r%QMXDUbjrg{EV1)YVypW%QSW0HMdr^PJu_`lI9cgZ%eEbDi3B=P1U$}9a=SF$; zv{kDdfzbj$u(l1w15vER+_d;8d+uh1Ppg8k-IEf2`8y!h$B=`w%t@Zwn?*n zpeOwtw(aZXTjl|vaSSB2Vu>T|&Tjz-Up@WyQ_ieh?OU2+T;2-C#@((A!`KgPsOL^e z$&2Cj9(&qHTjw3qmuz$uWFJ;M8-Zvr$?Vhie?w7obAoIdcwt0un6BQn(Cg3W4m?6bC2Iv1(lD@+$a5T09*_W;AK=SE7+X*Gr%@RHT%1)H zJ>l15BgZ`sL~|cC-;xN-^@;bxfqMA8lWc&VZSo1a0Y@PBBGNiR2Cr z_YxQ2?>@%gdNTF*VB!3q`E%9T?X>H;t++`45MoJzocufuMnH z7C6A&d~3;K54F1hNUhCgG^VgKUxE7HUO~O2^?e8d4B#f>*4-UB_y9|GWv|R`%FJj` zTb|8RD@a5X3;l@4yz}T5vS1%8d7Lu&v@AKi(V}90=r;%-$9k~U34jcJ8#@?Hn%j>Q zwL;cPdF3eU_QKZ9ZB@-J+z2HeAtw}?Uys%+Psii+Xl#s9u6vdv$6h&_p3VS@PvK!9 zW44^A`xwL(C)0sa~7D^s)#^k6G+Sn{D;xQ@O%! z=5U54V~eHzYe>Nucb*(!69$dX~|J zxxzJb;2Sf+(Bz~T0EI?Y@KlRyDQSY$EcW+f*sFu)dBkibi{&h;xBecnAo*>#treb@ z3th43zgux5dx`@X_D2VS~ruG!J)QAPEno zwf)n=w5xR=M1e^*P60eJ>+TlF2$Y?Q;B%yDwC1B^*$jP=g0d}r&I=(Tsy{32y>`cZ z5R98Z27rBE$>wmxKaRAmmy097Y5kB3Ip|n!ywtgI7{uMqq60|H5fwGl zM$k=^VWvwQVfXwzj+p;oiyBkfcgV;(DDz}rK;nn$M%)VFPshj*d~EU$$Ycg)|yZox+n6<7RX2>Jc?(TS%y`ru%dn z*GNd%-TCI~1CX;9U}Wn8&9#~k@F!F=l7D8WFI6m=Hmw3OSQN!_rC>4AmM?8MZ{b-e zZND*=0#Oa%bMp9diz76xX)O_2w)gfN1|)#a{r*o_bU?0QD|rYieKd%X4z9_Ku3e5- z(<^0bl~>8O>6P2Fo(6;Xe|ku(f-SFOWTM3gpdifZW?9afA6bHP7;IS>~C#V~>i5(L)?< zo$SMw^(hDdj)sTt3*_odr`e!V1AA3G&21{gn!ICd#R?o-Tk0A)?5v|o-?X1oGf!8? zxB#&SWa5jCkZ-y^8-JRWHAst(N<7+>pr@g&q@}G%sh;QaYSF_(TUdne{rYbp<@=Td z0PZQNk7f_Hxp}3De`H5-;ow$}l$B|{jMqlBrui8$kYfsR0s2>*-wYBe<29Gi zld{j&>rJbr%yR8+o}1eFY4Y+#+9-H(?^0$P(1!?uU;d=5$>>VchBZLySttXA^Qoze z`8{vNlcaPUn&20{-zxFMGXu4mFT*Et)k$$R20_h!Bn0`ge8b4f^*RB>^U`B&LNx1G+`L$dW} zj-dC}w%si5jCXkiKo2k?f{CzKtR*-zoZoH{9wz6KmGij|oj>#dX&pvQrV@`{N2bqn zK&f+<`Z<>njY5pK_6fVX5#t}|;BsXN*7#Z;#myZAmJ1+ct+27rP8*UWjR|=D`$J{1 zDUkFzHxHG|8lRq`Wz-&dIt-r-yO($_o4OW}1bzq7kJT4>sBg%Ugn{}d{V(qo+S$)8 z_A1&k>7})ml`=F~XE*Mtx~1y*)c^;*fu91(gW%Dv`9l_7;1+NxrM$Aa2)kibnmUeh zE{<~CAoWkH2-o#IgJe>w%C?eLarPI_=gkVf#`c!Wnc6!;vefbd%5re44EiZ&rO&^? z!KwNFzf^W*auMLlIPKNbfD^w^)^%nsKbLqf;nsi*oGV^us|STJGEfHof+H*dfR|5i zywGhZKLF)mZw6a%z;8q#s5;St3|p-K`%-S&^|cV!3pnpoZ`jp{y>$i#P$uS)r4`y7 zNumA91k?+g6^$DdW!~M9z7}Z(ehOBcHD#~)6boc+mwK*ClLB{zIR)Fk05$0{cIEa? zQA;7Aw`F`%0FM7~AyL`Nf2+|tHt(qE%rFMqt^UH;?uB1$yl_W%GQuW}b9h{5Ubc6H$g3v4936)mU=Guy=n6zf z@6nuENdi5)$wMiXrNdI`XlPR;YF~8W^M_kkK0B{8=1w01Lf;xLL%=v0$f5!>Kl8gc5fqq5W5*yFdm>dv z4irB%R~I2^RmEpMH(FSBm+@{*Z#wh4B@v!ZF%E3}y6Z5!b#?5Qe0F!Jg2cUWg5W(o zRWt5|QsLRZ!nm(-a#25)7Rq74t(J5%gc_LN6=&$VQsi7aa}UK6OmlWLSIWPKgG+~8 zKo2>{xhFeih2zIZff73mp}f1Jcj)~;JGU}uuPcpp`>U1FU3QPNFbT+H*7Q6FFk<-# z^qmwwp4p#EWm-^CdDf{L8!Pc=Bcp-sLs*xneQ!_#FRvUvGEcU$jsiBEJ{U@fK|nwT zWQHKFiN;TK6<*|TNqi}G6@hDBV0O3k9VSo@UzzfdOfM`zG9dr1p{;L}TF-2ry-7ST zF4wDNd%_lbeguCaM}q{W@U3{A!|-#9jeWjA^AchP${X*cTfIw0icmnGW4aU+dGRGu zmd+CrF?cRSUzoUMwSE+Iw7u0D z%L1PMieh^XM8tWE4H!;3Wkx8kinXA)At5##+&=Szo>o@H32?(N@8X0h-au5WXC_6T z<&evpgJA@m0vG(x*|5xay`%$<_}>j@7E=U7AJ_4ee!?_^3L=gkW1tGnU#V-XaH<%ddSNY{LdjOOq@N-JobTk7fADWvi9>I*cSZI{s)B-?Q{dpZ;IXYXcm*j zIDS-w6(HSNsaUUMHQmr?NPdMGN?fA`I5;_AR5w68NE}dP<0dQ|38`qP)a=*io+Z#m z2t4COAwG9k5`-M+-pKlM8;?elO0>>hDLf3u68@@;Y2~L}g_00;5~0x?`Nn1GR25xV z+o%IWq$%o+ONwOS{Y`Ch>KqJ+NpSl9=qe5VCknVG(jFqpE;$23BqJjfuJ4n72*c#r ztlZO8Iyje0#&=;zTMj6ALFoCS3aVB|`Hc$p*@M4(Z|mx7mV*tD(&SNO&-bu5&pR86 z4i?|wr{F)!;v1?^j+^t{1=mRSWfpkA>s#E9ZS*MC$YB$F;jiu?NvA!*Q1(JjBlQ{c zBlk=l8jgXBZZIh!;rvbhB`l)lOnZ`254?lZU+iY^e7iBVZxUN2)pz=~vO2-0847zf zhtzDKuRp!B{2Svd%j;xrw!&u)1aqA0f^qnk!PU{iknCJJWu;s?diCiki(NJGlKMVh zkeK^5ofTl>aOQL`e&DQ~WwCAsjQ7B$lAmEjAL;Q7ldl&w;>AD(nm z+1aW3Rw8;olW!FiHzEzmPHwi}heJ`54z zqgpmRXVUW`wtkC1%-Db6=A-@?X|(|Px7;fa$n&xZ7#lYPUTrLf`V5pqwwwq?CJj|p z@>}#QterplM`Z<$iYQ8QQu%LfkmHW6EIHpnVKFPE!z`$pF{*ny2tjBB^d647U4MV2 zCQAT%CFIjgjGDoO(E~H{PH7ustdk|>U=G#InRa+gsG2%yUv|&J)4TCI!xsFJ2t}PR zJzptlNsZ}D;b+7;5r21D^QiIcIlQ~kRaQAR#C98l|9+9!2&L{Fv~S>zQ(Ug#A9XgA z$u7i}`zO5lLES%|4)mPrihw3t(|m$u`gUNONLE=*`I%B4j|QqbBFYk)VL?BkkcGKn zRkD~9_?M|lIjU6lVmUz}`wrNu+u}v_jX|$pf|w&m!A<-&Xuc3OsD+kX3QnMNi8yx9 zK7Nu)!3Tb_Kk{T)iMxssQrCavMG^B06j@)iO^F#bxi)KOG;f%bF|cIJ=3VtfzS8;( zVqxYbe{d}(@6FXKEzDDLbE(QBUELsGgHcP{g=P7C$}P1h{Ukw&@Jof}lP{HO!|j{l z(4e2DBwPlUr<*&eHUkPHwVcNgP{3>=V)mF$0cQ;=t z!%kZ%+dDVm*+_hIB|S6lujkHQNl44;4l;Of_pHoxO**Pgd@9Wr(mhxLL`v-G(EZcD z>ycnW$$&z^7(jw?!O$d*ceCjK*iNHiF*m1WU*G{>6>*=?OjA;&Z)q-{LGR zNizG3es+sIp3yFs9IE%|On)I9a{~yJZ9v-hFGX=q+#t3pjhw>TDU}y-{>}U=5zJ+EBeZWvLNF~d`^7F1IC7pMk{wIuS1&I3VuDR&EaGMHoLsq z;QVS=JiH{il)`&MiI(pJ74Z1~gxDr`zL>tgN9wwsQTD=?$he31Eqv37f`)URWf%H! z8pm?k&*?|sanCDxWZS=qyD9pLSa09jy?cv9$UeEVvtn0b40K}5SrhW=2sqX;?w}mt zuNgd2p*fYG!rRNHIsvcz#0BI1C0w(M8AMuM!cLqm0Rw;Q_78~AsQqnf59Ug#cC~4|nni)R%zdK_ z(Z1KMdk(oz=>$WqYfVq)U0u_;y^)2-kDhCH^F_oJI6?oXl0H*qHCqRa<{P_^eq9XI z3~$b>$FV(42hRxiQ`qD*gR%oP{a|b=R1)9kc!dV`Gj%pEFEHT@dlo2S&+T1 zS})zBIR`=sk88tNsGw7?rlpuO0X0=OQFP8LlD9D#`HLMG|I}AB=H+GoYe`8vchcNu zbd#e+zu3lg2SOg%?|a6G|*QjgDQ4z3L)E&1i;lBC;>^+7T{aA=&T z2W}#s^Uhg>hxnCCaX5fp4-~?YX~Kv0us?L9^b{-Y18`>9B|U&J;d8_ZW$0-d2gw&Y zbK7oK&*+tL$QDH~1$p>6dum*yUDLtNiuKPRi6z!HSVH~UL@Z7PiBOoNPm^4-RqFNK z7TU}^Vx7BTCb^pWc-_vSs-yHL53iVJkCp7UxvM`v9cNE^Wm!LG1xaDm--I^1^}pl1 zb(VC!id?Ge*Gcn5XJHyyy4Km#m->F+!hY;W`8kx5o@>w-O%5%AYo77K;9?E)NBzyA zUXSe%)5_mZ)&@ldicfV6w9Q*a?-jAHL_{nFwpQgceangEMwwP7!J?EO;t zIrkv8|078Lw|{@iU2h$(O_h{RuJR5I;NYwe6>&_)>o8iLT(X{eZP1(#+RFsKFX|FX zzhiM#l+}DVV^4sJEBDYAsOS|p8Ean_I5BD@z4-(H7s30pffYBTIiO!SKYyxdCAsnY z;P7-gPTn=f1sz{l_6-MsQnO_^EUo9cLXw%LuBnLN&7RLwU=FcGL%>KH&4_O7l= z$K1siVh1)kNtwB7utRgmR>nSFVpuYp;v0H8QRAj@vP6p3hZv`=L@1i$cId63(BPQ_w(=2O;SbOGMQrH7a?Dz|eH8%IModu?N3o}Qlih6VeE-oS9k5Q%TH zY$4LQ4Ku2LrGcT7qE2Cdlj5i(h0qIB0p0hV#7l2U)-qT|?8trpN<-B?UUknm(KFJ5 zAIA|oLPX&GOKa^XzCi(f;;@?69MiLKD});Mwvdjrnn~7A3_ZH|h59;!I2sZX4ZbUR zh(slzj(6$($>LA`_HD z+^k>`+sXf(6;+G=p#oxrO|Cv;Mq0UugX{KCfShaqjM2@(UV*G20w={Rkrp|Pyv z_Y|w|%jLd|<>MwI6XO(7i;N*UrCIU4ni*!+1S{WMHAkCT5RPn2&}D7aNQGq0_IrC?69^5r5U?8pAmH^+UtvG<-QGB#-Ow}O zU%1}Rn$d>eu|SQ?yZa}RA_6$s#x1@Sxd9A6f1H%^UBurj;DIQivT>o?-$7*OAmW%! z!l@n9HLyuR{?yM84$KT%ixmGWT1BC5a~!H-9S4sqP^_9nIKjH%Hl;ZyPJ~vGn{d%x zC#zeIP6-ex4f@%v@0|9uw?0=2r~IJzEC~yC{NWRHnpd!R-xJ9KiW)DL&Kf5cP;|T& zO^C|U2O>hYThmKf`gH~j*v>NX?RG`g^!({X>`1d3j?U^a|8N;9^87Y2*ZJ8qpjQ!BTCZ!_eo-qrL=0?Incgo)CJNtOZTsk!4jJ!h zM)z>#1vcII3hRn~tbJoW{{uhljwMU6WT~e1gm`d5&8Clo+vIcG==X_lUQxZ5N91mx z?=tR!*;t^EK~<-piE2CUF74t1cL$YaT5`!;<5OTmiIWjHxcC_#R~4RTWq?OiQHc-u z?ed(Q^M>3b41mDy7!HhK>Vmq1j1`YnXCwBv+FNCAP48;Cco5(8)Ti;NbAOsnVRe-# z%n46B46)TSerB@n#g0&E=44wb-@h+xk9r0r0Q|}*hzVWmDK3*iq^4H>fYnc1Flpx( ztFsR2)tX>c|H$?@l*Bj+?|8I5oP@)modMAtNE(UL6bm7{DSbMR@paOtg#(x4Y)5prDvnhbgRLzBE@=;K-(GQzqkV5thOr z2n2^=RW@gD6VZ53=@$t`c}WYw&O2vGUFV9?g*5HSs^+k$hp&xFIvT~7_BOVJY3Tc& zd)hgtzt89DFJeASZd6nc&|J)~fMT0IcHV%YgrplVY7RT5?d6q0t{g0^ey;~uZa+6Y z07zVofxQ{^amqC8wN%|#YCh5~r&T^!4m%=XXM>!Yp_$)zbXmx9ohO1?_g$HekFoN$k z;N0AGyJSD7H*l_XL|7^zGiR8Hd9zeAu(%=mM=P( zm|>~ab33da&yrZ!4dgjYfmW%%h8v^cr^?eFERiSL>Yd}FTJEHo;Q^u$SCqZ1FgUVhv8h(semp$!%C=$ls#_(_k|dFkaz4spTPW;JRF57JD9+>?q)%vV92UqBhNcMH84b{#oeVl2>b5x(6FWnP*1bl6%n(6C~vB5 zeLqMfa|J4dNNd{;^SltVQbf3PSzuL3HY!;) zq3Rek^``M6571xN&#b6xD&|;Q<@Mi7eCCRAxgrFr)oyYY#rY}?K>W@nL-=SNEp`3= zt=^BSF1IVYH7OLzyt?|I7Ktg9dlw$M8H=NNf<`Kl;afu8qcftSo6&NPj_V9qd64n# z9jqmfn07iBp+NChn?V2VV99XlT-_Hhe@@*X7l3VM$N0Up2q{?n^JXREV_45Fu+z2Bh920$#%6+Saw>Xf zdv0HUI)|rJU{9$`uXDTfJ|9P>D;2}18S^&b+H7TZ)Aq4Pu^?9sGG;ejt=8+I`0@od zA#_5s(p`YvHUx^-yA)R#+~@A%Eopeq-Fc5l0_5R3)=@9=W}j!sVZGQlBK;N_nKeP_ zn{dX-Fz3p_Oeryllauo*T<~!>r1CXT!pJ~?2O1p#u*j2Tu^az&1}80u4YVZqX~YAd z+eFsuo*p_lg2acW=8qyh4o7G`0N(+?6dQ=8TyYT|-C|#p5G9A>c5B(XHxN69$_|D< zeDIUKQZA#)xO5!I5cJHnzkG4A4)MqhL1uD6q&53Hb6`f&vrwwNq5A<}KI)V2A}`69 zQ6&rI+B#0A?4PM4UA^=6JK^=rdiKSSYZ(;@%Cah6JWl;cgr?7&Svp6lShG5)`K|}w zCLZXHL_qM5({&AOZZv~XBywEw0;vq|E!XL^LjZ1$;Wi_qiu@1hOm+EfZz( z8>z3uGXix1Vmfz28L>7ZtsC;V;-t>4Ob6gleBO@UpQcD+5#XIafJb*EqS_AOwzWs+ zyVU=7tP9My)VPL+PrPKz?!yi$iesXi*KXK@*ifXOfNo?3k{j-~WXJmX>#1@XM{oPF zKZ168UIK}rcA5{TJ41WyuJQ5!lnKUVpQ}f)tpA=6#l$h-V44}%55HZlc{>)!GSY*r zUSJ}Fx_+=&TDngQyR*Kh4NnOtdw(ka?lP5yp=}{}p}dW$wIu8TWRKp&(t7;MFb5$l zx}tQxUy;eU?zrmoSC4cbCmTl&!A?uUp?)JHV_6shx01q`_ULD3W@c{gxyUT>kdJHI z+iJ?nB8Wf;v2%2E6crUsO0BJ{tE;KG0sm@i`(k1O)cjGKb<-juY_|qtA|u~7>mCD( z{P2)pzYR`M@JN(GWTr$1vVY-=p09$3hc{rUlCQEek}}xepP!evdg`2-nmRr{{&07- z1FKKlOim_nJQLTLI}EoM5)u*@e*;<=A7>`(>OvzvoX&L;FIF=#GSY5zEG{UJ%243q z;c<0#78Ms47ZZ~tqj&^OlKTrqyGTeB0x)mw_m=ghHBcD%iG-4(sgSkKep*r%Ia?Z*&PK! zLKCS?w1s?1Y1J*OZi{B4i`%0QL?lGnKP~)xIq+(u`}6;9W z$b|{BVt!Q$i!)x%+p!$#w*dZ_Q1xHkxpnn6#Hr^2q~=@K)j2vo4g#w^&V3#u))__U zDY4*0X|5b(Y!=A>EiH)_(-XB)=G0A_>Kon7?hYasb^4=K)|!kYo2{MwR?27N=5~SP z$__2zb6Qui0}Z!j-*3*gh7JL5MovwgU(IipxZLXX#JS>bXlE98y3xz1+f-L=lE&-A z5S1$)idStsj29|iX}i%=+Hzaakf;?&LP22+oyY+m!?}3fjzCPn=R8(7hbDOcx0&DL z_MEZhI;1_5~`AvZU7#q-W;szBAn#ib|w4Lm&m!#M;vv+V1nL5M|M6DgcG2lR4A z#)@3s6e!$W%>Sm^$om59vT^Q-6Z)elD@9b*XMm5BslbN zQ7<}bW-lOr5VxAbL|Xxi*+po&y1;^6gA-3?WTnoocv>8kSbI#r3F{l8ZZAf8{r;^> z&ks;2kS%LGElr{zo^8CcYL96v zrdj8KyrD08Vs1DgZ_hFzkA2=&Zx}ACc%fAZWcXZj_O~{J*VU3!t>xUrJSq?nr6nbA z_6kcWWD|{YQ+j%O8X6h^6gGEqfhQ#|?{!e#`;3$|Dvce^v#m;VbGN6|_1Lv-{j$>2 zSC^bt_=A9`fU!pSKolu>YL1{~AQT?}MC+*>(VmrQw( zM8H|Cx(hh~RPL)s1>`{&oxzf0!gVssgs^*$1#QfJ zPbyWOYRw&U6}PtR0`rTYG*$<3F+`pYaXsBnzR}UqWg9IvwFPy!Q53Q)X(&8$ z846A36ViV?1bp70;4$fYe+3J1~3oH7T{c%Ai9L%R<`?X-%925yoP0U@D3 z3jX%a4p@OIo>~9E`ssG26#(NajZUEUKYe{)f2H3r8tdv3H8x4QyEoO=a)Nm2=!OzY z=U(E48joj+{RyO{rl#iLP@{>No}NzSvL$6@jhgd3-yIhV{_t?U5pG}$^CCKgVZVU; zxEJ^=S@lm(tHFb4qX+C%%aCgXJ3}_v2?q7jrD$c?J>){W zscEyv*$A&*p?e;}how=CQicG#h=gnUIaESH<;T@?bh(tAd7fch{E#~qdx~P32iK*d z{B{7kCldfFEXh-{S&nsDKOh{4IFzJTD|7%J{KNA#j%-v^J~r))$&-nswt{!on#eCt zRO=GbC}r4TnpE-KX3}|FYS5-iI!CNJ)zi_=P-Y}5_8rSog;h(FKG#bc4vuN(Nb#B5 z-K_B0==X$#507`pl>Y~N?*UZR)~yR}^%y}hkW8Qgl0`v4C7BTf$vKJwk|k#_Ap#-_ zY!HwjISPV+NHTy*77=j^f`l!Sb58v&&wuax-~VpkeqCMF)m`^er>G-rHfzl_=NRJ) zW5CXy6U0OB&+0YXnUP&~;Lf%D?YQabl5sy5-S@vW9jtqHuVP+@OFtj)kfllMSMl+f)gEX#Af zv3cjdZP*Vaf{?$m*egY~JHEf?+w&_?B36;2HY^jVo@EC}q%#snVOYfxt!cS>cO8?0 z%$9o_Lh`3~muM%{lr-jWCe0eT1#;B&J4`l|Qxo^_Mv2!;iG0t>*FDvgn0nTB`gAjR zO2!9|%Wd;IOB)W=MKoss0gp}64J=##u>C_&Sf8xPU%7wLjB-o&sa_#>Z$tDPo9`@w zsn0sGh=`-{iv79v0}4s%F&DCOMkT}@;`xM9yv*$kjY@pSUW!G>l;N#Me)!a`KPZv+ zV2A3R)9s(mW=Ah%iR?=cVPNE4p6c{Tt6G)dJLls1_~q8jOm)`dTGKNzr&~P_T1`@i zI(O~1p_7;zeK~Qhx!z&$+YzVyu#X=<#(FPD*x%ia8M>t?CIOMSxCbg;`XJHSKII$QnDJg18G_eLSLTo*dJ${jj|1 zK~^zM;qW2L0j>P^6t2YR6Q-@h^k)L?hU(Xx6QiRan^RT2b1|;`wr`Xs-!GckP}wY% zbO*<&NR(&%2K1j5ZPUg-8031*51qFRwYZkEVx#l& z%#F45SxE!}n-z{&O_aAJ2(Z`;?B9bkITQ1?e1;w*MZg1$o7uRReRb9)1HFX$YOBMC zntwz(d7Rx$`CZd`W#>AEh)k<)4J-8WGVrSBIEVA=mt+vUJz4NO`B#l z%a?o{yC;i9tOm*ify4DkWJ*nDR{3q-qBPV+W)MCpk_;-G>Z+l7kRF-I}Co(@i zeRR;$<@7zDsj?H&Zf^5cA)FDd3DQ9<$F3V%xX;hc$;V1YYdHuC3kyq0mFItcr64(X z)go|oZV?z~sI^eY*}}qNNF?>QL*am}sEEktqgH~CmFTD1>>H@RL{^Lr&^rZ7ZfXbmZ6yVyL1c6itJjVm#$+I;3NIT=2r zBTZ^*Jkz~wBYh4tXDEjwn8QZRcf8}?Q;<>6X6qrj^3<2bXylOgi)&N;&37+~t|mD+ z!)-PYC6pJgQ?sc6@YjOU#zDX8wO0<=eB=IIfqT`GuFwo_KOH7uU|lAZ_-uHZK5c%! zc%}Tfg4ELF(=)!0sRFxq?F!@9DX^sE0)=yOa8!QyAT2E|cFdvM?Keh0@fgaNwSR_` z4U>y)0jt#QVR};Wuff6EcI#_73CE>sKOKCJW6CG>>6yMy;4v|=mB}UrR?o@CWcjFX zfxcT%G5p@T|12M)LgZ1i@t!I+=?6zy;r$xiach%LH3rast@qPTEyZ#sC zpOL%;$_h#1$>pT%_yb}*v3gpRdh?risjG&Kz*(^Zl|MOd@3pctBek-ql2xYXsFQ%LbKGwBu6UktywB0?Uj{0z9V(0+g{YihEd^xJOGs0U1b z_#tlkO=3HRd%C*bZuzZUU>QMWs9L>xwWX7?6Q?Bc;e|rav>`_R;zcp9#o45bg*5ZM zR4Tt#ZqKKuXK;^?5)u+3oNzCjV72~AdBL+FkW5QrVPRRCpSoiD%+$o>{f7@aE`#Z6 z8R}Wum2F;~K1|>88?xSZbi{7vQAxRE%oM!-3U*4>pO1@Sd}*1Pa`N&&a*bXJ_XaP`6Xx@ztaktJ1x0ZcZ}D)+>_C$Y@wi;uG|ky*@pS2gffp3W6)C@UM02 zv}ZDD#m;Zqv?;T7R$=X)XQM9DgIWV3?O6;=Oib;V zgJ4lCG5hoO-GK(?EgP|??V0D-?seqPuy&Sx9$WaO@zlu;X*SoYc|&96vbeZaoTk!F z`9cM>X}M|ij$dAWx;XhShql+aQ(ETh%Zsk3FPu8{h6p*M@8~+lQmtAGo)>Z|#0++K zU-^+ID<^l=CR?xAss71vF2hO}Vky7a&QF>N2v`@L!&nR)v5q2pPWzUeTS74BoKd{_ z0Kd7{s(z?Hk2HW_GuW?k<5Fe!Sd9B{QE!aF63W-};e97;`YNYDQ&)G- zZq@o&DJk2oim%hEJT@zqS2cYjjX43-Zmc0Z;c;5XYPabmKej!~$k2Iltr5(Zc56{d ziN{!1rRCgD#4ftsBDMV~72RXBAyuZap3^M`-uVR+(?hKWu2eEYSI<<7L7`sD&5)2C z?!`10kMBMh(jc``k}uqsk&)rOG^H{9tm?U|)3`s0bd2!V#_c?(ge^Wqa2zwZ{uwN6 zZ>+Z$wVJ`H+Fv=X_Txv*eDh|UluI$|F*-w#&97k{Ot#2Af~O@Ftr zDPM9jF*zu9Ey$Afji{)vE1O`mla;N^VkdvAec*B)g18w*Fob%f}_{ z4GlKPoeVEjR8B+>8?4N0ATOeTZwsd6A-qVmQYe&n@4AP76yslN)7|E6FUN40>(rAT zb!;HrzYh26s3<}{4ILe;kjeeV`Py2!rqItx7sKnKMBQCo)vkpM4h;cM9J~DXFKYFR z7cYMQ{=I|q5|@gsqT(l9c`O*%O;)vE(p2PIG?tt8K6~~IVP(~-Rn_E(h=>ml_67t5 zs1&4Ny)0Uu@tcoc8^SWhRlux!;|M2b%BN4Cyq4yPQ;C`_yxc;=&epc?d%nf>>(>$A zc~U=wKiVa1E*E(;&7j;1K)VgQ8&q$g$mq-rtE7j^%fz0-xa)J&BYE8j-i@T#+$PTg@g>F4FK zLJH4)&g{d?>@tEMTNexYY?8v47jEeb{@%Rq_M13S=E-%pJEMd>d6FnpWktn@KxPpU zt^;5Bk?WDwitLp*dTz$Im3yze^`XJNW95OA9Q(hReMx;36{W)=%7))ADY=YQ!#C>d z>wyA<#gf&Ns;a9If@<-Vnwq@)d`CMw^UsNXd3p9#7N5S`{1vxLh&k~lTX|!j1DeP# znR4`HI-KB*zLe6`$KF@IVuxpF=oc6$pJ!5+{))j{CE@d`GWts zCaI@Jfo}{y4NTc-Ww+F<^iR#deS7-$o1fj?Dy~`UNhdGHCnRKLW@ct(Wi8_oRIS~O z=U>%Ru7fdvYNs+*oeYuPu6n=AUrF)NPTF<6yu7aH=MPH8miz5+0xvxtp{b)&V=NQg zb4xyOm#|#>VocnD>_&-wW9u17qt{d< z9~`*lubI%)XNfDX@Qzjcp-oD?TVbH_NFmgAE$N1Ih+q;|Y$B88Y{PbvY^tTRYG~`n z=&}k?G?I$v%BlG8QB7QL&`)`&Oo}_rJ(BvdXW4LZZY@c?+Tq~+uRBypaYhl#R4SbS ziNttd6B=81&M0|%&rQ&fTtc<(aiIw%l@^~!{zf81hen(rkwy+1?~h*;P1RiPl(pa@ zscH}}kYiXd*zf$uaIdw9yIS!D- zPh-2^P`)4|vlG0Pt~EGJRaMpIg7xOye0*(Xo(qw}7MEfrt(?{%dTnDnPs=JItcr`5ASEJ)WY-p~a=B`Ym>74i!(*au zerkX^j#sj7-Kv;WmsWo%`ovU&_woe3ZWM^1S?n~_-;!-(Y8sAUzBoJf;>FRqBqVsh z9h~hYt`n$?vLk=3cDYJySZxQ8kl(&(7I`4;q+7`ehdq2a`rRVi*x0x#0Etf}S<+PN018K=vFMvRJSAK9osio3v?vK04Kcv%;c;H;OuPrpyNjs(nyr5zyIj()=brBL z=;-WB`3k|nG3DDLph%)>1|p~mkdcw;@SM>zezE;^f2(=s$ml7hrz&|;*zuT-BEAG>!)MMkE#4B&>^+SA<*x2-YVPgZXHbPyUIaP*D4UGOzU%0VQO?%t>g$~x92^`Stth$1ppg*Q z`B#48;(Bt&&y<6l2gl?JY3BZYLIHD|>X!h=Y|?st;#b|34-YsvIE+nATT+$YRaPb$ z*Mv2QPsl+v;~OmOdXOqu&Tt-wpu4+!Wqu$#O4R1puU{vQ9s81{)6&$GhgXMr3$rDi z5Un|%l^iy7HPg#i@i|YYP}Wrh!jnG8Ozwb0C>tr;Y>J&nBwfd^CIs9rvhQ8)m0CIS z!v)(jzOB%vgX3b@y~U}Yyt8o!fT@!- zn?}O<&3RJKS_W?4y47Qc+B94MQpq?qCnpEBW|Vc?^D7@7KHQ3Z=Q;*#QiE@!+;*`K zUGVIUc3wxztf(rRd5UyY3xc2yXp#{Gt2oGT)2rP0PKOKx!BjN!Z%jLYN_x-za5v znR%?uUD}-iYG^74)0F_+Oe&B=)g-noXd(YG{f zSWs!06ruv$CFQl)YEy1C{aH6S5u5(u!-t4|T)yWH$zHlt|A?g-oO9y?@v#8)iV#5R)o%oxsLa&rD3FBD9EroE7M=ffI9Tb`}+~c?UN}N zy=8)xm6c8EB85$AndiJTlz2MZRFl$pV(x!Fu=9++Q~IKUZWp zZE*X|K7*>NwNG<$6ti23i;FeJvlYDnC58x4bjaLf9)V%wj)Pp~IW`?-o`3sgA|fMS zIgjYuIvQQMl6U#tT>#=s`ip~k4NxWag`+;lv*!)>^zgvX_T+|#w-k?*@tRLhPluJ4 z;2_pjRXwT!M6v|!g8!PDn&?<1GfS8OY`(uRQ^6UnU3MZ6)r)J7G%fMePM*9G71fGl z6%rimH1yqZVcKe}znaX8Lt|aQ;#zBPWab{bOthHu^!Rvgetszc2QWdp2lvsV4z-Vu zoEM&*pZ7NbVf^)X@$~HMEY=!v=VO3P>D1Srs*sD#j|Ks9qfa;n*F1Rez!3j-uRu9i`1Yq~)-y4MBjoHAbK*O6=u%0~&`_y)Gf$H}v1}fz1_uX`F!bi$S+$oG zrKF?;1k@lyqKqW70&HAmj7r&*Nv}z?>O_bP6`>ZkC8w zb>(H3!OFHr*_5T#hAl5IBi!7Nj{bq;2QDm8?i8+3JKvm)($q}CSW@)jB?Ok0tC{P3 zKOuvIhCLY0#>Td^v?O1ccBkP*7dr%(rV74-egj4d2SW%X57o+z=KHN?yu06Ks~`^3xF3L&|BkYF0-pZDMN5>+FW~g^=vG8F9~a+EZohp7KBzB03N(eXGMrJK-2R zKSC5fVDBnKu-OkU^rhNaYbVR9s2$&#@n-)dcpyJ3Fp;`HYVNgg=5Mm$rv~#E_ZbWWj5og}HlRI2OLISb5C0TwqbsY{DK>MB}Mt4eF zaM#$Vsks(Ne)wtUq3>#xu^;X2UWg-?FJCsQ18El8A-M@TA-=?AtV_~!p83bs>$bKL z5VHyk3rik{hB|arY;1BXuxe%8v4btVHQ!MZHdrUQJ7}5i((_kCtiaby z)G0YRpHgFFkBK&$r)zU2e17MG;J69Fkr?oQQh42MLgzQK1zBh!aLe(;kC(RH6|<@! zYL28f(z(95wf5XpGR=k!%);iOnn#D8hebv4#&p}j`V}BhcCCQL-F*Q;(eGRnODBrN zZQHgP*&>KlTkK7Ou@H23HTCv-I!5LJ3tnMhla8XOb=4*@f#~8Z(4;3rqVaR z6~uNz{!G#&60=hGE(y@Eu^!`onz}`HNtE7Icg&iS{b+DA}z( z#ho&Gp6Yx4&iEcBo-ziU-uR|Rg@w*otGcf{n3=f_DWky4)-5vnLac$B8LE>I%vtBf8#hP>1|9JQnP6WK{i${!F>nH^%V*-B`CqNJh=d~)VUgPa zS@~DY`mdQgh-H$p}Yhi|%xLk}LX}QYvYKt-inD?%%HR_4yU)=ckmFgBSPYX~Ac6 z-k_EjxFQ34$jBBq%xHatJNB=`OSAVwd+#ZaWhu5_e`())Nl7CD((2&1=PIm+Wpl{( z*6E*B^DmuG@*Vr1ZnlCP!7g+2=FKosbxCHPL&Llee0`-6R4O4$#gD7)-n~0$eZ`mc zCfs?atgW9lX5g}!s4Nn0n%2QMF3gfOb~9N>bY5j`pu5_!|6ksYf))g+ZZ(lNX~l5E_3{nsu}l{8`QGv)%^0Tfn5AF(xLt~KKc1m5KZ|QIWIk7-nQ-O zlWqCzJiSDf!5G?qm5*UF%jwF`vmH8WhdbGDl&V=;A)4p49g#Wy<^rDET9JWO)H6dy zmIf+_yR{KQsB!KE21)=^LiW2@MsuX4m|ezvvs9@iMUcAYD{xHL#Q{N;ONQPf?WJ~K z58mrFO)NKHW=KMgH;P%fbLS4o8wP2aXMZt1;BxHnM=?CoTBzVf+l>m-9+6PU^b*R4 z^73-n5yQj7OG`_s^SncCeBLVLYI{LRdf8d&D!#y#Qnf$BPIgi6I|4BB%+c|roJ~E( z(U)h7vJ<%sROCy`;;r4bl<(iaQz+!D?QEz})D9ie%B^T=DOA;PPc{erS6mzI`h z_olx789V=%J_lI6#+zM{QJkC#L`LWtD&KBP8rD(5@4(Rq?eC3KX+#As#N~3z1ZQOp zT@)z&0t|ZPsft7sV4gan*w>G=(kV{YrZ}F`q+G9;t!ir8sBlT&YygYkJ~+ z=o>>#yz3w(kIehy#}C|RbaZs(IkofW@A{0Rxc2*k@8o2HV2qdN)ezIuk?9@$9H%0 zZrO3ti!=a9zjlM7qM~#;rLLbpbIhC5QNn3zY5Cl}Tg_`)`xuM~wX;*H-~@I+Yq}cm z*x_N|sZct5odWzUPD2e9mNH(`C)e%mL%f%6-MR&4?}TH&=CgDZo#Kv$U=ZBiq}wt+ zNZsVgixl`r!_+C{R!L4wN%>Gw;Q>hoa zc2@hDNaFp7-g!T70+VaEYtAjj_N@d|3-pHuku55GXqe8<;Y~Ek9C|)(YYZ~NnJ_Lp ztbA!O?yFdiQRODrVBh^`?%u2V1>8%nM6cC4-mU4HT$#FI-R)Cy&iqRf zN*BCYv-5gp80EOVr-2A^OU3{awn!LuMl-2^Y=4Cu6QC3Xb6UZ*#&|)|DIX^NtyFk; zlJ7)tN=SGD#uznTQ&go2Qz0BHSz1+F5&B>7(UW3)tVC`FBUQS1!{?NXFYV6wCvs;f z4YKK(_9|pzea&O3XHqq_m)$cMQB{cdhbH`HbhpZS^1j|d`vp*p-#AJ~a-l(b8N1t+CpV7Er+*VTQcL=hbxZu&GGQIh&vv3g@4%cig(E`|<& z>@8=n*Ka49PDEdF4VRm%2vF7PP3DnT8@T#KvIa!Oi^Umr{yz)NT^`o@$}z)dWeE2C z18i)S@#Ez(T5u?xwZh3keG}%qgzS;6`TXr*qFJ?{r0lWtf#S<)TI+7_7Z=y%Tep5a zEKn*%H|<{PQwPV-W0Rl~vw6CiU;D*YjR?p=FTTz%YS(#nkx@Y3BUI5e@TZ7Z!FTYP2!3hW?T7KcP4Fw-#pW0{o-W%JcbN zVAFA^-Q2*ypy^#&HTh3TaP7ydd@kPJM@N(HpQ%2htvuEC;^f~C>Xa)%A=H~{=YzN$ zO||GKD}|<|dr4022?!<1PLhz2(3#Vx&x*Psel(SJtE;Pbe+XgYP$39P4$$>9uQ(lRgp7XA4m1X}CS1Pok+cDW=n(>Nr|YvKaYN@S*R8Lvp8t89 zj?eqDK`5}n=AD9PzX;iOouw)Kg3b?uN*WV(#qL}l3-W$!EdoAZn;@{<0*KP6tRaBj z>X*V0{5DI*DaWwMDvx9l0EkOK`$cN1Upw#yR3&!CKsRcdi?wzfAdC3U4Bg{&`)hc5 zg|f8&?26-JV&x0N#YVO`rVym&L8MlCoAmYdfur@FqlN>kaf!IYXh9a0xWlCH(ite) zu4pZSoNV7)?{`TN`5!SDaM%+LP^v`$a$cm<=ru@bTrg|xKXGCn5q9m`wdeHaF9e5% zg^67K_z+Psz_yTG280P=yc<6~npM~?D8H6C1LoYEoTe4`wtL!wHdg=dtT?x?pX`?`>nlG2~bsBi`h6;le%UmXB}VbRx%a^@j`AR9Bn7 zzrQnn;0Fo{^eHH(@4AXgvj0mO&`&T^!;|AX_UtJ^&;VIH_EA<}UmqPR2-+|_umZtm zTY?lr?NN80#>Qp@5&0t(@f&OppWZ9gxkMXnZSAvXE8qjEbQc7gy>a7);EVEj?9<>c z?!1djOYT$s9N{~*Z@+c>Hcn20#|Cx*0fK(XE}f2i$@w+URH5l$n&Uq|nZG%(e21`N zI6MAIka}#Sbm;L?i{W96zAehs;Ns)^GB8!`dnR2SHVIrABvXrosqvTk$p+<~i(?MlZPymuc1_eh2I)(9L%0?NwGt*tsp*j$BY9APGx!I5TJYOOWOp^ z$?0iE(H*+Fy3hkEhCvEnhese`7koR&cszOUZm>YZ?fLkq(n$!LT~AK#zOL7J6{q*Y zkZ6+?fdB$`r+Ab!!|MwgsJ?y}PpkSMAd8Q$@50>N&gP#31H2}*;_lOygp@X82iOhM zYrr}Ln|Rd7VY_XBKO5!> z-tvcGX)b4FcuE<9*^^RIuyg~}yEg>0;{>?7xPVEVLDf~=T>vhb!1=o#$|qMBnMEL$ zQva2jsgwMmwlUVMQbtx5Q82flpddGQ``SNBZ_nE^At*5zKD>W#f>eyk_q?bKUUOgB z9~_cD12119y-e_btktiy&~DoF9nXWZB0MxS1So9&s{L(#4Ukwx$pJGnGlC&0-8T!~ zHsDn<@K850Fu#KFDEf2GpFMlelmeb#Sw%%&)ZWQy3!Dq@?_$5Ajp6ZQX5$cX2?q~nu&dLPGQ2+s+az#EYBEl?E`ISRouIq#e&ENXQ0P@=Iy_d#0 zjU3FD@r%5sDW;Lpz0aRWhbFe`boTe}Vqvj%$m0+angB`y(OMl6|F?@k7+fp4UcN@v zEZv{;N;A*Oe$`CbS-Fg@&>i>51gk;^$6CV=uK-8fVfTy=@7}S2>O*|vnEPG^asnpm z?|3X0*`>)({?db!zyNG6xD|f6J1yA;((LAI)b4Ll9>Jmq|f!0Nth=U&jx!Io2?Oya-MuN z;`!Gs9Wotj_#0SC>lTPJ_%aVo(%1Dl3fvU6Duq@7+rU z!hX)64dK|g353aTf4});dO|`su+|<124So3M{S>`ouW#`vrn~ef`^3Y@WT;6C^Zos zotV%mak0I6HL;QV%2~2~lOPadSCyuZ>hU}N#CB7AI>1BYJ?*G4R*HJwK8i<8Xb+U|r z71NI3i2d95vM}yp=HiS>x5o7h5Jvj2u!G@I!WJerHU{}aH`!$XY$3kO%gR>gHAwDe zX1->ucC$k~%-euW(Iz#o!vRyg51q;b^`D(9NZ2*&w`9UV5S$45*8{}_;k}s|YSpM& zNU#06I#py=b`8m}3b-B|@P5YfrRi24OWHf&4FkYA-hV%%7Lm19(TdRM!;oxK;^I#7 zotKt|q*Jh|bxj1g9WHk_H**gIjP?6!4D?(S^(-u7m z3uD~7SI;qUHA#Fs5nLwh?CsI?bRc}q8p6N&v%jB5U>Vf8TFW6w|2JVVBuz|@bi_tP zaEC)og}#I)pceqzGQsij@h||aK??Z%HRAtDM(4>xyCK1P{6|9n()O5mP44j`CQ9i3ToJai@?%VQ9*2c#ZF)^2Fs+4XR??FtqfH*YHwtQ zf<+bMRt*{R3vzPwK?yJl%Ui~x*^!l1AbjthJ)nu6#KZviIJyRcBf&$kat(r&;0QCV z-5kIhPiv1PX{nb{ljYR~}nSfj3baZY>-^$2qWi#UL+QXMnHm931$} zosmIN6ou8mGrpdVh0p4?+T1-3nG-;bE-tgE2VvQy5r*$v6(rT9pS;|%w1Y72qCkKCAWA$L%{E`Q_LxoGO%AJ z*bDFz6$8;saIX1bM8t6jd#LInOYe}`xlv#cI>r0@+h!7YQiFp70t5HLr$nLAl?eu^ zx0{|`^=`&oH6-32VC9OQgoI3h!P5K&>%hA|RQ~5Ji*eN{R}M2{Q3$6!BN@(sf0zc& zuD?hF85^C#pYhD&B`f|l#q!W48)pE za4`r72*6@B(2`BqQ@WB6kHJ3%?i5PKiq+M5f*&Ms*z;sm-Pq*Na7?h#uyE9YdX)dp z9iGm>Q4JC^Pn0$jHYTXC83!QD` z(CXbbC68ia4#@<=VGb=8%0Zd1;g7* zFM;j}1Qt8^n$HBYM_*#vvxo1!Ks!(`WN80%C(v^7mY0R{zRWwkeft(pSawE$ewM9U zfByRA1R)!Ce7IYFlzHj|C*nT98o#DcKsXOGYNPi+)cgzA+<9pnKj^10K3TWD+y==0 zR@uvIIZ-AA)YBBWoT_a#|0>ptMzhNhdR$OY133Dtms5}fS|K5qG$Aw=AZ+44&>y~Z ztc+r?IP8So0}llaHx&o(gBq`cEi;051qyH&@j<8^8vnogj4tvuv_rsP@YY#tn^|$$^Le= zT?hjA6h>oL7E}6!CusyqM)Bu}FCHQ&3*3;2yEi1%Gqp?^$6tHhJpq-P^a1gJKR4W5AuQe2`kc+<9u(oKYz@CO{KDGn5t}YvOLA&}H#4FE=?Q zg$|0E7vi7{gkpM|NATX@*CFJB&^?%~_1x$jb}he0o83!KP!5Fb1#h6Amo;h^B_$=S zTbPg>2B*LTc;LW+YNwvimCUok=Lw1ZIq(E7qlOBufj3(!#;u>Qn&}@xqmbIN`nOxS zoXU!dk5uqSo@nAc-*Ea%x*F1q5*&bOnt4~Ng8fr_;PgZIwxghl2DtHjc+UT-(Q}Cs z8n5RRSyT7r^{3<8)d^qV00LcYtdv2f7TRrqochV3VL*a_PPcF0CTqCBS`KFj2>l(m zi_jo-4Z;|eqT~Y}NeN1qE{GeI=@Dh+XRHXtAWdY;X!0eaM@O$K5g{0*%>6@M*!I8b zqX&kD_yrDfaDYgEYu9?hY4D}n6v3HN^Em!Rd?y6=I9fZ1_BiuM1ISwAkpPGc&4u!# zlap;FuJ#~z$O_%xT3hM%o;b?KCzlC7)s6A63sq#S>J%{xotbEvIX73yWN_3iAAr%W z6Pj07hD9DUNwxzAD!T9>{M|?}(7gmzWH*QPs~+G~<3W)Cs~A%N@<^Ia;WM+%Czy_i zMgN-@AR6b_HeTp6a#rZlY+hd8x!#@N+_0getj-%6#=ZOo=Y5GC)qh+Kdpis52#{eO zd!_;Pj+C#IY0|TCT?;ujomf7DHDd|sa^c#K?b+6*d*t*V{Y6^iIplp10~Jv9 zQX0A)=Fc0DWo0c!f4*VVAA%tcgxG-@<9S#*z`9jRFlP0&73INHw0M6O>0NSh^i0AR zx8C}_V;>!H!-2!T^cTqrDKM5DI1Bw#n^{oD)eaS7)9H68P5BlNp%@n5P{b}1UtS!H zGy$_n&*40T4tRX#Y)w`EU2Jd9hS>fsdR*`#Sr!TQKsBM*^N!8TgbN@ZBK2(zKZ}it z%rS1sf{Fm~FY347)r!+%YH5it_@u;P8TpnVO=l}Wg@zEhnFq=@Vj0e62D=QZ6-?PU zKcbAv_Lv(~Y0d8`2UX`Bkg@zvu-)f!@1p+t6_%cMK9n_rdf9{AIE=hUrKJ6B6gX#4 zjH_*0|L&ik_Pthg(~BS$-lAAlu!B2X0{wPQEosn6;Jxr6S+F6?9c!vHPO9;2OE?Es z>&pvf1~%1B8lAI?9=-cl~nBMg*wW^v)U6((ueT z!Czeh*TdH})lU@bhvlj-n9MCl+11!dF0ji4BMEcyDuf09*^lqhp7VO;iJ0>xhxfnX z`cmr1M7v;PbF&%?{WVmcga$^jNC*w3S330W=roUd{9r&iB>9?zb=r=FH5@*cd8yo+{ek>8kP1u-BAB)gp?Ly10T$iT+DCgy<*Q z;5jpp)BgO*k#H#qe#stANoW}WuWtYI_04bJzQN3-Tk4iiEB{%Ln!~tr=gy>gt^9gf z)hj?`uUB9tyBJC?l)q1eUF_=d+c|jPcyDP?pK_u*)@*A1hfFV%u6n@v*CTH44@NsT zTd4oL?rt#b$xbwpFOmxXC(T{wWxsOLc1JLLSK@W+hfgwOp&-Br2JPoYK9h!n0=5H# zaCUWF>w^g6<9mirajfY$76RO2z^>>_H!cNf!GFI@Z)$HZZ*M8Ym_#4_lhpzDQ&YL} zPMz}*wE>DQ$T*+R^QioWz#7+D5(n5SRA(hHeG<*I1y)gZ<0FfUi+_vkUe^G15I25+ zXNr$`q?ojwf!pTZk5c!-cxR1EczWTt{n=$;hGaaI^kk{*&bappSeO83jI@~}VClrM zL0r6W46DfaE6baUi?dj@Oo`)ZmzG~Y;TxaL{{8zJ^~*@L>vp~c2wKp(L) zP_r>IGKLM^(jm)2c|6EDe9{ON#7`xKsXklv;qhok^K(aB%QmW63g+Vq1@DY+yOxv&1MVM-r2{?#q|wY z9VjaBYfFZP0(&e>Iq|=wp39MCF-L5pt;`oJQv9z3E`Btop;|*Sv%*E9Xv0Jwj9bZi zEQA#0ovBke3@x1JX|SEL)4V{dm~I47aBtbe)$rpLnkP`vJUwv=1gwVe0iO@^oDmD5n<<8exJbH2q!F83A~V4EGj z9iSfFR&CmK@f=w;)Zk56(e@f$Jv{@rz9cmM0u?A^>LMh$S&WAwh1uk zFH9R)8YkTG0$AMns}%5;V0Ft*ihC`(SXvGWHHas8G^3Hbww|zfA~I^F_9NQGTgc-e zR9s6;SM7o-b2k@A?wVQes!+XBH-N=}>Hs%GlaCMgW1+U{xgpLsrz){I@+$p6o;_3@ zX4?5_105Y5Q1sllR+OKB`P7#OWRy|Uqd&u-z+7$4A@p|d{OoLK76;v0_>zBVke8RE zPs^m*smHr@qaWxbLE{uyZVRZ+&fp)w3FL};(AtFPqqK}JXTAn{Tl9By(s9A~l|&eY z|48gL(>ASg$Z9aB2zKuxGh)`_d@Yj&fFofUX0Y@N8SO#Is2o<-z!!CpU z%Tcb144w0QZlrDwEDCeovmf#2XmHy_9^lr{OP!GFhL*I)*OY_V5eOW40p(tQ8o!9rQ+w9-$njU~GDcx^47}V~FeK@0Z~?GmqQZnqcBTDo zw|#xEYVPG|?12Mbs6RR4gYjc!wN{F_gF`r(C8@h0CiQ%P*OJ#L#lRshO@|pjTa_`%Je;d2pKOT&1NAtuZnrU;-FB!NOBy1!;cUx_U?vcJ9s~t3S;&eN*iaw;li<=znCd+bi1K7IZA zC>ozYrSO@5tf@in>3}@tgwAzWc>vnL)8Rbi&^O2{QOopt>B>4x9G+^c_I!Zwo}ODo-kbT>Xa>wt*K<|ZA?p7~qQrm)Tddp>;< z07miDodQ}LPH#ft$C9!T1D z-5Gb|;^Ml77A-=hSK9n{llFPhXh`fp!;DN!DJ6HTI?9%C-O#A7eJ6naaXPx7oSegB zJ?9~ZgELZ+9h;h3La&)#{thi5IR%7phf<$jHHgj#6qQDR^EKWZGpbXXfGluew5@AF zw(s1wZNG_=0=m)U<@rs!)e12`5P_axy+P(5X$l<&&Is?d5?ImlGrx~)9rOa5(r?>o zD0Vf|J}(5N(BnawG(*H`!SN%MeLUD?sdnUGJYE8G&vE5(tMQ~MCAT1v7WcvDAZL{_!)=(&Wx+m|!B&VtZJ#C?jRBqzA>cWfA6F<*A(%XJ{rIsU z66wuvx@65z8j}^Y<&7JM>23uEo^mTi^FPPRCT&ob|3e;?mi~ZJ>nPxT>PB9k7s0TY z00HcWCrL~URqiV!TA+ME_h{8nqV>0;0+6hvL>xUIC~`1r0Gld2eP_C=@@aIpBhbR0 z3V#IvKK2LzSstX`d!A&i|5f8Pzx*i=gX{XSI-%@Um6cQs+eoL=QeRde`a(bf;VQum zVM+1(d8|gNWq*NCcz<+<{oCJD^SBV?C+wKkzo~gx{j%<{rGQpvIe@JKz8yRUKF7zQ zDdofuZyFT#T>EcrisMG@inxU0FND1ho}q_3XsW9$6<6_`etyb_P)2?}b-T{pEw)cnvi7-ccW674U^(XSTTxAPJ=*sz(_i7#$-_lb;?NhYTTobNgYvSI7kkEN(dKND@858jbbbr+1215dWDB`62ZxVN(Bl>gIVW0Uur2V zmLVZR@Zckc<-^zLQN9y{APBvke0h0s?m!Z%s=647Vtl)+8L%2!!#F%p582ZY#xNy) z8H$_*bo~NPIOHw{Pxgc;cuh7+!}FuR&_xA$ zyQfZ3JI4@zp`n-(j%i;17~<-B1m0?-3FB58Qd}3YDDOWvHKFMtTipu2wXWw=|I#Q0 zW2~4=FsNMYXVqUfi?j=^tvZ{r>fE=hQPsdks#36b6^Um9VNPHe`1pt|eh)kpu2%pP zmC(PO8h6mu#RX#!mG}-GJgENZ0f}TxPg35&`)Igmx(>a4JN$OI zek6#~)vH_oF2z`YwR39z_C$1qsY%R{<9Q41BNLu6AWy{I69Dd~f4HyuwC42M8`B3b zJ(+zJ|ihH&^&UP2KXY2R=o=K1-xH_FKR&57$jH+fGzLJz{# zZuH=nxwDbn-rwIu^0a{Ul(I^1Wn5rISzW!q%z=TI(2N4=0=&?u_L+QJ6E-ZoQB$1|DH42;WKG? zBPn`2@zZa4#U-b{sH}O(8G%`QaAuo`66)>^u{9@C?%+ZO>TJC1MV8;tc`3d;b~O%7 zh)&Q}`&_UKeNuSV?c4Bl^~sJ%1Pzfi_%Q7UvI-eHywDpg`olp5C#A8Z?+l&9m*z8b z9?M}m)>YPJGo>SY#a%`lOPENWr&39u3ahKDC71dXOfj`G&;|!yLeHuML_qmMarwgc zma=I|gIB?5SJ02CBmqw|a9`1qlijQV#Xa&Uo~z#O9t{yYFBM=ejs4T|9FldI)%p1x)^;^PYXjx;*OJH~$bGU__U_ z$FA>db%FO22PxrDh03H4>qe# z#tJ5^KIu)&4G}FH3(R=-Dt)vbmj$P!=cMjkqmwb)5Vbcl4XqhaKmoh~f*L^Es9F5!Nz z4hxWnGg#{I@Y*HjhKfg0lv>(?mi77Nr4nw>b*HODSLPK-?}Dk)jKCPgAio1&b>pc1qd;WK4lcn#IA$iXmh6KRbh zR2huN81WV5;l*rN*Ib4PQnuI@7%xa~@x)0dkDXqNX^qk-WJ#Skc%*%Mp5l9Vx+g10 zd)Q#ECMeIF?KC7eU&U2FnV%a~`rm9{OL7Y&#?#%nj{QyA`hY06|I>G4L)`rD7ysMZ z{{MG1c-FbCCmkcq@|a(BWUmdPl%eyw8!g+Ou716ZX!0*85ZS8`A!rORwx(E$w1*BG zM0&J2+iMElQ!*bqX5WiGd)eiG^9L*G!TPVMB*j`}^DsWG;2(>93f?CWGhdILA-)XL zJ31wDqX6kWAhWN(?j1je({4v$m6-f=W=*LW>BAOZuT`ItJS6{&5ig#&xt;~|-}f7X z3MYY+z2P`-vQ<%OdOb}0`gsKsf}S=MAkc81#~$uK^Z5-7OMRktr1?mk`HLVW7;P|?@%Pr zh=MxMz5aMFF_=Y1Ah@9yetV)v4B&MTOd)a}G2;VWLC~IRYH9++pXcWbi?N- z!65!>1d`X457L}+kFPhYY3RXqg$lTTvahbL4l_JtKfr|#+3eh-nO9@)cjv8a0i|~w zkvN213sVQsX!Fgs+k2_SyY79so)cv=;(bNK>XRCK=GUI&gv({R#$Z`+uYqkPL!aDR zEz@Xnom3k#fR&Zi54SpE$fd-Ht3nDn@To%Ozj*<03)o~CSbT}`k8G6&Jt)MNCR3!A zM7<|Oqt#`>_6Q5Zq|z%j`IK&`y|h@Tc>PH(6Rb!DZar^|BV0Fl3nFkfgg8)2oHDs! zZa@$=LC{yCIc;FCYLt82zLfskxpI)`f2$tjp$pv9QLfSAa+2;aMJC^CCI< zpxDDfj8b8NMc_mX*o5qIf!DU@km2=_SPSlZ!S3QCY}a^>DT#PwXr5}xQUpz=pr+QW zQ8rE#FfceAb~F>D`O^S)!DeP($scIEh9;u}+c_V38r z>uq+wnMgxRL~g`n)z4#iRoPpENwkQ&;Mk^g!=c~~KSNUaAhvqh!jBQ6da2`q&OJUp z4&AfOsnv?YT}d!$221@Kdt;XeFzC{Ix!3#i7}Npl;-Lcc7nWb|uq%SqWP{^`7+wYH*zYgj;lj zyV6uNZ)$)j)jCkLQ0lThsJtoBPTDB~j-O>XiCTkFH{#JNhOL}5!cIDcq^N%dMjHxW z1-t{O?Aokv(;RxaPv>dlb1r`utcylZBbHuWO)aS#>~&IxmLLEF;X7&?gQ$Jtm&((* ziC)v)ko=}Z%je|##5VpP?R|SV)oI)IPdze2Wu{0I+LBN-rI?jnnl?*??6a*Vp^bg^ zyQYjr#kA}a5>m>pY?94FCJ}`QDJ+}BvJDGMcHepTeBb-M@9`erpYL%z&v)?0935j> z>$mRT^}Fu-I?wYuFZCaAK5laV@|+I_1N9?bZDV(WMX#?_p+)O^^u`BfCVe@m@10hG zC6g=Sb`G3z9XQ?xP^kK)k(p!JkCCxLLsKxzGU!~p=m)Yb@5*F{!7rN}-eT>t(BXZ) z5XfkoXTKtoeYDvfQ5YbI(^+xVOFY{uorhz6CYqbMS05-ZS-&2cdp;MrX&~DKO^vTt zJo5A`fx^<>8x5%J#n@%Wt-0`Gz}dE{JD7VGXy@78a#kyr`0F4Gm8@&Q2da3TI((SKTCgu4zhLcIMduwr zwFj8|0{3L`wc*{POIy3+^r4dz)R+7ds7D~Rz~LS~&l2Jxu)#V%@=i|sGdu|RT~deKoUPcg6pR_Y815p$Pge`$HkAj|fId!C#}*_?8q7pvIHBoUvn)vZ`Pizu@_g0Pu}6 zvd}t@7T4kmQ6v|i83}r(v@{ibhddsk8v;Tl4k|zjtTtP(cC;Kt1yFj2n>YM8iR*mE zqFDpk1`v_Cek;8Ki@&B6S3WucL!JOkV={r)@2)1Z(AL7fM=3NponLxloHIwZ<+jwZ zUtI%jIYmk$bKhHPGKCXWfJNM24DxKbsl%hxM#`BzI*$?(I)4|C>uszT$Pk!9VUfyx z32J?oB%chE92u2+kKj;)M-6DYTW07XR3PDMsJ3=e03zd`Dv1aW(Qew-_ANIP3vH5k8%?zTh}z} z8}+uf<8P@~whv+>Zor~>0yWZZAkx{{IKK5?t>L|U)$HbRcZxq}y7aZ({mWYOZ+gbC z@v@(#sbdoYwcyI;TpBM~Ea;5{tX2NM_wU-Y2e87wgXL)c8cGuH7q>~suJ8BLtCh!I zuhdfN0N$-VoRORiI{7su6d%Opc7b&q_MI#MU^BpBqW>)eIcL~!emcM{SX6N+Oi!yB zeRU_ZaVJr}`Az5g_4o11plze5JHnKI7?$T^kU+Xxy&5RXI#;z7?&`01)|{WDLEv84 zn{bUkLoJjxTsY>7dwlV0BZarY&ITeJ8xQ__{x4D_nbEcWtHEtle%DiX`)ZQup=DN=iyX0cV~Rg8-bzZrE&)Bth4;u$T*(=Z28ug@Dl? z?;0Bk`#mVrjUt=dR6IAnvB{$lYWR=hCmKPGrt6|So&g;2IK}7?ywFnXH@!$T;!)Dn z;B9|~)6dfZlN-pcgIa$XBp{%aL|Gm^vFxAq2eComq5O(*f3v9S8%MUjBs>ABK%o#K zIk}9Lf{HLSZsrpv$bE_Qn~KEQ(RQ*I`-tk7OM)b)S$?gHl1kS|vY^^od^aE2VqtqZBC5*YTEA63tL>w$E?M+ZSvL zayfA+ghUeOqMc}k!P*WLRYMvMDU+_HWijv_O!DjN>%;MpS+P~qXpf$rUKW%m=r#sw zfnT7@)@>4|n6`Fy79ijVt!!a&r>Nq4shHYU5gsL9v5{K&_(eK0C`*}TIB8SuKVvo`{;BkF8jkuV&@)mXs0{oq>t}w@~*(^p`3A@E-`k>J#Ep#^hpZGcoGPHmK#5{7Z)EE*!?KoXQ$~R6RJ_Voq6wJVj?zGC17Q9sL4{7nkE|S)Akz;y4+|9UmR9EQ` z&PzEtIg)J$I&S^}SXz%?A%;R*{uG8Wubz zbNrIfoB=%<$CFU6!LrZJvC=U{?6Y0JCR1VQtuHEm9JZgdStZ4OQ|b}UIdd{13|n}W z31fMRWu{1z40WY$DCTGS^N>8(ELm~;r(W2~YpXu+(mP_=&m1fyr+!Pg_m&Z?$JPr9 z)ZBb7UUlA`(pf+4*r-*|%y4zoHI@$0mfpGwf3#0rN>{V^L6@6ngzWJ+p~hegjBxj1 zua==dFMGi+o7A;N7_~en)GW|o-2AA|y*#HEc|tfhoF0^sWIvvhD0pmK5H1t&8{SHQ zrc#^I`tykZXX$`YnSidX$7r#KpU`;yxwPsVr!Twa)!Ee!Y2k&@w6*;P?tTySWSh5Y zc9e#1Dtgwvg?bU0bFgm1VOm>UcJ!qY#b5mGY$tP?M;uaJFT2jWUd-xx$sYCO^WphI z3*QzttdzlUiIqi>=y|-nnXxhYqNlR4j8}H6q#?gzZ-F)s#Z2JxoDiSiRv2{)p>-+! zj6Q=QWxz<6GN#Sua-$ciD+~r}%Sr0f=4AD${4|cCG}Aa}<|8}W)$>7sEXB!k?P0K8 zqO~ILicMMKqRmzA=+iajRvz@`ad^E2f7d2W{;o^W+D(v>?XFI@SVdV_=JSW&bAQ5p z#aFvH>Bu9Aq>EQIdD`C_o<8YQH(nToNB7S=M<2O}g=Jbju;-&PM9P{PrO1~s8rkU> z-X>GVCvN!@W#LAP)u#s?xQ5rqufRno%C2P9QDUO`tunsus}0SUjMmcg`0wAlxdysG&^hM{S)0dX}UAg=BYTZif70>~F#LEf-S^7(-&c-Lh-^f1{V5V)vbRj35+aY84+kY#**=`8;wbRsMLR z55xF~VC^+c+wkB?PGy1+bu}YJQg;7mWG40D-s34@bH7qbXT(NUlfUL9)P*LNmwSpk z?zEm=bk{FzG&ChFPSbo_j))A!e3&BNV0Sc{du9oirt;Ob0Dg7(emmc=ITf?ch|LC6 zFCTMS?-e$**?8-!PM-~n9SWs)=XCaLRJYtRH)vAnmN9Hi1*;@wm;fgA>AlK&Qgnr9 zeb?_H)a-*?tsY*g{86>`O!vV}>T11>k!kj4kV7QJsn6vb)Qd;L1t>|9-`lvY~Zq1{zqp1 z@7V0ZZug&&>V?%J}AIiqM5dv$$!ltc7&A;oi4v)Kv?YVOW9e;?{jR+f={R|9KcWr`c@LcgH)X?16 zVQ(pMzlp3k752SQPv?0mSWtua?ydRh;`xsN85j}<1_r~PO$C}Bnt<|23;hVh8UT#m z22P8V5Yz=Q-n}dHzoKNw2ct7K<;{@5$ZXr2Q9(eG39JKJhR%Ta`8B-jb0F z6Tm!3#W<9LU)WJwu=s~C9iA;`&>92jJ*LFG)6?EIB8?J=biUA3yTkOpo~?1zi(hN&_Zw!tql>tuOPc|r>S;b-|1;@z3)BPV<=J?5>X1ab?USn9z7dz!b8(YeVAcn*5*+s3ThlWJJQ{D23K=Z= ztpTa^zD-1rp&Ie<(7S&+X{Jd?d%|w%(*OIFV733&}3~x?=?hdkKejc> z+JC66{W3f3rxvTQ8udD!C>pSKXo@$}>)yWyNA;4R&ags3<0kqCMv{*uLiqGPt*Peo z4~Re9AGF7rb8TE(j6BC*4wT{f^1~n*kyWLDK>-2W3f{AJzVTYz#%)n}uQvz?ti+VJ zs!!~u4}jahWMpVF?g2a`x}XmG zFTfk@J?o><`|U9HMiGo!6`e8jNlFdUAJv@zE$S?b9il4Xg`mG$2HA)$KLlTM)xqp~ zfGb#{!0JzdWi+acFprH>G%z(S zz)TJo?`EsWi!{o zMcBid(qc$}iO2ZAqvc7P_hTf{Om@8%o*Pq!1is2GIIvIXxTbdyIJ+63@tNwFsoH&l zl5~i*^j6CtJ8SbHD#aCSbXYz?VEQoeF(ApW>|*V9dUL;kvm{hDodLY5qj#UA>C&CZXoShk zX$BedGyD!Pd;=~U@EcDxC4?{@;yN9=QG<2nE-5@6RYNi0kk`nd4Y?E|_pE7hVJ@|f zmo$fcg9sB7CfJ}gF@FhQ7IIucg=*Le8AvjOI%HSewX`Z`SKm=7sL1or>i!9$0^180 zR4NsT<3as%keYR6+s$A3PPIXo$+mv3X;q~UBc0`9N`D2z$s}f*xBU4KY=!suRG~GM zi@y9J*WMDDrogB_QTuJy%Ac)M=4)vEsSxdP+J#r3df!4aAmchVFC!+Y0T8QH&HRcjrXpVX?m#dKMm zczq7|jP%_@HQH~(OLc5>Yl}@YLw%x-@oXu*&Rs6b~CO6_6K$uX%j@26GEFM4M}%lO){<1UOMP;F*Q)!^XyDHQ3FkjH0gR=Cf1M z`MU3g59);=M8Y(HzQmO#mR74wlEyL4SoEs3UX2e+mj%fi59>d5O3cgiKq~~h`xHa2 zT1>~#a5v67Bv;WH;5Q76jI2}o_81=Z^9@QD8rqlt<-RpYR~>#JV1A$rAj7Cvdr6Yc z>`EDI6&a5nX-1wU{1gbDt(hapo0*P(u2cZ%u7zA*RYJ`g6=BzT@XX}Lbdvayg(ccFp=1^jQTm9*XayscQ%NV z=0K7P$$>~bcYxd#Gr7oZcgU&1b4JY)$~^oUKjmu|uP3;?!R~9&^hFFLh{fXJp>>|R zzQU^MPZl1jn~E1~Y}~({`9NGpr%eq-ocDNzkgc~3gljp{c47SuP&Rcl$qygCT9xcl z_`Bx3Ur_pySlX_Sf#-{o6XBkQ4R(=O9I{L?4cl2(aUv1F*xT6jx@2K3S{3xRzUq#%LCJ+*8^EFRB&(Hsr zPVOC?YBrg2GT{Vu{@BrY997!D2Q#OOsPYZCl^IYRifl6p3&ta858Vd*x1WAIgXXN< z7N;us=AgU2LAH3*P+z|gx)7~$q}+n8;5{&6GJWVN&k(l356x$ z|Mv_RW^u#TlsHzO^7NnE4fYBbb^lrg_~mqXyQ2Nsx%0)Ju$QvMSS>7Vgz*nICApobM^z@x7!qX0Go)##H*h9wZD}b)+Q7yjLH}d9 zxXGvN6U3qv4lvM8fgdAEG7Dw-pGuw(1T@!7E*EkS$oTP^S+)w=uCz1WTG-%*8bN{f z){KW43$4ltunbUOA&27>JJmYu+vFHcJ{sW%PqM$QKwrF>?79 zT@g`fdHFcpY&fdWRJsh^gynWfck@Dd6BD3mB%r@zc0xJ`X?&*Qpz0{j>#F!mcHS*L z9CBdXqlWhm%4qlNH)VMu#NmK~QV_>5)m8)0EN1gDVk`aAG3u5WE@1b70++Bbkl`N; z7He*bT!agPegP#JVhX**zAAI(ekE$;m;!1NR1Z!7NKsnKBQI9S?`GB3IzS%_r2OH_4V&~Sd1fy zV!B7JKl$Ua`fmy)2g=0t-4ycAnsL=^I{70Xm!Qs5T>X|2zjYog{L_D79TtLMAqc32 zAXo^3g&?37f?y#C7J`6U2!e$mSO@}iO)pdj|34oLA@%$h`8n~u@ERtiV3B9m2I>>q z2z08z*f_C?hf+^|+&4ZsIgI{peHnBL>C1$pP3?L}h@z*o&GG<^cv#HhCowS*cG-P3 zJ)b{6YUqO%*}%a0G{bZ4e^3hY@uhtYO-+hQO2%@L8M7TAF)-tVl^lLoT8Ka3)DvTw zZ~*67VC3P9&;}?QHk$ye0W@RS91LPHS|cAgHyI$)T^X*KlwjPxxw3n-q@<)XXO^~F zRMZO!h1m7$<8)5$l8LN>V;$NR6=#|vEq(K7gYrDCrjl5V%^df9F;Pu%WHFjU$2hcj3LX@4LoN9vD85c5wF>{f#+K zpAvQjOE5z`chF812R8?kG`adawJv?GowBFpT4?d5k7_@D1e@LFx2K=C1)UrHAj|G} ze2zNx#Jzl79ZWnxJwUg_CaM-#l@NzthkfnKG0V@>cj>Du{IX^b^+{toyujBmzf&Og zc8<`Yh>MH6%^xc}HG+=kZQ+{0%JZhR7Y`kn4T*Vv;YXR&)7Nqnxgo2EjDeFA!Ha%) zW^!WUZzBblK{AzJL{H}pos(9e%y4mVKDAF2xwN?vuG=hDadx=VcqkmGfBweme)1Kj z><%SAu6s9S%XzGu0~aZ@&7$|!VpQ|^J4^zG;)58wq2p;>Y&$haPkx+WHhtTpJy@7O z0QVT=9_vl(*!)A|twU%mVlQxw0ai_#gyx9cu9aPXyP1yyQhC zIam0Prs~TKHfQxqS0Q7kBwaSq7hSlJR`U#+D@5cBx~3rjH#?kM>kVoN#+O~GB3lDB;vYjMz_-@@ z?wx(>yuUohRLhUYAzUXu%KeS<)?8Q|qNU8`;>8AM8w5FMHuUC;h!{}MG?Qx; zls#AX-Ob8!25~bUN330-jAJux{5#G zYYp%E2%QZdh|OXzdbez-c#UlMI3LKMQVypXMn^~EjiRD{s@c4x-9#h629DC3Hf_4m zw#e}2)YKGWJ!{UHSPu5|^s9;Z8{;xyPyQKhtuQ5S>fL|B;8tub6W+(OJs8=lpVIK{ z^VljMdnoE3O!}{T^>sFwrx{LT!XLC$Zt#D`5zLu;~BA1qlPTUg+IE_r36KdXg^j|HjBZU$pv@ z*XpGi5-kd7<^EsDFbddFI_^}VnpVgtM46;bpa%{;q3sZkVQ$_H3qHfUnn^oow0z9v zi*pnZ5I_P&Zd2`|Y*uLm+{d>e^SaSN2g@V!?r$)k4`X+rFPt2V4?Yf}| z!A85p>aF2$b0lt$Fm$U@>BWMOu@qI()J&+(Nl9bxD{TjCqicY8xV(*U?-m z=-Z!(Vl#k4ZDZZjO4yK9R8)ZX?4S6@Y3lYduS605?o94?T~PaR{<&4X?N?vvg*i8} z;gzIUe(S2?(L@Xm2@DJ*77UWdm--8*ro9y?8GU4!!XjP@mVQi7|4w<{vVNVdTODFe z(p*7OqA9)w8`3wa95Xa!1lNEdCA6Wq_{C0v^uFO?`**z<|I0yYKL5^ztE~}Wa1sgs z^yyDf-CzREiHG|6Q}P1B+y}7CMCb1~M)2Yt-SW(ekni~eO4H^CVSWmup$Wt(@fJ@i z$yu{n21(ZToy{|0yx%889i9s;AsU5|i{~fKY4FpaO1DMYUD^@aY5zj4*wm!D` zW^H_uCUS3e3S&X!fzl5}k$gCMLoTtD*6kY>q{N?Y3~Obd&NiMe&_*?-=g;XB)9 zCGD^eez_UUi4P#U^=jgwmBKbs;63G}5?DI8LMBx=K%)a+cP(b=4!sOVO2AgQ`>d>u z+8oLW7!Z)XLD$K$WMxPyISi^f0iT~j$Jvu$HFyKvF#ad5&GxWT+B6SeE04h zJj2S%%SD+YR7zFnQA;M1S>JO*E70u**TAsTHGi?4kY|=JJ1$ fqZmcycFjLuS>X2UP3&%RP4xX*zou)P`2BwYo@4g7 diff --git a/Samples/Legacy/src/main/res/menu/menu_main.xml b/Samples/Legacy/src/main/res/menu/menu_main.xml index e998a13f..09d4728d 100644 --- a/Samples/Legacy/src/main/res/menu/menu_main.xml +++ b/Samples/Legacy/src/main/res/menu/menu_main.xml @@ -18,9 +18,9 @@ android:title="@string/action_counter" app:showAsAction="never" /> 72sp 16sp 12dp + 16dp + 32dp diff --git a/Samples/Legacy/src/main/res/values/strings.xml b/Samples/Legacy/src/main/res/values/strings.xml index 884f59a1..f8922aa0 100644 --- a/Samples/Legacy/src/main/res/values/strings.xml +++ b/Samples/Legacy/src/main/res/values/strings.xml @@ -20,5 +20,10 @@ Decrement Accessibility sample Edit %s + Espresso sample Save + Hello Espresso! + Change text + type something… + Open activity and change text From a633cc5bb43f7cf2ad4a233881e35567031d30a6 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sun, 15 Oct 2023 11:19:01 -0400 Subject: [PATCH 4/5] Add Jacoco code coverage 4,025 of 8,875 = 54% --- Library/build.gradle | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Library/build.gradle b/Library/build.gradle index cb823852..7924390b 100644 --- a/Library/build.gradle +++ b/Library/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'kotlin-android' id 'org.jetbrains.dokka' + id 'jacoco' } ext { @@ -22,6 +23,8 @@ version = "$project.versions.testify" group = pom.publishedGroupId archivesBaseName = pom.artifact +jacoco { toolVersion = "0.8.10" } + android { compileSdkVersion coreVersions.compileSdk @@ -110,3 +113,30 @@ afterEvaluate { } apply from: '../ktlint.gradle' + +tasks.withType(Test) { + jacoco.includeNoLocationClasses = true + jacoco.excludes = ['jdk.internal.*'] +} + +project.afterEvaluate { + android.buildTypes.each { buildType -> + def testTaskName = "test${buildType.name.capitalize()}UnitTest" + task "${testTaskName}Coverage"(type: JacocoReport, dependsOn: ["$testTaskName"]) { + group = "Reporting" + description = "Generate Jacoco coverage reports for the $testTaskName" + reports { + html.required = true + xml.required = true + } + def excludes = [ + '**/dev/testify/core/processor/capture/*', + ] + def javaClasses = fileTree(dir: "${buildDir}/intermediates/javac/debug/classes", excludes: excludes) + def kotlinClasses = fileTree(dir: "${buildDir}/tmp/kotlin-classes/debug", excludes: excludes) + classDirectories.setFrom(files([javaClasses, kotlinClasses])) + sourceDirectories.setFrom(files(["$project.projectDir/src/main/java"])) + executionData(files("${project.buildDir}/jacoco/${testTaskName}.exec")) + } + } +} \ No newline at end of file From 5cd78c64deee35ca566ce8d44cd287c14eadc4f1 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sun, 22 Oct 2023 22:12:33 -0400 Subject: [PATCH 5/5] Add more tests to increase coverage 465 of 6,988 = 93% --- Library/build.gradle | 10 + .../java/dev/testify/ActivityLaunchCycle.kt | 41 ++ .../main/java/dev/testify/ScreenshotRule.kt | 24 +- .../dev/testify/core/AssertExpectedDevice.kt | 2 + .../dev/testify/core/ConfigurationBuilder.kt | 18 +- .../java/dev/testify/core/DeviceIdentifier.kt | 3 +- .../dev/testify/core/TestifyConfiguration.kt | 7 +- .../java/dev/testify/core/logic/AssertSame.kt | 20 +- .../core/processor/BitmapExtentions.kt | 8 +- .../core/processor/ParallelPixelProcessor.kt | 2 +- .../core/processor/diff/HighContrastDiff.kt | 20 +- .../ExcludeFromJacocoGeneratedReport.kt | 44 ++ .../InstrumentationRegistryExtensions.kt | 4 +- .../internal/extensions/LocaleExtensions.kt | 14 +- .../testify/internal/helpers/BuildVersion.kt | 8 + .../internal/helpers/EspressoHelper.kt | 18 +- .../testify/internal/helpers/FindRootView.kt | 2 + .../internal/helpers/IsRunningOnUiThread.kt | 2 + .../internal/helpers/ManifestHelpers.kt | 7 +- .../internal/helpers/OrientationHelper.kt | 52 ++- .../internal/helpers/ResourceWrapper.kt | 12 +- .../internal/helpers/WrappedFontScale.kt | 12 +- .../java/dev/testify/report/ReportSession.kt | 3 + .../main/java/dev/testify/report/Reporter.kt | 14 +- .../java/dev/testify/ScreenshotRuleTest.kt | 428 +++++++++++++++++- .../java/dev/testify/TestDescriptionTest.kt | 50 ++ .../testify/core/ConfigurationBuilderTest.kt | 192 ++++++++ .../dev/testify/core/DeviceIdentifierTest.kt | 4 - .../ScreenshotRuleCompatibilityMethodsTest.kt | 169 +++++++ .../testify/core/TestifyConfigurationTest.kt | 236 ++++++++++ .../testify/core/exception/ErrorCauseTest.kt | 1 + .../core/processor/BitmapTestHelpers.kt | 91 ++++ .../processor/ParallelPixelProcessorTest.kt | 27 +- .../processor/compare/FuzzyCompareTest.kt | 130 ++++++ .../processor/compare/RegionCompareTest.kt | 14 +- .../processor/diff/HighContrastDiffTest.kt | 238 ++++++++++ .../internal/helpers/ActivityProviderTest.kt | 60 +++ .../internal/helpers/OrientationHelperTest.kt | 128 ++++++ .../internal/helpers/ResourceWrapperTest.kt | 123 +++++ .../internal/helpers/WrappedFontScaleTest.kt | 101 +++++ .../internal/helpers/WrappedLocaleTest.kt | 95 ++++ .../internal/helpers/WrappedResourceTest.kt | 53 +++ .../StaticPasswordTransformationMethodTest.kt | 39 ++ .../java/dev/testify/report/ReporterTest.kt | 68 +-- .../TestingResourcesCounterExampleTest.kt | 3 +- 45 files changed, 2434 insertions(+), 163 deletions(-) create mode 100644 Library/src/main/java/dev/testify/ActivityLaunchCycle.kt create mode 100644 Library/src/main/java/dev/testify/internal/annotation/ExcludeFromJacocoGeneratedReport.kt create mode 100644 Library/src/main/java/dev/testify/internal/helpers/BuildVersion.kt create mode 100644 Library/src/test/java/dev/testify/TestDescriptionTest.kt create mode 100644 Library/src/test/java/dev/testify/core/ConfigurationBuilderTest.kt create mode 100644 Library/src/test/java/dev/testify/core/ScreenshotRuleCompatibilityMethodsTest.kt create mode 100644 Library/src/test/java/dev/testify/core/TestifyConfigurationTest.kt create mode 100644 Library/src/test/java/dev/testify/core/processor/BitmapTestHelpers.kt create mode 100644 Library/src/test/java/dev/testify/core/processor/compare/FuzzyCompareTest.kt create mode 100644 Library/src/test/java/dev/testify/core/processor/diff/HighContrastDiffTest.kt create mode 100644 Library/src/test/java/dev/testify/internal/helpers/ActivityProviderTest.kt create mode 100644 Library/src/test/java/dev/testify/internal/helpers/OrientationHelperTest.kt create mode 100644 Library/src/test/java/dev/testify/internal/helpers/ResourceWrapperTest.kt create mode 100644 Library/src/test/java/dev/testify/internal/helpers/WrappedFontScaleTest.kt create mode 100644 Library/src/test/java/dev/testify/internal/helpers/WrappedLocaleTest.kt create mode 100644 Library/src/test/java/dev/testify/internal/helpers/WrappedResourceTest.kt create mode 100644 Library/src/test/java/dev/testify/internal/modification/StaticPasswordTransformationMethodTest.kt diff --git a/Library/build.gradle b/Library/build.gradle index 7924390b..f7e71036 100644 --- a/Library/build.gradle +++ b/Library/build.gradle @@ -88,6 +88,7 @@ android { abortOnError true textOutput file('stdout') textReport true + htmlReport true warningsAsErrors true xmlReport false } @@ -131,6 +132,15 @@ project.afterEvaluate { } def excludes = [ '**/dev/testify/core/processor/capture/*', + '**/dev/testify/extensions/ViewExtensionsKt*', + '**/dev/testify/internal/extensions/LocaleExtensionsKt*', + '**/dev/testify/internal/helpers/AssetLoaderKt*', + '**/dev/testify/internal/helpers/OrientationHelperKt*', + '**/dev/testify/internal/helpers/WrappedFontScaleKt*', + '**/dev/testify/core/processor/compare/SameAsCompare*', + '**/dev/testify/output/*', + '**/dev/testify/resources/TestifyResourcesOverride*', + '**/dev/testify/ScreenshotUtilityKt*', ] def javaClasses = fileTree(dir: "${buildDir}/intermediates/javac/debug/classes", excludes: excludes) def kotlinClasses = fileTree(dir: "${buildDir}/tmp/kotlin-classes/debug", excludes: excludes) diff --git a/Library/src/main/java/dev/testify/ActivityLaunchCycle.kt b/Library/src/main/java/dev/testify/ActivityLaunchCycle.kt new file mode 100644 index 00000000..eb01b602 --- /dev/null +++ b/Library/src/main/java/dev/testify/ActivityLaunchCycle.kt @@ -0,0 +1,41 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify + +interface ActivityLaunchCycle { + + /** + * Invoked before the Activity under test has been launched. + * Invoked after @Before methods. + * Invoked after beforeAssertSame() + */ + fun beforeActivityLaunched() + + /** + * Invoked after the Activity under test has been launched. + * Invoked after @Before methods. + * Invoked after beforeAssertSame() + */ + fun afterActivityLaunched() +} diff --git a/Library/src/main/java/dev/testify/ScreenshotRule.kt b/Library/src/main/java/dev/testify/ScreenshotRule.kt index 11a1f0bc..3a2fcce9 100644 --- a/Library/src/main/java/dev/testify/ScreenshotRule.kt +++ b/Library/src/main/java/dev/testify/ScreenshotRule.kt @@ -52,6 +52,7 @@ import dev.testify.core.logic.AssertionState import dev.testify.core.logic.ScreenshotLifecycleHost import dev.testify.core.logic.ScreenshotLifecycleObserver import dev.testify.core.logic.assertSame +import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport import dev.testify.internal.extensions.isInvokedFromPlugin import dev.testify.internal.helpers.ActivityProvider import dev.testify.internal.helpers.EspressoActions @@ -76,10 +77,12 @@ open class ScreenshotRule @JvmOverloads constructor( TestRule, ActivityProvider, ScreenshotLifecycle, + ActivityLaunchCycle, AssertionState, ScreenshotLifecycleHost by ScreenshotLifecycleObserver(), CompatibilityMethods, T> by ScreenshotRuleCompatibilityMethods() { + @ExcludeFromJacocoGeneratedReport @Deprecated( message = "Parameter launchActivity is deprecated and no longer required", replaceWith = ReplaceWith("ScreenshotRule(activityClass = activityClass, rootViewId = rootViewId, initialTouchMode = initialTouchMode, enableReporter = enableReporter, configuration = TestifyConfiguration())") // ktlint-disable max-line-length @@ -106,7 +109,7 @@ open class ScreenshotRule @JvmOverloads constructor( override var screenshotViewProvider: ViewProvider? = null override var throwable: Throwable? = null override var viewModification: ViewModification? = null - private var extrasProvider: ExtrasProvider? = null + @VisibleForTesting internal var extrasProvider: ExtrasProvider? = null @VisibleForTesting internal var reporter: Reporter? = null @@ -281,21 +284,26 @@ open class ScreenshotRule @JvmOverloads constructor( return this } - public final override fun getActivityIntent(): Intent { + public final override fun getActivityIntent(): Intent? { var intent: Intent? = super.getActivityIntent() if (intent == null) { intent = getIntent() } extrasProvider?.let { - val bundle = Bundle() - it(bundle) + val bundle = invokeExtrasProvider() intent.extras?.putAll(bundle) ?: intent.replaceExtras(bundle) } return intent } + @ExcludeFromJacocoGeneratedReport + @VisibleForTesting + internal fun invokeExtrasProvider(): Bundle = + Bundle().apply { extrasProvider?.invoke(this) } + + @ExcludeFromJacocoGeneratedReport @VisibleForTesting internal fun getIntent(): Intent { var intent = super.getActivityIntent() @@ -309,6 +317,7 @@ open class ScreenshotRule @JvmOverloads constructor( launchActivity(intent) } + @ExcludeFromJacocoGeneratedReport override fun launchActivity(startIntent: Intent?): T { try { return super.launchActivity(startIntent) @@ -389,11 +398,4 @@ open class ScreenshotRule @JvmOverloads constructor( reporter?.fail(throwable) throw throwable } - - @VisibleForTesting - var isDebugMode: Boolean = false - set(value) { - field = value - assertSameInvoked = value - } } diff --git a/Library/src/main/java/dev/testify/core/AssertExpectedDevice.kt b/Library/src/main/java/dev/testify/core/AssertExpectedDevice.kt index 10286b31..130d329c 100644 --- a/Library/src/main/java/dev/testify/core/AssertExpectedDevice.kt +++ b/Library/src/main/java/dev/testify/core/AssertExpectedDevice.kt @@ -26,6 +26,7 @@ package dev.testify.core import android.content.Context import android.content.res.AssetManager import dev.testify.core.exception.UnexpectedDeviceException +import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport import dev.testify.output.SCREENSHOT_DIR import java.io.File import dev.testify.internal.extensions.TestInstrumentationRegistry.Companion.isRecordMode as recordMode @@ -42,6 +43,7 @@ import dev.testify.internal.extensions.TestInstrumentationRegistry.Companion.isR * @param context - [Context] of the test instrumentation's package * @param testName - The name of the currently running test */ +@ExcludeFromJacocoGeneratedReport fun assertExpectedDevice(context: Context, testName: String, isRecordMode: Boolean) { if (isRecordMode || recordMode) return diff --git a/Library/src/main/java/dev/testify/core/ConfigurationBuilder.kt b/Library/src/main/java/dev/testify/core/ConfigurationBuilder.kt index b3459128..e8897d25 100644 --- a/Library/src/main/java/dev/testify/core/ConfigurationBuilder.kt +++ b/Library/src/main/java/dev/testify/core/ConfigurationBuilder.kt @@ -26,8 +26,11 @@ package dev.testify.core import android.app.Activity +import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE +import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT import android.view.View import androidx.annotation.IdRes +import androidx.annotation.VisibleForTesting import dev.testify.CaptureMethod import dev.testify.CompareMethod import dev.testify.ScreenshotRule @@ -101,6 +104,9 @@ class ConfigurationBuilder internal constructor(private val rule: } fun setOrientation(requestedOrientation: Int): ConfigurationBuilder { + require( + requestedOrientation in SCREEN_ORIENTATION_LANDSCAPE..SCREEN_ORIENTATION_PORTRAIT + ) innerConfiguration.orientation = requestedOrientation return this } @@ -132,7 +138,16 @@ class ConfigurationBuilder internal constructor(private val rule: return this } - private fun build(): TestifyConfiguration.() -> Unit = { + /** + * Record a new baseline when running the test + */ + fun setRecordModeEnabled(isRecordMode: Boolean): ConfigurationBuilder { + innerConfiguration.isRecordMode = isRecordMode + return this + } + + @VisibleForTesting + internal fun build(): TestifyConfiguration.() -> Unit = { this.exactness = innerConfiguration.exactness this.exclusionRectProvider = innerConfiguration.exclusionRectProvider this.exclusionRects.addAll(innerConfiguration.exclusionRects) @@ -149,6 +164,7 @@ class ConfigurationBuilder internal constructor(private val rule: this.useSoftwareRenderer = innerConfiguration.useSoftwareRenderer this.captureMethod = innerConfiguration.captureMethod this.compareMethod = innerConfiguration.compareMethod + this.isRecordMode = innerConfiguration.isRecordMode } fun assertSame() { diff --git a/Library/src/main/java/dev/testify/core/DeviceIdentifier.kt b/Library/src/main/java/dev/testify/core/DeviceIdentifier.kt index f96c8551..ec416beb 100644 --- a/Library/src/main/java/dev/testify/core/DeviceIdentifier.kt +++ b/Library/src/main/java/dev/testify/core/DeviceIdentifier.kt @@ -29,6 +29,7 @@ import android.content.Context import android.util.DisplayMetrics import android.view.WindowManager import dev.testify.internal.extensions.languageTag +import dev.testify.internal.helpers.buildVersionSdkInt import java.util.Locale typealias TestName = Pair @@ -94,7 +95,7 @@ open class DeviceStringFormatter(private val context: Context, private val testN get() = getDeviceDimensions(context) internal open val androidVersion: String - get() = android.os.Build.VERSION.SDK_INT.toString() + get() = buildVersionSdkInt().toString() internal open val deviceWidth: String get() = dimensions.first.toString() diff --git a/Library/src/main/java/dev/testify/core/TestifyConfiguration.kt b/Library/src/main/java/dev/testify/core/TestifyConfiguration.kt index ca173711..ca4bfe3a 100644 --- a/Library/src/main/java/dev/testify/core/TestifyConfiguration.kt +++ b/Library/src/main/java/dev/testify/core/TestifyConfiguration.kt @@ -33,6 +33,7 @@ import android.view.ViewGroup import androidx.annotation.FloatRange import androidx.annotation.IdRes import androidx.annotation.UiThread +import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import dev.testify.CaptureMethod import dev.testify.CompareMethod @@ -100,10 +101,12 @@ data class TestifyConfiguration( val hasExactness: Boolean get() = exactness != null - private val orientationHelper: OrientationHelper? + @VisibleForTesting + internal val orientationHelper: OrientationHelper? get() = orientation?.let { OrientationHelper(it) } - private var ignoreAnnotation: IgnoreScreenshot? = null + @VisibleForTesting + internal var ignoreAnnotation: IgnoreScreenshot? = null /** * Update the internal configuration values based on any annotations that may be present on the test method diff --git a/Library/src/main/java/dev/testify/core/logic/AssertSame.kt b/Library/src/main/java/dev/testify/core/logic/AssertSame.kt index 2630caa8..106f46dd 100644 --- a/Library/src/main/java/dev/testify/core/logic/AssertSame.kt +++ b/Library/src/main/java/dev/testify/core/logic/AssertSame.kt @@ -27,6 +27,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.view.View +import androidx.annotation.VisibleForTesting import androidx.test.platform.app.InstrumentationRegistry import dev.testify.TestifyFeatures import dev.testify.core.DEFAULT_FOLDER_FORMAT @@ -99,7 +100,6 @@ internal fun assertSame( } catch (e: ScreenshotTestIgnoredException) { // Exit gracefully; mark test as ignored Assume.assumeTrue(false) - return } var activity: TActivity? = null @@ -129,9 +129,8 @@ internal fun assertSame( screenshotLifecycleHost.notifyObservers { it.afterScreenshot(activity, currentBitmap) } - if (configuration.pauseForInspection) { - Thread.sleep(LAYOUT_INSPECTION_TIME_MS) - } + if (configuration.pauseForInspection) + pauseForInspection() val isRecordMode = isRecordMode() @@ -140,7 +139,7 @@ internal fun assertSame( val destination = getDestination(activity, outputFileName) val baselineBitmap = loadBaselineBitmapForComparison(testContext, description.name) - ?: if (isRecordMode()) { + ?: if (isRecordMode) { TestInstrumentationRegistry.instrumentationPrintln( "\n\t✓ " + "Recording baseline for ${description.name}".cyan() ) @@ -172,15 +171,16 @@ internal fun assertSame( throw FinalizeDestinationException(destination.description) if (TestifyFeatures.GenerateDiffs.isEnabled(activity)) { - HighContrastDiff(configuration.exclusionRects) + HighContrastDiff + .create(configuration.exclusionRects) // TODO: Test me .name(outputFileName) .baseline(baselineBitmap) .current(currentBitmap) .exactness(configuration.exactness) .generate(context = activity) } - if (TestInstrumentationRegistry.isRecordMode) { - TestInstrumentationRegistry.instrumentationPrintln( + if (isRecordMode) { + TestInstrumentationRegistry.instrumentationPrintln( // TODO: Test me "\n\t✓ " + "Recording baseline for ${description.name}".cyan() ) } else { @@ -202,3 +202,7 @@ internal fun assertSame( } private const val LAYOUT_INSPECTION_TIME_MS = 60000L + +@VisibleForTesting +internal fun pauseForInspection() = + Thread.sleep(LAYOUT_INSPECTION_TIME_MS) diff --git a/Library/src/main/java/dev/testify/core/processor/BitmapExtentions.kt b/Library/src/main/java/dev/testify/core/processor/BitmapExtentions.kt index b3345a1a..be513d7d 100644 --- a/Library/src/main/java/dev/testify/core/processor/BitmapExtentions.kt +++ b/Library/src/main/java/dev/testify/core/processor/BitmapExtentions.kt @@ -2,6 +2,7 @@ package dev.testify.core.processor import android.graphics.Bitmap import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.PACKAGE_PRIVATE import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher import java.util.concurrent.Executors @@ -17,11 +18,12 @@ fun ParallelPixelProcessor.TransformResult.createBitmap(): Bitmap { private val numberOfAvailableCores = Runtime.getRuntime().availableProcessors() -@VisibleForTesting -var maxNumberOfChunkThreads = numberOfAvailableCores +@VisibleForTesting(otherwise = PACKAGE_PRIVATE) +internal var maxNumberOfChunkThreads = numberOfAvailableCores +@Suppress("ObjectPropertyName") @VisibleForTesting -var _executorDispatcher: CoroutineDispatcher? = null +internal var _executorDispatcher: CoroutineDispatcher? = null val executorDispatcher by lazy { if (_executorDispatcher == null) { diff --git a/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt b/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt index d778534c..af1561a2 100644 --- a/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt +++ b/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt @@ -45,7 +45,7 @@ class ParallelPixelProcessor private constructor() { private fun getChunkData(width: Int, height: Int): ChunkData { val size = width * height - val chunkSize = size / maxNumberOfChunkThreads + val chunkSize = (size / maxNumberOfChunkThreads).coerceAtLeast(1) val chunks = ceil(size.toFloat() / chunkSize.toFloat()).toInt() return ChunkData(size, chunks, chunkSize) } diff --git a/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt b/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt index ae3c9753..dbb4f63d 100644 --- a/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt +++ b/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt @@ -47,13 +47,18 @@ import dev.testify.saveBitmapToDestination * - Red: Different in excess of allowable tolerances * */ -class HighContrastDiff(private val exclusionRects: Set) { +class HighContrastDiff private constructor(private val exclusionRects: Set) { - private lateinit var fileName: String - private lateinit var baselineBitmap: Bitmap - private lateinit var currentBitmap: Bitmap + private var fileName: String? = null + private var baselineBitmap: Bitmap? = null + private var currentBitmap: Bitmap? = null fun generate(context: Context) { + val fileName = this.fileName ?: throw IllegalArgumentException("call name() to set fileName") + val baselineBitmap = + this.baselineBitmap ?: throw IllegalArgumentException("call baseline() to set baselineBitmap") + val currentBitmap = this.currentBitmap ?: throw IllegalArgumentException("call current() to set currentBitmap") + val transformResult = ParallelPixelProcessor .create() .baseline(baselineBitmap) @@ -112,4 +117,11 @@ class HighContrastDiff(private val exclusionRects: Set) { this.currentBitmap = currentBitmap return this } + + companion object { + fun create(exclusionRects: Set) = + HighContrastDiff( + exclusionRects + ) + } } diff --git a/Library/src/main/java/dev/testify/internal/annotation/ExcludeFromJacocoGeneratedReport.kt b/Library/src/main/java/dev/testify/internal/annotation/ExcludeFromJacocoGeneratedReport.kt new file mode 100644 index 00000000..4ff30f7c --- /dev/null +++ b/Library/src/main/java/dev/testify/internal/annotation/ExcludeFromJacocoGeneratedReport.kt @@ -0,0 +1,44 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.internal.annotation + +/** + * Annotation for excluding code from the Jacoco coverage generator. + * + * Used to exclude elements from being considered for code coverage. + * Element annotated with [ExcludeFromJacocoGeneratedReport] are filtered out + * during generation of the report. + * + * Normally this is reserved for use by Android platform implementations that are + * normally mocked in tests and are otherwise untestable. + */ +@Retention(AnnotationRetention.RUNTIME) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.CONSTRUCTOR +) +annotation class ExcludeFromJacocoGeneratedReport diff --git a/Library/src/main/java/dev/testify/internal/extensions/InstrumentationRegistryExtensions.kt b/Library/src/main/java/dev/testify/internal/extensions/InstrumentationRegistryExtensions.kt index 5378cd7f..5b513aa4 100644 --- a/Library/src/main/java/dev/testify/internal/extensions/InstrumentationRegistryExtensions.kt +++ b/Library/src/main/java/dev/testify/internal/extensions/InstrumentationRegistryExtensions.kt @@ -26,6 +26,7 @@ package dev.testify.internal.extensions import android.app.Instrumentation import android.os.Bundle import androidx.test.platform.app.InstrumentationRegistry +import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport import dev.testify.internal.helpers.ManifestPlaceholder import dev.testify.internal.helpers.getMetaDataValue @@ -66,6 +67,7 @@ class TestInstrumentationRegistry { * @return Gradle module name if available. e.g. :Sample * Empty string otherwise. */ + @ExcludeFromJacocoGeneratedReport fun getModuleName(): String { val extras = InstrumentationRegistry.getArguments() val name = if (extras.containsKey("moduleName")) extras.getString("moduleName")!! + ":" else "" @@ -80,9 +82,7 @@ class TestInstrumentationRegistry { fun isInvokedFromPlugin(): Boolean = InstrumentationRegistry.getArguments().containsKey("annotation") -private const val ESC_YELLOW = "${27.toChar()}[33m" private const val ESC_CYAN = "${27.toChar()}[36m" private const val ESC_RESET = "${27.toChar()}[0m" -fun String.yellow() = "$ESC_YELLOW$this$ESC_RESET" fun String.cyan() = "$ESC_CYAN$this$ESC_RESET" diff --git a/Library/src/main/java/dev/testify/internal/extensions/LocaleExtensions.kt b/Library/src/main/java/dev/testify/internal/extensions/LocaleExtensions.kt index f976cb54..da297bdf 100644 --- a/Library/src/main/java/dev/testify/internal/extensions/LocaleExtensions.kt +++ b/Library/src/main/java/dev/testify/internal/extensions/LocaleExtensions.kt @@ -25,25 +25,29 @@ package dev.testify.internal.extensions +import android.annotation.SuppressLint import android.annotation.TargetApi import android.content.Context import android.content.res.Configuration import android.os.Build import android.os.LocaleList +import androidx.annotation.VisibleForTesting +import dev.testify.internal.helpers.buildVersionSdkInt import java.util.Locale internal fun Context.updateLocale(locale: Locale?): Context { if (locale == null) return this - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return if (buildVersionSdkInt() >= Build.VERSION_CODES.N) { this.updateResources(locale) } else { this.updateResourcesLegacy(locale) } } +@VisibleForTesting @TargetApi(Build.VERSION_CODES.N) -private fun Context.updateResources(locale: Locale): Context { +internal fun Context.updateResources(locale: Locale): Context { val configuration = Configuration(this.resources.configuration) val localeList = LocaleList(locale) LocaleList.setDefault(localeList) @@ -51,8 +55,9 @@ private fun Context.updateResources(locale: Locale): Context { return this.createConfigurationContext(configuration) } +@VisibleForTesting @Suppress("DEPRECATION") -private fun Context.updateResourcesLegacy(locale: Locale): Context { +internal fun Context.updateResourcesLegacy(locale: Locale): Context { Locale.setDefault(locale) val configuration = Configuration(this.resources.configuration) configuration.locale = locale @@ -61,8 +66,9 @@ private fun Context.updateResourcesLegacy(locale: Locale): Context { } internal val Locale.languageTag: String + @SuppressLint("NewApi") get() { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return if (buildVersionSdkInt() >= Build.VERSION_CODES.LOLLIPOP) { this.toLanguageTag().replace("-", "_") } else { "${this.language}_${this.country}" diff --git a/Library/src/main/java/dev/testify/internal/helpers/BuildVersion.kt b/Library/src/main/java/dev/testify/internal/helpers/BuildVersion.kt new file mode 100644 index 00000000..9a8721ed --- /dev/null +++ b/Library/src/main/java/dev/testify/internal/helpers/BuildVersion.kt @@ -0,0 +1,8 @@ +package dev.testify.internal.helpers + +import android.os.Build + +/** + * Helper to simplify mocking during tests + */ +fun buildVersionSdkInt() = Build.VERSION.SDK_INT diff --git a/Library/src/main/java/dev/testify/internal/helpers/EspressoHelper.kt b/Library/src/main/java/dev/testify/internal/helpers/EspressoHelper.kt index ed8334dc..eb052863 100644 --- a/Library/src/main/java/dev/testify/internal/helpers/EspressoHelper.kt +++ b/Library/src/main/java/dev/testify/internal/helpers/EspressoHelper.kt @@ -24,9 +24,11 @@ package dev.testify.internal.helpers import android.app.Activity +import androidx.annotation.VisibleForTesting import androidx.test.espresso.Espresso import dev.testify.ScreenshotLifecycle import dev.testify.core.TestifyConfiguration +import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport typealias EspressoActions = () -> Unit @@ -41,10 +43,18 @@ class EspressoHelper(private val configuration: TestifyConfiguration) : Screensh override fun afterInitializeView(activity: Activity) { actions?.invoke() - Espresso.onIdle() + syncUiThread() - if (configuration.hideSoftKeyboard) { - Espresso.closeSoftKeyboard() - } + if (configuration.hideSoftKeyboard) + closeSoftKeyboard() } + + @ExcludeFromJacocoGeneratedReport + @VisibleForTesting + internal fun syncUiThread() = + Espresso.onIdle() + + @VisibleForTesting + internal fun closeSoftKeyboard() = + Espresso.closeSoftKeyboard() } diff --git a/Library/src/main/java/dev/testify/internal/helpers/FindRootView.kt b/Library/src/main/java/dev/testify/internal/helpers/FindRootView.kt index 19f32f7c..1c01d461 100644 --- a/Library/src/main/java/dev/testify/internal/helpers/FindRootView.kt +++ b/Library/src/main/java/dev/testify/internal/helpers/FindRootView.kt @@ -4,6 +4,8 @@ import android.app.Activity import android.view.ViewGroup import androidx.annotation.IdRes import dev.testify.core.exception.RootViewNotFoundException +import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport +@ExcludeFromJacocoGeneratedReport fun Activity.findRootView(@IdRes rootViewId: Int): ViewGroup = this.findViewById(rootViewId) ?: throw RootViewNotFoundException(this, rootViewId) diff --git a/Library/src/main/java/dev/testify/internal/helpers/IsRunningOnUiThread.kt b/Library/src/main/java/dev/testify/internal/helpers/IsRunningOnUiThread.kt index 97f6a4df..45ae2af8 100644 --- a/Library/src/main/java/dev/testify/internal/helpers/IsRunningOnUiThread.kt +++ b/Library/src/main/java/dev/testify/internal/helpers/IsRunningOnUiThread.kt @@ -24,6 +24,8 @@ package dev.testify.internal.helpers import android.os.Looper +import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport +@ExcludeFromJacocoGeneratedReport fun isRunningOnUiThread(): Boolean = Looper.getMainLooper().thread == Thread.currentThread() diff --git a/Library/src/main/java/dev/testify/internal/helpers/ManifestHelpers.kt b/Library/src/main/java/dev/testify/internal/helpers/ManifestHelpers.kt index 95ca9c99..29c0655b 100644 --- a/Library/src/main/java/dev/testify/internal/helpers/ManifestHelpers.kt +++ b/Library/src/main/java/dev/testify/internal/helpers/ManifestHelpers.kt @@ -23,10 +23,12 @@ */ package dev.testify.internal.helpers +import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager import android.os.Bundle import androidx.test.platform.app.InstrumentationRegistry +import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport private const val MANIFEST_DESTINATION_KEY = "dev.testify.destination" private const val MANIFEST_MODULE_KEY = "dev.testify.module" @@ -38,8 +40,10 @@ sealed class ManifestPlaceholder(val key: String) { object RecordMode : ManifestPlaceholder(MANIFEST_IS_RECORD_MODE) } +@ExcludeFromJacocoGeneratedReport +@SuppressLint("NewApi") internal fun getMetaDataBundle(context: Context): Bundle? { - val applicationInfo = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + val applicationInfo = if (buildVersionSdkInt() >= android.os.Build.VERSION_CODES.TIRAMISU) { context.packageManager?.getApplicationInfo(context.packageName, PackageManager.ApplicationInfoFlags.of(0)) } else { @Suppress("DEPRECATION") @@ -48,6 +52,7 @@ internal fun getMetaDataBundle(context: Context): Bundle? { return applicationInfo?.metaData } +@ExcludeFromJacocoGeneratedReport fun ManifestPlaceholder.getMetaDataValue(): String? { val metaData = getMetaDataBundle(InstrumentationRegistry.getInstrumentation().context) return if (metaData?.containsKey(this.key) == true) diff --git a/Library/src/main/java/dev/testify/internal/helpers/OrientationHelper.kt b/Library/src/main/java/dev/testify/internal/helpers/OrientationHelper.kt index 2a62452c..a2848a46 100644 --- a/Library/src/main/java/dev/testify/internal/helpers/OrientationHelper.kt +++ b/Library/src/main/java/dev/testify/internal/helpers/OrientationHelper.kt @@ -27,36 +27,37 @@ package dev.testify.internal.helpers import android.app.Activity -import android.content.pm.ActivityInfo +import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE +import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT import android.graphics.Point +import androidx.annotation.VisibleForTesting import androidx.test.espresso.Espresso import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry import androidx.test.runner.lifecycle.Stage import dev.testify.core.exception.UnexpectedOrientationException +import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit class OrientationHelper( private var requestedOrientation: Int? ) { - var deviceOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED private lateinit var lifecycleLatch: CountDownLatch private lateinit var activityClass: Class<*> + init { + require( + requestedOrientation == null || + requestedOrientation in SCREEN_ORIENTATION_LANDSCAPE..SCREEN_ORIENTATION_PORTRAIT + ) + } + fun afterActivityLaunched() { // Set the orientation based on how the activity was launched - deviceOrientation = if (activity.isLandscape) - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - else - ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - - this.requestedOrientation?.let { - if (!activity.isRequestedOrientation(it)) { - activity.changeOrientation(it) - - // Re-capture the orientation based on user requested value - deviceOrientation = it + this.requestedOrientation?.let { requestedOrientation -> + if (!activity.isRequestedOrientation(requestedOrientation)) { + changeOrientation(activity, requestedOrientation) } } } @@ -84,7 +85,8 @@ class OrientationHelper( /** * Lifecycle callback. Wait for the activity under test to completely resume after configuration change. */ - private fun lifecycleCallback(activity: Activity, stage: Stage) { + @VisibleForTesting + internal fun lifecycleCallback(activity: Activity, stage: Stage) { if (activity::class.java == activityClass) { if (stage == Stage.RESUMED) { lifecycleLatch.countDown() @@ -92,17 +94,25 @@ class OrientationHelper( } } - private fun Activity.changeOrientation(requestedOrientation: Int) { - activityClass = this@changeOrientation.javaClass + @ExcludeFromJacocoGeneratedReport + @VisibleForTesting + internal fun syncUiThread() { + Espresso.onIdle() + } + + @ExcludeFromJacocoGeneratedReport + @VisibleForTesting + internal fun changeOrientation(activity: Activity, requestedOrientation: Int) { + activityClass = activity.javaClass ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(::lifecycleCallback) - Espresso.onIdle() + syncUiThread() val rotationLatch = CountDownLatch(1) lifecycleLatch = CountDownLatch(1) - this.runOnUiThread { - this.requestedOrientation = requestedOrientation + activity.runOnUiThread { + activity.requestedOrientation = requestedOrientation rotationLatch.countDown() } @@ -133,6 +143,6 @@ private val Activity.isLandscape: Boolean * Check if the activity's current orientation matches what was requested */ internal fun Activity.isRequestedOrientation(requestedOrientation: Int?): Boolean { - return (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE && this.isLandscape) || - (requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE && !this.isLandscape) + return (requestedOrientation == SCREEN_ORIENTATION_LANDSCAPE && this.isLandscape) || + (requestedOrientation != SCREEN_ORIENTATION_LANDSCAPE && !this.isLandscape) } diff --git a/Library/src/main/java/dev/testify/internal/helpers/ResourceWrapper.kt b/Library/src/main/java/dev/testify/internal/helpers/ResourceWrapper.kt index 2144566c..c2796024 100644 --- a/Library/src/main/java/dev/testify/internal/helpers/ResourceWrapper.kt +++ b/Library/src/main/java/dev/testify/internal/helpers/ResourceWrapper.kt @@ -45,8 +45,11 @@ interface WrappedResource { object ResourceWrapper { - private var isWrapped: Boolean = false - private val wrappedResources = HashSet>() + @VisibleForTesting + internal var isWrapped: Boolean = false + + @VisibleForTesting + internal val wrappedResources = HashSet>() fun wrap(context: Context): Context { isWrapped = true @@ -75,12 +78,13 @@ object ResourceWrapper { } private fun WrappedResource<*>.applyToActivity(activity: Activity) { - val version: Int = Build.VERSION.SDK_INT + val version: Int = buildVersionSdkInt() when { version <= Build.VERSION_CODES.M -> { this.afterActivityLaunched(activity) } - version >= Build.VERSION_CODES.N -> { + + else -> { if (activity !is TestifyResourcesOverride) { throw ActivityMustImplementResourceOverrideException(activity.localClassName) } diff --git a/Library/src/main/java/dev/testify/internal/helpers/WrappedFontScale.kt b/Library/src/main/java/dev/testify/internal/helpers/WrappedFontScale.kt index f728b105..0c79b349 100644 --- a/Library/src/main/java/dev/testify/internal/helpers/WrappedFontScale.kt +++ b/Library/src/main/java/dev/testify/internal/helpers/WrappedFontScale.kt @@ -30,6 +30,7 @@ import android.app.Activity import android.content.Context import android.content.res.Configuration import android.os.Build +import androidx.annotation.VisibleForTesting class WrappedFontScale(override var overrideValue: Float) : WrappedResource { override var defaultValue: Float = 1.0f @@ -43,12 +44,13 @@ class WrappedFontScale(override var overrideValue: Float) : WrappedResource= Build.VERSION_CODES.N) { + return if (buildVersionSdkInt() >= Build.VERSION_CODES.N) { this.updateResources(fontScale) } else { this.updateResourcesLegacy(fontScale) } } +@VisibleForTesting @TargetApi(Build.VERSION_CODES.N) -private fun Context.updateResources(fontScale: Float): Context { +internal fun Context.updateResources(fontScale: Float): Context { val configuration = Configuration(this.resources.configuration) configuration.fontScale = fontScale val x = this.createConfigurationContext(configuration) return x } +@VisibleForTesting @Suppress("DEPRECATION") -private fun Context.updateResourcesLegacy(fontScale: Float): Context { +internal fun Context.updateResourcesLegacy(fontScale: Float): Context { val configuration = Configuration(this.resources.configuration) configuration.fontScale = fontScale this.resources.updateConfiguration(configuration, this.resources.displayMetrics) diff --git a/Library/src/main/java/dev/testify/report/ReportSession.kt b/Library/src/main/java/dev/testify/report/ReportSession.kt index e35427fe..f7999fcd 100644 --- a/Library/src/main/java/dev/testify/report/ReportSession.kt +++ b/Library/src/main/java/dev/testify/report/ReportSession.kt @@ -26,6 +26,7 @@ package dev.testify.report import android.app.Instrumentation import androidx.annotation.VisibleForTesting +import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport import java.io.BufferedReader import java.io.File import java.text.SimpleDateFormat @@ -57,6 +58,7 @@ internal open class ReportSession { failCount++ } + @ExcludeFromJacocoGeneratedReport open fun initFromFile(file: File) { initFromLines(file.readLines()) } @@ -91,6 +93,7 @@ internal open class ReportSession { sessionId = getSessionId(instrumentation, Thread.currentThread()) } + @ExcludeFromJacocoGeneratedReport open fun isEqual(file: File): Boolean { return (getSessionIdFromFile(file.bufferedReader())?.endsWith(sessionId) == true) } diff --git a/Library/src/main/java/dev/testify/report/Reporter.kt b/Library/src/main/java/dev/testify/report/Reporter.kt index 2f43579f..68530958 100644 --- a/Library/src/main/java/dev/testify/report/Reporter.kt +++ b/Library/src/main/java/dev/testify/report/Reporter.kt @@ -35,6 +35,7 @@ import dev.testify.core.DEFAULT_NAME_FORMAT import dev.testify.core.DeviceStringFormatter import dev.testify.core.formatDeviceString import dev.testify.core.getDeviceDescription +import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport import dev.testify.output.Destination import dev.testify.output.PNG_EXTENSION import dev.testify.output.getDestination @@ -129,10 +130,11 @@ internal open class Reporter protected constructor( /** * Finalize the report.yml file */ - fun finalize() { + @ExcludeFromJacocoGeneratedReport + fun finalize() = getDestination().finalize() - } + @ExcludeFromJacocoGeneratedReport @VisibleForTesting open fun writeToFile(builder: StringBuilder, file: File) = file.appendText(builder.toString()) @@ -148,6 +150,7 @@ internal open class Reporter protected constructor( extension = PNG_EXTENSION ) + @get:ExcludeFromJacocoGeneratedReport private val Context.fileName: String get() = formatDeviceString( DeviceStringFormatter( @@ -157,10 +160,12 @@ internal open class Reporter protected constructor( DEFAULT_NAME_FORMAT ) + @ExcludeFromJacocoGeneratedReport @VisibleForTesting internal open fun getOutputPath(): String = getDestination(context, context.fileName).description + @ExcludeFromJacocoGeneratedReport @VisibleForTesting internal open fun getEnvironmentArguments(): Bundle = InstrumentationRegistry.getArguments() @@ -183,7 +188,8 @@ internal open class Reporter protected constructor( return destination.file } - private val headerLineCount: Int + @VisibleForTesting + internal val headerLineCount: Int get() = listOf(HEADER, "tests").size + session.sessionLineCount @VisibleForTesting @@ -208,6 +214,7 @@ internal open class Reporter protected constructor( } } + @ExcludeFromJacocoGeneratedReport @VisibleForTesting internal open fun clearFile(file: File) { file.writeText("") @@ -225,6 +232,7 @@ internal open class Reporter protected constructor( } } + @ExcludeFromJacocoGeneratedReport @VisibleForTesting internal open fun readBodyLines(file: File): List { return file.readLines().drop(headerLineCount) diff --git a/Library/src/test/java/dev/testify/ScreenshotRuleTest.kt b/Library/src/test/java/dev/testify/ScreenshotRuleTest.kt index d83f1936..94c893dd 100644 --- a/Library/src/test/java/dev/testify/ScreenshotRuleTest.kt +++ b/Library/src/test/java/dev/testify/ScreenshotRuleTest.kt @@ -27,31 +27,52 @@ import android.app.Activity import android.app.Instrumentation import android.content.Context import android.content.Intent +import android.content.res.Resources import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Bundle import android.os.Looper +import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.annotation.IdRes +import androidx.annotation.LayoutRes import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import dev.testify.annotation.TestifyLayout +import dev.testify.core.TestifyConfiguration import dev.testify.core.assertExpectedDevice +import dev.testify.core.exception.AssertSameMustBeLastException +import dev.testify.core.exception.FailedToCaptureBitmapException import dev.testify.core.exception.FinalizeDestinationException +import dev.testify.core.exception.MissingAssertSameException +import dev.testify.core.exception.MissingScreenshotInstrumentationAnnotationException +import dev.testify.core.exception.NoScreenshotsOnUiThreadException import dev.testify.core.exception.ScreenshotBaselineNotDefinedException import dev.testify.core.exception.ScreenshotIsDifferentException +import dev.testify.core.exception.ScreenshotTestIgnoredException import dev.testify.core.getDeviceDimensions import dev.testify.core.logic.compareBitmaps +import dev.testify.core.logic.pauseForInspection import dev.testify.core.logic.takeScreenshot import dev.testify.core.processor.capture.createBitmapFromDrawingCache import dev.testify.core.processor.compare.sameAsCompare +import dev.testify.core.processor.diff.HighContrastDiff +import dev.testify.internal.extensions.TestInstrumentationRegistry +import dev.testify.internal.helpers.EspressoHelper import dev.testify.internal.helpers.findRootView +import dev.testify.internal.helpers.isRunningOnUiThread import dev.testify.output.DataDirectoryDestination import dev.testify.output.DataDirectoryDestinationNotFoundException import dev.testify.output.getDestination import dev.testify.report.Reporter +import io.mockk.clearAllMocks import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject import io.mockk.mockkStatic +import io.mockk.runs import io.mockk.slot import io.mockk.spyk import io.mockk.unmockkAll @@ -59,6 +80,8 @@ import io.mockk.verify import org.junit.After import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.AssumptionViolatedException import org.junit.Before import org.junit.Test import org.junit.runner.Description @@ -88,6 +111,9 @@ class ScreenshotRuleTest { ) ) private val mockReporter = mockk(relaxed = true) + private val mockHighContrastDiff = mockk(relaxed = true) + private val testifyConfiguration = spyk(TestifyConfiguration()) + private val mockEspressoHelper: EspressoHelper = spyk(EspressoHelper(testifyConfiguration)) /** * Simulate the TestRule evaluation @@ -111,11 +137,15 @@ class ScreenshotRuleTest { mockkStatic(::takeScreenshot) mockkStatic(::compareBitmaps) mockkStatic(::getDestination) + mockkStatic(::isRunningOnUiThread) + mockkStatic(::pauseForInspection) mockkStatic(InstrumentationRegistry::class) mockkStatic(Looper::class) mockkStatic("dev.testify.internal.helpers.FindRootViewKt") mockkStatic(BitmapFactory::class) mockkObject(Reporter.Companion) + mockkObject(TestInstrumentationRegistry.Companion) + mockkObject(HighContrastDiff.Companion) val arguments = mockk(relaxed = true) val currentThread = mockk() @@ -130,18 +160,23 @@ class ScreenshotRuleTest { every { InstrumentationRegistry.getArguments() } returns arguments every { mainLooper.thread } returns currentThread every { Looper.getMainLooper() } returns mainLooper - every { getDeviceDimensions(any()) } returns (1024 to 2048) - every { Reporter.create(any(), any()) } returns mockReporter - - subject = spyk(ScreenshotRule(Activity::class.java, enableReporter = true)) - every { subject.launchActivity(any()) } returns mockActivity - every { subject.activity } returns mockActivity - every { subject.getIntent() } returns mockIntent every { any().findRootView(any()) } returns mockViewGroup - - every { subject.espressoHelper } returns mockk(relaxed = true) + every { getDestination(any(), any(), any(), any(), any()) } returns mockDestination + every { mockDestination.assureDestination(any()) } returns true + every { mockDestination.getFileOutputStream() } returns mockk(relaxed = true) + every { BitmapFactory.decodeFile(any(), any()) } returns mockCapturedBitmap + every { createBitmapFromDrawingCache(any(), any()) } returns mockCapturedBitmap + every { deleteBitmap(any()) } returns true + every { loadBaselineBitmapForComparison(any(), any()) } returns mockBaselineBitmap + every { loadBitmapFromFile(any(), any()) } returns mockCurrentBitmap + every { sameAsCompare(any(), any()) } returns true + every { isRunningOnUiThread() } returns false + every { pauseForInspection() } just runs + every { HighContrastDiff.create(any()) } returns mockHighContrastDiff + every { mockEspressoHelper.syncUiThread() } just runs + every { mockEspressoHelper.closeSoftKeyboard() } just runs val slot = slot() every { mockActivity.runOnUiThread(capture(slot)) } answers { @@ -152,18 +187,36 @@ class ScreenshotRuleTest { every { mockActivity.getDir(capture(getDirSlot), Context.MODE_PRIVATE) } answers { File("/data/user/0/dev.testify.sample/app_images/" + getDirSlot.captured) } + subject = initSubject() + } - every { getDestination(any(), any(), any(), any(), any()) } returns mockDestination - every { mockDestination.assureDestination(any()) } returns true - every { mockDestination.getFileOutputStream() } returns mockk(relaxed = true) - every { BitmapFactory.decodeFile(any(), any()) } returns mockCapturedBitmap - every { createBitmapFromDrawingCache(any(), any()) } returns mockCapturedBitmap - every { deleteBitmap(any()) } returns true - every { loadBaselineBitmapForComparison(any(), any()) } returns mockBaselineBitmap - every { loadBitmapFromFile(any(), any()) } returns mockCurrentBitmap - every { sameAsCompare(any(), any()) } returns true + private fun initSubject( + configuration: TestifyConfiguration = testifyConfiguration, + constructor: () -> ScreenshotRule = { + spyk( + ScreenshotRule( + activityClass = Activity::class.java, + enableReporter = true, + configuration = configuration + ) + ) + }, + base: Statement = mockStatement, + description: Description = mockDescription + ): ScreenshotRule = constructor().apply { + mockActivityLifecycle() + apply(base = base, description = description) + } - subject.apply(base = mockStatement, description = mockDescription) + private fun ScreenshotRule<*>.mockActivityLifecycle() { + every { launchActivity(any()) } answers { + beforeActivityLaunched() + afterActivityLaunched() + mockActivity + } + every { activity } returns mockActivity + every { getIntent() } returns mockIntent + every { espressoHelper } returns mockEspressoHelper } private fun verifyReporter() { @@ -175,12 +228,58 @@ class ScreenshotRuleTest { @After fun tearDown() { + clearAllMocks() unmockkAll() observer?.let { subject.removeScreenshotObserver(it) } } + @Suppress("DEPRECATION") + @Test + fun `WHEN using deprecated constructor THEN create instance`() { + val subject = initSubject( + constructor = { + spyk( + ScreenshotRule( + activityClass = Activity::class.java, + rootViewId = 123, + initialTouchMode = true, + launchActivity = true, + enableReporter = true + ) + ) + } + ) + subject.test() + + verify { subject.launchActivity(any()) } + verify { takeScreenshot(any(), any(), any(), any()) } + verify { assertExpectedDevice(any(), any(), any()) } + verify { loadBaselineBitmapForComparison(any(), any()) } + verify { compareBitmaps(any(), any(), any()) } + verify(exactly = 0) { mockDestination.finalize() } + verifyReporter() + } + + @Test + fun `WHEN reporter is enabled in constructor THEN enable reporter`() { + assertThat(subject.reporter).isNotNull() + } + + @Test + fun `WHEN reporter is enabled by feature THEN enable reporter`() { + TestifyFeatures.Reporter.setEnabled(true) + val subject = spyk(ScreenshotRule(Activity::class.java, enableReporter = false)) + assertThat(subject.reporter).isNotNull() + } + + @Test + fun `WHEN reporter is disabled in constructor THEN do not enable reporter`() { + val subject = spyk(ScreenshotRule(Activity::class.java, enableReporter = false)) + assertThat(subject.reporter).isNull() + } + @Test(expected = ScreenshotBaselineNotDefinedException::class) fun `WHEN no baseline bitmap THEN throw ScreenshotBaselineNotDefinedException`() { every { loadBaselineBitmapForComparison(any(), any()) } returns null @@ -245,6 +344,7 @@ class ScreenshotRuleTest { "beforeAssertSame", "beforeInitializeView", "afterInitializeView", + "applyConfiguration", "beforeScreenshot", "afterScreenshot", ) @@ -269,6 +369,10 @@ class ScreenshotRuleTest { override fun afterScreenshot(activity: Activity, currentBitmap: Bitmap?) { expectedCalls.remove("afterScreenshot") } + + override fun applyConfiguration(activity: Activity, configuration: TestifyConfiguration) { + expectedCalls.remove("applyConfiguration") + } }.also { subject.addScreenshotObserver(it) } @@ -290,4 +394,290 @@ class ScreenshotRuleTest { verifyReporter() } + + @Test(expected = NoScreenshotsOnUiThreadException::class) + fun `WHEN isRunningOnUiThread THEN throw NoScreenshotsOnUiThreadException`() { + every { isRunningOnUiThread() } returns true + subject.test() + } + + @Test(expected = AssumptionViolatedException::class) + fun `WHEN assureActivity throws ScreenshotTestIgnoredException THEN ignore test`() { + every { subject.assureActivity(any()) } throws ScreenshotTestIgnoredException() + subject.test() + } + + @Test(expected = ScreenshotIsDifferentException::class) + fun `WHEN GenerateDiffs is enabled THEN generate high contrast diffs`() { + every { compareBitmaps(any(), any(), any()) } returns false + TestifyFeatures.GenerateDiffs.isEnabled(mockActivity) + subject.test() + verify { mockHighContrastDiff.generate(any()) } + verify { mockReporter.fail(any()) } + verifyReporter() + } + + @Test(expected = FailedToCaptureBitmapException::class) + fun `WHEN takeScreenshot fails THEN throws FailedToCaptureBitmapException`() { + every { takeScreenshot(any(), any(), any(), any()) } returns null + subject.test() + verify { mockReporter.fail(any()) } + verifyReporter() + } + + @Test + fun `WHEN pauseForInspection THEN sleep`() { + subject + .configure { + pauseForInspection = true + } + .test() + verify { pauseForInspection() } + verifyReporter() + } + + @Test + fun `WHEN isRecordMode THEN is always successful`() { + every { loadBaselineBitmapForComparison(any(), any()) } returns null + + subject + .configure { + isRecordMode = true + } + .test() + + verify { takeScreenshot(any(), any(), any(), any()) } + verify { mockReporter.pass() } + verifyReporter() + } + + @Test(expected = FinalizeDestinationException::class) + fun `WHEN bitmaps do not match AND finalize destination fails THEN throw FinalizeDestinationException`() { + every { compareBitmaps(any(), any(), any()) } returns false + every { mockDestination.finalize() } returns false + + subject.test() + + verify { compareBitmaps(any(), any(), any()) } + verify { mockReporter.fail(any()) } + verifyReporter() + } + + @Test(expected = ScreenshotIsDifferentException::class) + fun `WHEN compareBitmap fails THEN throw ScreenshotIsDifferentException`() { + every { compareBitmaps(any(), any(), any()) } returns false + + subject.test() + + verify { compareBitmaps(any(), any(), any()) } + verify { mockReporter.fail(any()) } + verifyReporter() + } + + @Test + fun `WHEN compareBitmap fails AND record mode THEN pass`() { + every { TestInstrumentationRegistry.isRecordMode } returns true + every { compareBitmaps(any(), any(), any()) } returns false + + subject.test() + verify { mockReporter.pass() } + verifyReporter() + } + + @Test + fun `WHEN configuration isRecordMode is true THEN pass`() { + every { compareBitmaps(any(), any(), any()) } returns false + subject + .configure { + isRecordMode = true + } + .test() + verify { mockReporter.pass() } + verifyReporter() + } + + @Test + fun `WHEN espresso action are specified THEN execute espresso`() { + var isEvaluated = false + subject + .setEspressoActions { + isEvaluated = true + } + .test() + assertThat(isEvaluated).isTrue() + } + + @Test(expected = AssertSameMustBeLastException::class) + fun `WHEN espresso action are specified after assertSame THEN throw exception`() { + subject.test() + subject.setEspressoActions { } + } + + @Test(expected = ScreenshotIsDifferentException::class) + fun `WHEN experimental feature enabled THEN generate diffs`() { + every { compareBitmaps(any(), any(), any()) } returns false + + subject + .withExperimentalFeatureEnabled(TestifyFeatures.GenerateDiffs) + .test() + + verify { mockHighContrastDiff.generate(any()) } + verify { mockReporter.fail(any()) } + verifyReporter() + } + + @Test + fun `WHEN view modifications are specified THEN modify view`() { + var isEvaluated = false + subject + .setViewModifications { viewGroup -> + assertThat(viewGroup).isEqualTo(mockViewGroup) + isEvaluated = true + } + .test() + assertThat(isEvaluated).isTrue() + } + + @Test(expected = AssertSameMustBeLastException::class) + fun `WHEN view modifications are specified after assertSame THEN throw exception`() { + subject.test() + subject.setViewModifications { } + } + + @Test(expected = MissingAssertSameException::class) + fun `WHEN assertSame is not invoked THEN throw exception`() { + every { mockStatement.evaluate() } just runs + subject.statement?.evaluate() + } + + @Test + fun `WHEN setRootViewId THEN find requested view`() { + @IdRes val id = 1234 + val mockRoot = mockk(relaxed = true) + every { any().findRootView(id) } returns mockRoot + + val subject = initSubject() + subject.setRootViewId(id) + subject.setViewModifications { viewGroup -> + assertThat(viewGroup).isEqualTo(mockRoot) + } + subject.test() + } + + @Test + fun `WHEN setTargetLayoutId THEN inflate layout`() { + @LayoutRes val layoutId = 1234 + val mockLayoutRoot = mockk(relaxed = true) + val mockLayoutInflater = mockk(relaxed = true) + every { mockActivity.layoutInflater } returns mockLayoutInflater + every { mockLayoutInflater.inflate(any(), any(), any()) } returns mockLayoutRoot + + val subject = initSubject() + subject.setTargetLayoutId(layoutId) + subject.setViewModifications { viewGroup -> + assertThat(viewGroup).isEqualTo(mockViewGroup) + } + subject.test() + + verify { mockLayoutInflater.inflate(layoutId, any(), true) } + } + + @Test + fun `WHEN TestifyLayout annotation THEN inflate layout`() { + val mockLayoutRoot = mockk(relaxed = true) + val mockLayoutInflater = mockk(relaxed = true) + every { mockActivity.layoutInflater } returns mockLayoutInflater + every { mockLayoutInflater.inflate(any(), any(), any()) } returns mockLayoutRoot + every { mockDescription.annotations } returns listOf( + TestifyLayout(layoutId = 1234) + ) + val subject = initSubject() + subject + .setViewModifications { viewGroup -> + assertThat(viewGroup).isEqualTo(mockViewGroup) + } + .test() + + verify { mockLayoutInflater.inflate(1234, any(), true) } + } + + @Test + fun `WHEN TestifyLayout annotation set with layoutResName THEN inflate layout`() { + val layoutResName = "dev.testify.test:layout/elevation_test" + val mockResources = mockk(relaxed = true) + every { mockResources.getIdentifier(layoutResName, any(), any()) } returns 1234 + every { mockActivity.resources } returns mockResources + + val mockLayoutRoot = mockk(relaxed = true) + val mockLayoutInflater = mockk(relaxed = true) + every { mockActivity.layoutInflater } returns mockLayoutInflater + every { mockLayoutInflater.inflate(any(), any(), any()) } returns mockLayoutRoot + every { mockDescription.annotations } returns listOf( + TestifyLayout(layoutResName = layoutResName) + ) + val subject = initSubject() + subject + .setViewModifications { viewGroup -> + assertThat(viewGroup).isEqualTo(mockViewGroup) + } + .test() + + verify { mockLayoutInflater.inflate(1234, any(), true) } + verify { mockResources.getIdentifier(layoutResName, any(), any()) } + } + + @Test + fun `WHEN missing annotation AND invoked from plugin THEN throw exception`() { + val arguments = mockk(relaxed = true) { + every { containsKey("annotation") } returns true + } + every { InstrumentationRegistry.getArguments() } returns arguments + + val subject = initSubject() + try { + subject.test() + } catch (e: RuntimeException) { + assertThat(e.cause).isInstanceOf(MissingScreenshotInstrumentationAnnotationException::class.java) + } catch (t: Throwable) { + fail() + } + } + + @Test + fun `WHEN afterActivityLaunched THEN apply configuration`() { + subject.test() + + verify { testifyConfiguration.beforeActivityLaunched() } + verify { testifyConfiguration.afterActivityLaunched(mockActivity) } + } + + @Test + fun `WHEN has intent extras THEN pass to activity`() { + val subject = initSubject() + + val extraValues = mutableMapOf() + val providerValues = mutableMapOf() + val providerBundle = mockk(relaxed = true) { + every { putString(any(), any()) } answers { providerValues[arg(0)] = arg(1) } + } + val extrasBundle = mockk(relaxed = true) { + every { putAll(providerBundle) } answers { extraValues.putAll(providerValues) } + } + every { mockIntent.extras } returns extrasBundle + every { subject.invokeExtrasProvider() } answers { + subject.extrasProvider?.invoke(providerBundle) + providerBundle + } + + var isEvaluated = false + subject + .addIntentExtras { + it.putString("Testify", "Extra") + isEvaluated = true + } + .test() + + assertThat(extraValues).containsEntry("Testify", "Extra") + assertThat(isEvaluated).isTrue() + } } diff --git a/Library/src/test/java/dev/testify/TestDescriptionTest.kt b/Library/src/test/java/dev/testify/TestDescriptionTest.kt new file mode 100644 index 00000000..5669fb11 --- /dev/null +++ b/Library/src/test/java/dev/testify/TestDescriptionTest.kt @@ -0,0 +1,50 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify + +import android.app.Instrumentation +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import org.junit.Test + +class TestDescriptionTest { + + @Test + fun `WHEN test description is set THEN get test description`() { + val description1 = TestDescription("methodName1", TestDescriptionTest::class.java) + val description2 = TestDescription("methodName2", TestDescriptionTest::class.java) + val instrumentation = mockk(relaxed = true) + + instrumentation.testDescription = description1 + assertThat(instrumentation.testDescription).isEqualTo(description1) + instrumentation.testDescription = description2 + assertThat(instrumentation.testDescription).isEqualTo(description2) + } + + @Test(expected = IllegalStateException::class) + fun `WHEN test description is not set THEN throw exception`() { + val instrumentation = mockk(relaxed = true) + instrumentation.testDescription + } +} diff --git a/Library/src/test/java/dev/testify/core/ConfigurationBuilderTest.kt b/Library/src/test/java/dev/testify/core/ConfigurationBuilderTest.kt new file mode 100644 index 00000000..a45d820a --- /dev/null +++ b/Library/src/test/java/dev/testify/core/ConfigurationBuilderTest.kt @@ -0,0 +1,192 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.core + +import android.app.Activity +import android.app.Instrumentation +import android.content.pm.ActivityInfo +import android.graphics.Rect +import android.view.View +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import dev.testify.CaptureMethod +import dev.testify.CompareMethod +import dev.testify.ScreenshotRule +import dev.testify.TestifyFeatures +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.spyk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import java.util.Locale + +class ConfigurationBuilderTest { + + private val dummyConfiguration = TestifyConfiguration() + private lateinit var dummyRule: ScreenshotRule + private lateinit var subject: ConfigurationBuilder + + @Before + fun before() { + mockkStatic(InstrumentationRegistry::class) + val instrumentation = mockk(relaxed = true) + every { InstrumentationRegistry.getInstrumentation() } returns instrumentation + dummyRule = spyk(ScreenshotRule(Activity::class.java, configuration = dummyConfiguration)) { + every { assertSame() } just runs + } + subject = spyk(makeConfigurable(dummyRule)) + } + + @Test + fun `WHEN assertSame THEN configures rule`() { + subject.assertSame() + verify { + @Suppress("UnusedLambdaExpressionBody") + subject.build() + } + verify { dummyRule.configure(any()) } + verify { dummyRule.assertSame() } + } + + @Test + fun `WHEN setExactness THEN configure exactness`() { + subject.setExactness(0.5f).assertSame() + assertThat(dummyConfiguration.exactness).isEqualTo(0.5f) + } + + @Test + fun `WHEN defineExclusionRects THEN configure ineExclusionRects`() { + val rect = Rect(0, 0, 1, 1) + val provider: ExclusionRectProvider = { _, rects -> + rects.add(rect) + } + subject.defineExclusionRects(provider).assertSame() + assertThat(dummyConfiguration.exclusionRectProvider).isEqualTo(provider) + val rects = mutableSetOf() + dummyConfiguration.exclusionRectProvider?.invoke(mockk(), rects) + assertThat(rects).hasSize(1) + assertThat(rects).contains(rect) + } + + @Test + fun `WHEN setCaptureMethod THEN configure captureMethod`() { + val mockCaptureMethod = mockk(relaxed = true) + subject.setCaptureMethod(mockCaptureMethod).assertSame() + assertThat(dummyConfiguration.captureMethod).isEqualTo(mockCaptureMethod) + } + + @Test + fun `WHEN setCompareMethod THEN configure compareMethod`() { + val mockCompareMethod = mockk(relaxed = true) + subject.setCompareMethod(mockCompareMethod).assertSame() + assertThat(dummyConfiguration.compareMethod).isEqualTo(mockCompareMethod) + } + + @Test + fun `WHEN setFocusTarget THEN configure focusTarget`() { + subject.setFocusTarget(true, 123).assertSame() + assertThat(dummyConfiguration.focusTargetId).isEqualTo(123) + subject.setFocusTarget(false).assertSame() + assertThat(dummyConfiguration.focusTargetId).isEqualTo(View.NO_ID) + } + + @Test + fun `WHEN setFontScale THEN configure fontScale`() { + subject.setFontScale(3.5f).assertSame() + assertThat(dummyConfiguration.fontScale).isEqualTo(3.5f) + } + + @Test + fun `WHEN setHideCursor THEN configure hideCursor`() { + subject.setHideCursor(false).assertSame() + assertThat(dummyConfiguration.hideCursor).isFalse() + } + + @Test + fun `WHEN setHidePasswords THEN configure hidePasswords`() { + subject.setHidePasswords(false).assertSame() + assertThat(dummyConfiguration.hidePasswords).isFalse() + } + + @Test + fun `WHEN setHideScrollbars THEN configure hideScrollbars`() { + subject.setHideScrollbars(false).assertSame() + assertThat(dummyConfiguration.hideScrollbars).isFalse() + } + + @Test + fun `WHEN setHideSoftKeyboard THEN configure hideSoftKeyboard`() { + subject.setHideSoftKeyboard(false).assertSame() + assertThat(dummyConfiguration.hideSoftKeyboard).isFalse() + } + + @Test + fun `WHEN setHideTextSuggestions THEN configure hideTextSuggestions`() { + subject.setHideTextSuggestions(false).assertSame() + assertThat(dummyConfiguration.hideTextSuggestions).isFalse() + } + + @Test + fun `WHEN setLayoutInspectionModeEnabled THEN configure layoutInspectionModeEnabled`() { + subject.setLayoutInspectionModeEnabled(true).assertSame() + assertThat(dummyConfiguration.pauseForInspection).isTrue() + } + + @Test + fun `WHEN setLocale THEN configure locale`() { + subject.setLocale(Locale.CANADA_FRENCH).assertSame() + assertThat(dummyConfiguration.locale).isEqualTo(Locale.CANADA_FRENCH) + } + + @Test(expected = IllegalArgumentException::class) + fun `WHEN setOrientation THEN configure orientation`() { + subject.setOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE).assertSame() + assertThat(dummyConfiguration.orientation).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + subject.setOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT).assertSame() + assertThat(dummyConfiguration.orientation).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + subject.setOrientation(ActivityInfo.SCREEN_ORIENTATION_BEHIND).assertSame() + } + + @Test + fun `WHEN setUseSoftwareRenderer THEN configure useSoftwareRenderer`() { + subject.setUseSoftwareRenderer(true).assertSame() + assertThat(dummyConfiguration.useSoftwareRenderer).isTrue() + } + + @Test + fun `WHEN withExperimentalFeatureEnabled THEN set feature`() { + subject.withExperimentalFeatureEnabled(TestifyFeatures.ExampleFeature).assertSame() + assertThat(TestifyFeatures.ExampleFeature.isEnabled()).isTrue() + } + + @Test + fun `WHEN setRecordModeEnabled THEN configure recordModeEnabled`() { + subject.setRecordModeEnabled(true).assertSame() + assertThat(dummyConfiguration.isRecordMode).isTrue() + } +} diff --git a/Library/src/test/java/dev/testify/core/DeviceIdentifierTest.kt b/Library/src/test/java/dev/testify/core/DeviceIdentifierTest.kt index 488f24f9..5f71ae53 100644 --- a/Library/src/test/java/dev/testify/core/DeviceIdentifierTest.kt +++ b/Library/src/test/java/dev/testify/core/DeviceIdentifierTest.kt @@ -24,10 +24,6 @@ */ package dev.testify.core -import dev.testify.core.DEFAULT_FOLDER_FORMAT -import dev.testify.core.DEFAULT_NAME_FORMAT -import dev.testify.core.DeviceStringFormatter -import dev.testify.core.formatDeviceString import io.mockk.every import io.mockk.mockk import org.junit.Assert.assertEquals diff --git a/Library/src/test/java/dev/testify/core/ScreenshotRuleCompatibilityMethodsTest.kt b/Library/src/test/java/dev/testify/core/ScreenshotRuleCompatibilityMethodsTest.kt new file mode 100644 index 00000000..77b0cbaf --- /dev/null +++ b/Library/src/test/java/dev/testify/core/ScreenshotRuleCompatibilityMethodsTest.kt @@ -0,0 +1,169 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.core + +import android.app.Activity +import android.app.Instrumentation +import android.content.pm.ActivityInfo +import android.graphics.Rect +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import dev.testify.CaptureMethod +import dev.testify.CompareMethod +import dev.testify.ScreenshotRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk +import org.junit.Before +import org.junit.Test +import java.util.Locale + +@Suppress("DEPRECATION") +class ScreenshotRuleCompatibilityMethodsTest { + + private lateinit var subject: ScreenshotRuleCompatibilityMethods, Activity> + private val dummyConfiguration = TestifyConfiguration() + private lateinit var dummyRule: ScreenshotRule + + @Before + fun before() { + mockkStatic(InstrumentationRegistry::class) + val instrumentation = mockk(relaxed = true) + every { InstrumentationRegistry.getInstrumentation() } returns instrumentation + dummyRule = spyk(ScreenshotRule(Activity::class.java, configuration = dummyConfiguration)) + subject = spyk(ScreenshotRuleCompatibilityMethods()) + subject.withRule(dummyRule) + assertThat(subject.rule).isNotNull() + } + + @Test + fun `WHEN setExactness THEN configure exactness`() { + subject.setExactness(0.5f) + assertThat(dummyConfiguration.exactness).isEqualTo(0.5f) + } + + @Test + fun `WHEN defineExclusionRects THEN configure ineExclusionRects`() { + val rect = Rect(0, 0, 1, 1) + val provider: ExclusionRectProvider = { _, rects -> + rects.add(rect) + } + subject.defineExclusionRects(provider) + assertThat(dummyConfiguration.exclusionRectProvider).isEqualTo(provider) + val rects = mutableSetOf() + dummyConfiguration.exclusionRectProvider?.invoke(mockk(), rects) + assertThat(rects).hasSize(1) + assertThat(rects).contains(rect) + } + + @Test + fun `WHEN setCaptureMethod THEN configure captureMethod`() { + val mockCaptureMethod = mockk(relaxed = true) + subject.setCaptureMethod(mockCaptureMethod) + assertThat(dummyConfiguration.captureMethod).isEqualTo(mockCaptureMethod) + } + + @Test + fun `WHEN setCompareMethod THEN configure compareMethod`() { + val mockCompareMethod = mockk(relaxed = true) + subject.setCompareMethod(mockCompareMethod) + assertThat(dummyConfiguration.compareMethod).isEqualTo(mockCompareMethod) + } + + @Test + fun `WHEN setFocusTarget THEN configure focusTarget`() { + subject.setFocusTarget(true, 123) + assertThat(dummyConfiguration.focusTargetId).isEqualTo(123) + } + + @Test + fun `WHEN setFontScale THEN configure fontScale`() { + subject.setFontScale(3.5f) + assertThat(dummyConfiguration.fontScale).isEqualTo(3.5f) + } + + @Test + fun `WHEN setHideCursor THEN configure hideCursor`() { + subject.setHideCursor(false) + assertThat(dummyConfiguration.hideCursor).isFalse() + } + + @Test + fun `WHEN setHidePasswords THEN configure hidePasswords`() { + subject.setHidePasswords(false) + assertThat(dummyConfiguration.hidePasswords).isFalse() + } + + @Test + fun `WHEN setHideScrollbars THEN configure hideScrollbars`() { + subject.setHideScrollbars(false) + assertThat(dummyConfiguration.hideScrollbars).isFalse() + } + + @Test + fun `WHEN setHideSoftKeyboard THEN configure hideSoftKeyboard`() { + subject.setHideSoftKeyboard(false) + assertThat(dummyConfiguration.hideSoftKeyboard).isFalse() + } + + @Test + fun `WHEN setHideTextSuggestions THEN configure hideTextSuggestions`() { + subject.setHideTextSuggestions(false) + assertThat(dummyConfiguration.hideTextSuggestions).isFalse() + } + + @Test + fun `WHEN setLayoutInspectionModeEnabled THEN configure layoutInspectionModeEnabled`() { + subject.setLayoutInspectionModeEnabled(true) + assertThat(dummyConfiguration.pauseForInspection).isTrue() + } + + @Test + fun `WHEN setLocale THEN configure locale`() { + subject.setLocale(Locale.CANADA_FRENCH) + assertThat(dummyConfiguration.locale).isEqualTo(Locale.CANADA_FRENCH) + } + + @Test(expected = IllegalArgumentException::class) + fun `WHEN setOrientation THEN configure orientation`() { + subject.setOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + assertThat(dummyConfiguration.orientation).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + subject.setOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + assertThat(dummyConfiguration.orientation).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + subject.setOrientation(ActivityInfo.SCREEN_ORIENTATION_BEHIND) + } + + @Test + fun `WHEN setRecordModeEnabled THEN configure recordModeEnabled`() { + subject.setRecordModeEnabled(true) + assertThat(dummyConfiguration.isRecordMode).isTrue() + } + + @Test + fun `WHEN setUseSoftwareRenderer THEN configure useSoftwareRenderer`() { + subject.setUseSoftwareRenderer(true) + assertThat(dummyConfiguration.useSoftwareRenderer).isTrue() + } +} diff --git a/Library/src/test/java/dev/testify/core/TestifyConfigurationTest.kt b/Library/src/test/java/dev/testify/core/TestifyConfigurationTest.kt new file mode 100644 index 00000000..bc8e73ba --- /dev/null +++ b/Library/src/test/java/dev/testify/core/TestifyConfigurationTest.kt @@ -0,0 +1,236 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.core + +import android.app.Activity +import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE +import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED +import android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS +import android.text.method.PasswordTransformationMethod +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import com.google.common.truth.Truth.assertThat +import dev.testify.annotation.BitmapComparisonExactness +import dev.testify.annotation.IgnoreScreenshot +import dev.testify.core.exception.ScreenshotTestIgnoredException +import dev.testify.internal.helpers.ResourceWrapper +import dev.testify.internal.helpers.WrappedFontScale +import dev.testify.internal.helpers.WrappedLocale +import dev.testify.internal.helpers.isRequestedOrientation +import dev.testify.internal.modification.StaticPasswordTransformationMethod +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import java.util.Locale + +@Suppress("UsePropertyAccessSyntax") +class TestifyConfigurationTest { + + private val mockEditText = mockk(relaxed = true) + private val mockViewGroup = mockk(relaxed = true) { + every { childCount } returns 1 + every { getChildAt(any()) } returns mockEditText + } + private val mockActivity = mockk(relaxed = true) + + @Before + fun before() { + mockkStatic(Activity::isRequestedOrientation) + every { any().isRequestedOrientation(any()) } returns false + val slot = slot() + every { mockActivity.runOnUiThread(capture(slot)) } answers { + slot.captured.run() + } + mockkObject(ResourceWrapper) + } + + @Test + fun `WHEN all configuration options are disabled THEN no configurations applied`() { + every { mockEditText.transformationMethod } returns mockk() + every { mockEditText.inputType } returns 1 + val subject = TestifyConfiguration( + hideCursor = false, + hideScrollbars = false, + hidePasswords = false, + hideTextSuggestions = false, + ) + + assertThat(subject.exactness).isNull() + assertThat(subject.ignoreAnnotation).isNull() + assertThat(subject.orientationHelper).isNull() + + subject.applyAnnotations(emptySet()) + subject.applyViewModificationsMainThread(mockViewGroup) + subject.applyViewModificationsTestThread(mockActivity) + + verify(exactly = 0) { mockActivity.findViewById(any()) } + verify(exactly = 0) { mockActivity.runOnUiThread(any()) } + verify(exactly = 0) { mockEditText.setCursorVisible(any()) } + verify(exactly = 0) { mockViewGroup.setHorizontalScrollBarEnabled(any()) } + verify(exactly = 0) { mockViewGroup.setVerticalScrollBarEnabled(any()) } + verify(exactly = 0) { mockEditText.setTransformationMethod(any()) } + verify(exactly = 0) { ResourceWrapper.addOverride(any()) } + } + + @Test(expected = IllegalArgumentException::class) + fun `WHEN orientation is invalid THEN throw exception`() { + TestifyConfiguration( + orientation = SCREEN_ORIENTATION_UNSPECIFIED + ) + } + + @Test + fun `WHEN BitmapComparisonExactness annotation THEN set exactness`() { + val subject = TestifyConfiguration() + subject.applyAnnotations(setOf(BitmapComparisonExactness(0.5f))) + assertThat(subject.exactness).isEqualTo(0.5f) + } + + @Test + fun `WHEN IgnoreScreenshot annotation THEN ignore orientation`() { + val subject = TestifyConfiguration() + subject.applyAnnotations(setOf(IgnoreScreenshot(ignoreAlways = true, SCREEN_ORIENTATION_LANDSCAPE))) + assertThat(subject.ignoreAnnotation).isNotNull() + } + + @Test(expected = ScreenshotTestIgnoredException::class) + fun `WHEN IgnoreScreenshot annotation THEN do not run test`() { + val subject = spyk( + TestifyConfiguration( + orientation = SCREEN_ORIENTATION_LANDSCAPE + ) + ) + assertThat(subject.orientationHelper).isNotNull() + subject.applyAnnotations(setOf(IgnoreScreenshot(ignoreAlways = true))) + subject.afterActivityLaunched(mockk()) + verify(exactly = 0) { subject.orientationHelper?.afterActivityLaunched() } + } + + @Test(expected = ScreenshotTestIgnoredException::class) + fun `WHEN IgnoreScreenshot annotation AND matches orientation THEN do not run test`() { + every { any().isRequestedOrientation(any()) } returns true + val subject = spyk( + TestifyConfiguration( + orientation = SCREEN_ORIENTATION_LANDSCAPE + ) + ) + assertThat(subject.orientationHelper).isNotNull() + subject.applyAnnotations(setOf(IgnoreScreenshot(orientationToIgnore = SCREEN_ORIENTATION_LANDSCAPE))) + subject.afterActivityLaunched(mockk()) + verify(exactly = 0) { subject.orientationHelper?.afterActivityLaunched() } + } + + @Test + fun `WHEN hideCursor is true THEN hide cursor`() { + val subject = TestifyConfiguration( + hideCursor = true + ) + subject.applyViewModificationsMainThread(mockViewGroup) + verify { mockEditText.setCursorVisible(false) } + } + + @Test + fun `WHEN hideScrollbars is true THEN hide scrollbars`() { + val subject = TestifyConfiguration( + hideScrollbars = true + ) + subject.applyViewModificationsMainThread(mockViewGroup) + verify { mockViewGroup.setHorizontalScrollBarEnabled(false) } + verify { mockViewGroup.setVerticalScrollBarEnabled(false) } + } + + @Test + fun `WHEN hidePasswords is true THEN set to static password transformation method`() { + every { mockEditText.transformationMethod } returns mockk() + val subject = TestifyConfiguration( + hidePasswords = true + ) + subject.applyViewModificationsMainThread(mockViewGroup) + verify { mockEditText.setTransformationMethod(any()) } + } + + @Test + fun `WHEN hideTextSuggestions is true THEN hide text suggestions`() { + every { mockEditText.inputType } returns 1 + + val inputTypeSlot = slot() + every { mockEditText.setInputType(capture(inputTypeSlot)) } just runs + + val subject = TestifyConfiguration( + hideTextSuggestions = true + ) + subject.applyViewModificationsMainThread(mockViewGroup) + verify { mockEditText.setInputType(any()) } + assertThat(inputTypeSlot.captured).isEqualTo(1 or TYPE_TEXT_FLAG_NO_SUGGESTIONS) + } + + @Test + fun `WHEN useSoftwareRenderer is true THEN set software layer type`() { + val subject = TestifyConfiguration( + useSoftwareRenderer = true + ) + subject.applyViewModificationsMainThread(mockViewGroup) + verify { mockEditText.setLayerType(View.LAYER_TYPE_SOFTWARE, any()) } + } + + @Test + fun `WHEN target ID set THEN set as in focus`() { + val subject = TestifyConfiguration( + focusTargetId = 123 + ) + every { mockActivity.findViewById(123) } returns mockEditText + subject.applyViewModificationsTestThread(mockActivity) + + verify { mockEditText.setFocusableInTouchMode(true) } + verify { mockEditText.setFocusable(true) } + verify { mockEditText.requestFocus() } + } + + @Test + fun `WHEN locale is set THEN set pending resource override`() { + val subject = TestifyConfiguration( + locale = Locale.CANADA_FRENCH + ) + subject.beforeActivityLaunched() + verify { ResourceWrapper.addOverride(any()) } + } + + @Test + fun `WHEN font scale is set THEN set pending resource override`() { + val subject = TestifyConfiguration( + fontScale = 10f + ) + subject.beforeActivityLaunched() + verify { ResourceWrapper.addOverride(any()) } + } +} diff --git a/Library/src/test/java/dev/testify/core/exception/ErrorCauseTest.kt b/Library/src/test/java/dev/testify/core/exception/ErrorCauseTest.kt index 073eb350..ee90c5b2 100644 --- a/Library/src/test/java/dev/testify/core/exception/ErrorCauseTest.kt +++ b/Library/src/test/java/dev/testify/core/exception/ErrorCauseTest.kt @@ -70,5 +70,6 @@ class ErrorCauseTest { assertEquals("UNKNOWN", describeErrorCause(Throwable()).name) assertEquals("NO_TEST_STORAGE", describeErrorCause(TestStorageNotFoundException()).name) assertEquals("FINALIZE_DESTINATION", describeErrorCause(FinalizeDestinationException("")).name) + assertEquals("UNEXPECTED_ORIENTATION", describeErrorCause(UnexpectedOrientationException("")).name) } } diff --git a/Library/src/test/java/dev/testify/core/processor/BitmapTestHelpers.kt b/Library/src/test/java/dev/testify/core/processor/BitmapTestHelpers.kt new file mode 100644 index 00000000..e7660e65 --- /dev/null +++ b/Library/src/test/java/dev/testify/core/processor/BitmapTestHelpers.kt @@ -0,0 +1,91 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.core.processor + +import android.graphics.Bitmap +import android.graphics.Rect +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import java.nio.Buffer +import java.nio.IntBuffer + +const val DEFAULT_BITMAP_WIDTH = 1080 +const val DEFAULT_BITMAP_HEIGHT = 2220 + +fun mockBitmap( + width: Int = DEFAULT_BITMAP_WIDTH, + height: Int = DEFAULT_BITMAP_HEIGHT, + getPixel: (x: Int, y: Int) -> Int = { _, _ -> 0xffffffff.toInt() }, +): Bitmap = mockk(relaxed = true) { + every { this@mockk.height } returns height + every { this@mockk.width } returns width + + val buffer = IntBuffer.allocate(width * height) + for (x in 0 until width) { + for (y in 0 until height) { + buffer.put(getPixel(x, y)) + } + } + + every { this@mockk.getPixel(any(), any()) } answers { + buffer[arg(1) * width + arg(0)] + } + + val slotBuffer = slot() + every { this@mockk.copyPixelsToBuffer(capture(slotBuffer)) } answers { + val outputBuffer = slotBuffer.captured as IntBuffer + for (i in 0 until width * height) { + outputBuffer.put(buffer[i]) + } + } + + every { this@mockk.sameAs(any()) } answers { + val self = this@mockk + val other = arg(0) + var sameAs = true + x@ for (x in 0 until width) { + for (y in 0 until height) { + if (self.getPixel(x, y) != other.getPixel(x, y)) { + sameAs = false + break@x + } + } + } + sameAs + } +} + +// Rect is an android platform type so can't be instantiated directly. It must be mocked. +fun mockRect(left: Int, top: Int, right: Int, bottom: Int): Rect = + mockk(relaxed = true) { + every { this@mockk.contains(any(), any()) } answers { + if (left > top) right + bottom + + val x: Int = arg(0) + val y: Int = arg(1) + + (x in left..right) && (y in top..bottom) + } + } diff --git a/Library/src/test/java/dev/testify/core/processor/ParallelPixelProcessorTest.kt b/Library/src/test/java/dev/testify/core/processor/ParallelPixelProcessorTest.kt index 4fb6caa8..695d9362 100644 --- a/Library/src/test/java/dev/testify/core/processor/ParallelPixelProcessorTest.kt +++ b/Library/src/test/java/dev/testify/core/processor/ParallelPixelProcessorTest.kt @@ -1,10 +1,5 @@ package dev.testify.core.processor -import android.graphics.Bitmap -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.runs import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -24,20 +19,6 @@ class ParallelPixelProcessorTest { private val mainThreadSurrogate = newSingleThreadContext("UI thread") - companion object { - const val WIDTH = 1080 - const val HEIGHT = 2220 - } - - private fun mockBitmap(width: Int = WIDTH, height: Int = HEIGHT): Bitmap { - return mockk { - every { this@mockk.height } returns height - every { this@mockk.width } returns width - every { this@mockk.getPixel(any(), any()) } returns 0xffffffff.toInt() - every { this@mockk.copyPixelsToBuffer(any()) } just runs - } - } - private lateinit var pixelProcessor: ParallelPixelProcessor private fun forceSingleThreadedExecution() { @@ -69,7 +50,7 @@ class ParallelPixelProcessorTest { index.incrementAndGet() true } - assertEquals(WIDTH * HEIGHT, index.get()) + assertEquals(DEFAULT_BITMAP_WIDTH * DEFAULT_BITMAP_HEIGHT, index.get()) } @Test @@ -82,7 +63,7 @@ class ParallelPixelProcessorTest { true } - assertEquals(WIDTH * HEIGHT, index.get()) + assertEquals(DEFAULT_BITMAP_WIDTH * DEFAULT_BITMAP_HEIGHT, index.get()) } @Test @@ -95,7 +76,7 @@ class ParallelPixelProcessorTest { true } - assertEquals(WIDTH * HEIGHT, index.get()) + assertEquals(DEFAULT_BITMAP_WIDTH * DEFAULT_BITMAP_HEIGHT, index.get()) } @Test @@ -124,7 +105,7 @@ class ParallelPixelProcessorTest { } private fun assertPosition(index: Int, position: Pair) { - val (x, y) = pixelProcessor.getPosition(index, WIDTH) + val (x, y) = pixelProcessor.getPosition(index, DEFAULT_BITMAP_WIDTH) assertEquals(position, x to y) } diff --git a/Library/src/test/java/dev/testify/core/processor/compare/FuzzyCompareTest.kt b/Library/src/test/java/dev/testify/core/processor/compare/FuzzyCompareTest.kt new file mode 100644 index 00000000..bee593ac --- /dev/null +++ b/Library/src/test/java/dev/testify/core/processor/compare/FuzzyCompareTest.kt @@ -0,0 +1,130 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.core.processor.compare + +import com.google.common.truth.Truth.assertThat +import dev.testify.core.TestifyConfiguration +import dev.testify.core.processor.ParallelPixelProcessor +import dev.testify.core.processor._executorDispatcher +import dev.testify.core.processor.mockBitmap +import io.mockk.clearAllMocks +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(DelicateCoroutinesApi::class) +@ExperimentalCoroutinesApi +class FuzzyCompareTest { + + private val mainThreadSurrogate = newSingleThreadContext("UI thread") + + @Before + fun setUp() { + Dispatchers.setMain(mainThreadSurrogate) + _executorDispatcher = Dispatchers.Main + + mockkObject(ParallelPixelProcessor.Companion) + } + + @After + fun tearDown() { + clearAllMocks() + unmockkAll() + Dispatchers.resetMain() + mainThreadSurrogate.close() + } + + private val subject = FuzzyCompare(TestifyConfiguration()) + + @Test + fun `WHEN bitmaps are identical THEN succeed fast`() { + assertThat( + subject + .compareBitmaps( + mockBitmap(2, 2), + mockBitmap(2, 2) + ) + ).isTrue() + verify(exactly = 0) { ParallelPixelProcessor.Companion.create() } + } + + @Test + fun `WHEN bitmaps are different width THEN fail fast`() { + assertThat( + subject + .compareBitmaps( + mockBitmap(2, 2), + mockBitmap(4, 2) + ) + ).isFalse() + verify(exactly = 0) { ParallelPixelProcessor.Companion.create() } + } + + @Test + fun `WHEN bitmaps are different height THEN fail fast`() { + assertThat( + subject + .compareBitmaps( + mockBitmap(2, 2), + mockBitmap(2, 4) + ) + ).isFalse() + verify(exactly = 0) { ParallelPixelProcessor.Companion.create() } + } + + @Test + fun `WHEN differences within tolerance THEN pass`() { + val subject = FuzzyCompare(TestifyConfiguration(exactness = 0.9f)) + assertThat( + subject + .compareBitmaps( + mockBitmap(2, 2) { _, _ -> 0xFF0000FF.toInt() }, + mockBitmap(2, 2) { _, _ -> 0xFF0000FE.toInt() } + ) + ).isTrue() + verify { ParallelPixelProcessor.Companion.create() } + } + + @Test + fun `WHEN differences exceed tolerance THEN fail`() { + val subject = FuzzyCompare(TestifyConfiguration(exactness = 0.99f)) + assertThat( + subject + .compareBitmaps( + mockBitmap(2, 2) { _, _ -> 0xFF0000FF.toInt() }, + mockBitmap(2, 2) { _, _ -> 0xFF0000EE.toInt() } + ) + ).isFalse() + verify { ParallelPixelProcessor.Companion.create() } + } +} diff --git a/Library/src/test/java/dev/testify/core/processor/compare/RegionCompareTest.kt b/Library/src/test/java/dev/testify/core/processor/compare/RegionCompareTest.kt index 6c876ed8..e152bd58 100644 --- a/Library/src/test/java/dev/testify/core/processor/compare/RegionCompareTest.kt +++ b/Library/src/test/java/dev/testify/core/processor/compare/RegionCompareTest.kt @@ -28,6 +28,7 @@ import android.graphics.Bitmap import android.graphics.Rect import dev.testify.core.TestifyConfiguration import dev.testify.core.processor._executorDispatcher +import dev.testify.core.processor.mockRect import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.DelicateCoroutinesApi @@ -143,17 +144,4 @@ class RegionCompareTest { } } } - - // Rect is an android platform type so can't be instantiated directly. It must be mocked. - private fun mockRect(left: Int, top: Int, right: Int, bottom: Int): Rect = - mockk(relaxed = true) { - every { this@mockk.contains(any(), any()) } answers { - if (left > top) right + bottom - - val x: Int = arg(0) - val y: Int = arg(1) - - (x in left..right) && (y in top..bottom) - } - } } diff --git a/Library/src/test/java/dev/testify/core/processor/diff/HighContrastDiffTest.kt b/Library/src/test/java/dev/testify/core/processor/diff/HighContrastDiffTest.kt new file mode 100644 index 00000000..d5cde6e6 --- /dev/null +++ b/Library/src/test/java/dev/testify/core/processor/diff/HighContrastDiffTest.kt @@ -0,0 +1,238 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.core.processor.diff + +import android.app.Activity +import android.graphics.Color +import com.google.common.truth.Truth.assertThat +import dev.testify.core.processor.ParallelPixelProcessor +import dev.testify.core.processor._executorDispatcher +import dev.testify.core.processor.createBitmap +import dev.testify.core.processor.mockBitmap +import dev.testify.core.processor.mockRect +import dev.testify.output.DataDirectoryDestination +import dev.testify.output.getDestination +import dev.testify.saveBitmapToDestination +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) +class HighContrastDiffTest { + + private lateinit var subject: HighContrastDiff + + private val mockActivity = mockk(relaxed = true) + private val mockContext = mockActivity + private val mockDestination = mockk(relaxed = true) + private var diffPixels: IntArray = IntArray(0) + private val mainThreadSurrogate = newSingleThreadContext("UI thread") + private val mockBitmapBlue = mockBitmap(4, 4) { _, _ -> Color.BLUE } + private val mockBitmapGreen = mockBitmap(4, 4) { _, _ -> Color.GREEN } + + private fun forceSingleThreadedExecution() { + Dispatchers.setMain(mainThreadSurrogate) + _executorDispatcher = Dispatchers.Main + } + + @Before + fun setUp() { + forceSingleThreadedExecution() + mockkStatic(::getDestination) + mockkStatic(::saveBitmapToDestination) + mockkStatic("dev.testify.core.processor.BitmapExtentionsKt") + + every { any().createBitmap() } answers { + val receiver = firstArg() + diffPixels = IntArray(receiver.width * receiver.height) + receiver.pixels.copyInto(diffPixels) + mockBitmap( + receiver.width, + receiver.height + ) + } + every { getDestination(any(), any(), any(), any(), any()) } returns mockDestination + every { saveBitmapToDestination(any(), any(), any()) } returns true + + subject = HighContrastDiff.create(emptySet()) + } + + @After + fun tearDown() { + clearAllMocks() + unmockkAll() + repeat(diffPixels.size) { diffPixels[it] = 0 } + } + + @Test(expected = IllegalArgumentException::class) + fun `WHEN name not set THEN throw exception`() { + subject.generate(mockContext) + } + + @Test(expected = IllegalArgumentException::class) + fun `WHEN baseline not set THEN throw exception`() { + subject + .name("name") + .generate(mockContext) + } + + @Test(expected = IllegalArgumentException::class) + fun `WHEN current not set THEN throw exception`() { + subject + .name("name") + .baseline(mockk(relaxed = true)) + .generate(mockContext) + } + + @Test + fun `WHEN initialized correctly THEN generate bitmap`() { + val baseline = mockBitmap(4, 4) + val current = mockBitmap(4, 4) + + subject + .name("name") + .baseline(baseline) + .current(current) + .generate(mockContext) + + diffPixels.forEach { + assertThat(it).isEqualTo(Color.BLACK) + } + } + + @Test + fun `WHEN exactness is set AND bitmaps are the same THEN identical`() { + val baseline = mockBitmapBlue + val current = mockBitmapBlue + + subject + .name("name") + .baseline(baseline) + .current(current) + .exactness(0.9f) + .generate(mockContext) + + diffPixels.forEach { + assertThat(it).isEqualTo(Color.BLACK) + } + } + + @Test + fun `WHEN exactness is not set THEN identical`() { + val baseline = mockBitmapBlue + val current = mockBitmapGreen + + subject + .name("name") + .baseline(baseline) + .current(current) + .generate(mockContext) + + diffPixels.forEach { + assertThat(it).isEqualTo(Color.BLACK) + } + } + + @Test + fun `WHEN current is different THEN generate difference`() { + val baseline = mockBitmapBlue + val current = mockBitmapGreen + + subject + .name("name") + .baseline(baseline) + .current(current) + .exactness(0.9f) + .generate(mockContext) + + diffPixels.forEach { + assertThat(it).isEqualTo(Color.RED) + } + } + + @Test + fun `WHEN current is different AND within tolerance THEN generate warning`() { + val baseline = mockBitmap(4, 4) { _, _ -> 0xFF0000FF.toInt() } + val current = mockBitmap(4, 4) { _, _ -> 0xFF0000FE.toInt() } + + subject + .name("name") + .baseline(baseline) + .current(current) + .exactness(0.9f) + .generate(mockContext) + + diffPixels.forEach { + assertThat(it).isEqualTo(Color.YELLOW) + } + } + + @Test + fun `WHEN current is different AND excluded THEN generate ignored`() { + val baseline = mockBitmapBlue + val current = mockBitmapGreen + + val subject = HighContrastDiff.create( + exclusionRects = setOf( + mockRect(0, 0, 3, 3) + ) + ) + subject + .name("name") + .baseline(baseline) + .current(current) + .exactness(0.9f) + .generate(mockContext) + + diffPixels.forEach { + assertThat(it).isEqualTo(Color.GRAY) + } + } + + @Test + fun `WHEN tiny bitmap THEN generate bitmap`() { + val baseline = mockBitmap(1, 1) + val current = mockBitmap(1, 1) + + subject + .name("name") + .baseline(baseline) + .current(current) + .generate(mockContext) + + diffPixels.forEach { + assertThat(it).isEqualTo(Color.BLACK) + } + } +} diff --git a/Library/src/test/java/dev/testify/internal/helpers/ActivityProviderTest.kt b/Library/src/test/java/dev/testify/internal/helpers/ActivityProviderTest.kt new file mode 100644 index 00000000..04e9092b --- /dev/null +++ b/Library/src/test/java/dev/testify/internal/helpers/ActivityProviderTest.kt @@ -0,0 +1,60 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.internal.helpers + +import android.app.Activity +import android.app.Instrumentation +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import org.junit.Test + +class ActivityProviderTest { + + @Test + fun `WHEN provider is registered THEN return provider`() { + val provider1 = mockk>(relaxed = true) + val provider2 = mockk>(relaxed = true) + val instrumentation = mockk(relaxed = true) + + instrumentation.registerActivityProvider(provider1) + assertThat(instrumentation.getActivityProvider()).isEqualTo(provider1) + instrumentation.registerActivityProvider(provider2) + assertThat(instrumentation.getActivityProvider()).isEqualTo(provider2) + } + + @Test(expected = IllegalArgumentException::class) + fun `WHEN provider is requested from different context THEN throw exception`() { + val mockActivity1 = mockk(relaxed = true) + val mockActivity2 = mockk(relaxed = true) + + val instrumentation = mockk(relaxed = true) { + every { targetContext } returns mockActivity1 andThen mockActivity2 + } + val provider = mockk>(relaxed = true) + + instrumentation.registerActivityProvider(provider) + instrumentation.getActivityProvider() + } +} diff --git a/Library/src/test/java/dev/testify/internal/helpers/OrientationHelperTest.kt b/Library/src/test/java/dev/testify/internal/helpers/OrientationHelperTest.kt new file mode 100644 index 00000000..4bf92a1b --- /dev/null +++ b/Library/src/test/java/dev/testify/internal/helpers/OrientationHelperTest.kt @@ -0,0 +1,128 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.internal.helpers + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE +import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT +import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.lifecycle.ActivityLifecycleMonitor +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import com.google.common.truth.Truth.assertThat +import dev.testify.core.exception.UnexpectedOrientationException +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class OrientationHelperTest { + + private lateinit var subject: OrientationHelper + private val mockActivity = mockk(relaxed = true) + private val mockInstrumentation = mockk(relaxed = true) + private val mockActivityLifecycleMonitor = mockk(relaxed = true) + + @Before + fun before() { + mockkStatic(Instrumentation::class) + mockkStatic(InstrumentationRegistry::class) + mockkStatic("dev.testify.internal.helpers.ActivityProviderKt") + mockkStatic(ActivityLifecycleMonitorRegistry::class) + mockkStatic("dev.testify.internal.helpers.OrientationHelperKt") + + val mockActivityProvider: ActivityProvider = object : ActivityProvider { + override fun getActivity() = mockActivity + override fun assureActivity(intent: Intent?) {} + } + + every { InstrumentationRegistry.getInstrumentation() } returns mockInstrumentation + every { any().getActivityProvider() } returns mockActivityProvider + every { ActivityLifecycleMonitorRegistry.getInstance() } returns mockActivityLifecycleMonitor + + subject = spyk(OrientationHelper(SCREEN_ORIENTATION_LANDSCAPE)) + + every { subject.syncUiThread() } just runs + + val slot = slot() + every { mockActivity.runOnUiThread(capture(slot)) } answers { + slot.captured.run() + } + + every { mockActivity.requestedOrientation = any() } answers { + subject.lifecycleCallback(mockActivity, Stage.RESUMED) + } + } + + @Test + fun `WHEN constructed with null THEN OrientationHelper is valid`() { + assertThat(OrientationHelper(null)).isNotNull() + } + + @Test + fun `WHEN constructed with SCREEN_ORIENTATION_LANDSCAPE THEN OrientationHelper is valid`() { + assertThat(OrientationHelper(SCREEN_ORIENTATION_LANDSCAPE)).isNotNull() + } + + @Test + fun `WHEN constructed with SCREEN_ORIENTATION_PORTRAIT THEN OrientationHelper is valid`() { + assertThat(OrientationHelper(SCREEN_ORIENTATION_PORTRAIT)).isNotNull() + } + + @Test(expected = IllegalArgumentException::class) + fun `WHEN constructed with unsupported SCREEN_ORIENTATION THEN OrientationHelper is not created`() { + OrientationHelper(SCREEN_ORIENTATION_UNSPECIFIED) + } + + @Test + fun `WHEN activity orientation does not match requested orientation THEN change orientation`() { + every { mockActivity.isRequestedOrientation(SCREEN_ORIENTATION_LANDSCAPE) } returns false + subject.afterActivityLaunched() + verify { subject.changeOrientation(mockActivity, SCREEN_ORIENTATION_LANDSCAPE) } + verify { mockActivity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE } + } + + @Test + fun `WHEN activity orientation matches requested orientation THEN do not change orientation`() { + every { mockActivity.isRequestedOrientation(SCREEN_ORIENTATION_LANDSCAPE) } returns true + subject.afterActivityLaunched() + verify(exactly = 0) { subject.changeOrientation(mockActivity, SCREEN_ORIENTATION_LANDSCAPE) } + verify(exactly = 0) { mockActivity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE } + } + + @Test(expected = UnexpectedOrientationException::class) + fun `WHEN activity is the wrong orientation THEN throw exception`() { + every { mockActivity.isRequestedOrientation(SCREEN_ORIENTATION_LANDSCAPE) } returns false + subject.assertOrientation() + } +} diff --git a/Library/src/test/java/dev/testify/internal/helpers/ResourceWrapperTest.kt b/Library/src/test/java/dev/testify/internal/helpers/ResourceWrapperTest.kt new file mode 100644 index 00000000..7a8225b8 --- /dev/null +++ b/Library/src/test/java/dev/testify/internal/helpers/ResourceWrapperTest.kt @@ -0,0 +1,123 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.internal.helpers + +import android.app.Activity +import android.os.Build +import com.google.common.truth.Truth.assertThat +import dev.testify.core.exception.ActivityMustImplementResourceOverrideException +import dev.testify.core.exception.TestMustWrapContextException +import dev.testify.resources.TestifyResourcesOverride +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import java.util.Locale + +class ResourceWrapperTest { + + private lateinit var subject: ResourceWrapper + private val mockActivity = mockk(relaxed = true) + private val mockWrappedResource: WrappedResource = mockk(relaxed = true) + + @Before + fun before() { + mockkStatic(::buildVersionSdkInt) + every { buildVersionSdkInt() } returns Build.VERSION_CODES.M + + subject = spyk(ResourceWrapper) + subject.reset() + } + + @Test + fun `WHEN object created THEN not wrapped`() { + assertThat(subject.isWrapped).isFalse() + assertThat(subject.wrappedResources).isEmpty() + } + + @Test + fun `WHEN wrap THEN update context`() { + subject.addOverride(mockWrappedResource) + subject.wrap(mockActivity) + assertThat(subject.isWrapped).isTrue() + verify { mockWrappedResource.updateContext(mockActivity) } + } + + @Test + fun `WHEN reset THEN remove all wrapped resources`() { + subject.addOverride(mockWrappedResource) + subject.reset() + assertThat(subject.wrappedResources).isEmpty() + assertThat(subject.isWrapped).isFalse() + } + + @Test + fun `WHEN activity lifecycle THEN call all wrapped resources`() { + val mockWrappedResource2: WrappedResource = mockk(relaxed = true) + + subject.addOverride(mockWrappedResource) + subject.addOverride(mockWrappedResource2) + assertThat(subject.wrappedResources).hasSize(2) + + subject.beforeActivityLaunched() + subject.afterActivityLaunched(mockActivity) + + verify { mockWrappedResource.beforeActivityLaunched() } + verify { mockWrappedResource2.beforeActivityLaunched() } + verify { mockWrappedResource.afterActivityLaunched(mockActivity) } + verify { mockWrappedResource2.afterActivityLaunched(mockActivity) } + } + + @Test + fun `WHEN afterTestFinished THEN reset`() { + subject.addOverride(mockWrappedResource) + subject.afterTestFinished(mockActivity) + verify { mockWrappedResource.afterTestFinished(mockActivity) } + verify { subject.reset() } + assertThat(subject.isWrapped).isFalse() + } + + @Test(expected = ActivityMustImplementResourceOverrideException::class) + fun `WHEN activity does not implement TestifyResourcesOverride THEN throws exception`() { + every { buildVersionSdkInt() } returns Build.VERSION_CODES.N + subject.addOverride(mockWrappedResource) + subject.afterActivityLaunched(mockActivity) + } + + @Test(expected = TestMustWrapContextException::class) + fun `WHEN activity is not wrapped THEN throws TestMustWrapContextException`() { + every { buildVersionSdkInt() } returns Build.VERSION_CODES.N + + val mockTestifyResourcesOverride = mockk( + relaxed = true, + moreInterfaces = arrayOf(TestifyResourcesOverride::class) + ) + + subject.addOverride(mockWrappedResource) + subject.afterActivityLaunched(mockTestifyResourcesOverride) + } +} diff --git a/Library/src/test/java/dev/testify/internal/helpers/WrappedFontScaleTest.kt b/Library/src/test/java/dev/testify/internal/helpers/WrappedFontScaleTest.kt new file mode 100644 index 00000000..f1de1737 --- /dev/null +++ b/Library/src/test/java/dev/testify/internal/helpers/WrappedFontScaleTest.kt @@ -0,0 +1,101 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.internal.helpers + +import android.app.Activity +import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class WrappedFontScaleTest { + + private lateinit var subject: WrappedFontScale + private val mockActivity = mockk(relaxed = true) + + @Before + fun before() { + mockkStatic("dev.testify.internal.helpers.WrappedFontScaleKt") + + val mockResources = mockk(relaxed = true) + val dummyConfiguration = Configuration().apply { + fontScale = 1.5f + } + + every { mockResources.configuration } returns dummyConfiguration + every { mockActivity.resources } returns mockResources + + every { any().updateResources(any()) } returns mockk() + every { any().updateResourcesLegacy(any()) } returns mockk() + + subject = WrappedFontScale(overrideValue = 2.0f) + } + + @Test + fun `Override value is set`() { + subject.test(mockActivity) + assertThat(subject.defaultValue).isEqualTo(1.5f) + assertThat(subject.overrideValue).isEqualTo(2.0f) + + verify { mockActivity.updateResources(2.0f) } + verify(exactly = 0) { mockActivity.updateResourcesLegacy(any()) } + } + + @Test + fun `Override value can be changed before the activity launches`() { + subject.overrideValue = 3.0f + subject.test(mockActivity) + assertThat(subject.defaultValue).isEqualTo(1.5f) + assertThat(subject.overrideValue).isEqualTo(3.0f) + verify { mockActivity.updateResources(3.0f) } + verify(exactly = 0) { mockActivity.updateResourcesLegacy(any()) } + } + + @Test + fun `Override value is set - Android M`() { + subject.testMaxSdkVersionM(mockActivity) + assertThat(subject.defaultValue).isEqualTo(1.5f) + assertThat(subject.overrideValue).isEqualTo(2.0f) + + verify { mockActivity.updateResourcesLegacy(2.0f) } + verify(exactly = 0) { mockActivity.updateResources(any()) } + } + + @Test + fun `Override value can be changed before the activity launches - Android M`() { + subject.overrideValue = 3.0f + subject.testMaxSdkVersionM(mockActivity) + assertThat(subject.defaultValue).isEqualTo(1.5f) + assertThat(subject.overrideValue).isEqualTo(3.0f) + + verify { mockActivity.updateResourcesLegacy(3.0f) } + verify(exactly = 0) { mockActivity.updateResources(any()) } + } +} diff --git a/Library/src/test/java/dev/testify/internal/helpers/WrappedLocaleTest.kt b/Library/src/test/java/dev/testify/internal/helpers/WrappedLocaleTest.kt new file mode 100644 index 00000000..a65072e6 --- /dev/null +++ b/Library/src/test/java/dev/testify/internal/helpers/WrappedLocaleTest.kt @@ -0,0 +1,95 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.internal.helpers + +import android.app.Activity +import android.content.Context +import com.google.common.truth.Truth.assertThat +import dev.testify.internal.extensions.updateResources +import dev.testify.internal.extensions.updateResourcesLegacy +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import java.util.Locale + +class WrappedLocaleTest { + + private lateinit var subject: WrappedLocale + private val mockActivity = mockk(relaxed = true) + + @Before + fun before() { + mockkStatic("dev.testify.internal.extensions.LocaleExtensionsKt") + + every { any().updateResources(any()) } returns mockk() + every { any().updateResourcesLegacy(any()) } returns mockk() + + Locale.setDefault(Locale.US) + subject = WrappedLocale(overrideValue = Locale.CANADA_FRENCH) + } + + @Test + fun `Override value is set`() { + subject.test(mockActivity) + assertThat(subject.defaultValue).isEqualTo(Locale.US) + assertThat(subject.overrideValue).isEqualTo(Locale.CANADA_FRENCH) + + verify { mockActivity.updateResources(Locale.CANADA_FRENCH) } + verify(exactly = 0) { mockActivity.updateResourcesLegacy(any()) } + } + + @Test + fun `Override value can be changed before the activity launches`() { + subject.overrideValue = Locale.JAPAN + subject.test(mockActivity) + assertThat(subject.defaultValue).isEqualTo(Locale.US) + assertThat(subject.overrideValue).isEqualTo(Locale.JAPAN) + verify { mockActivity.updateResources(Locale.JAPAN) } + verify(exactly = 0) { mockActivity.updateResourcesLegacy(any()) } + } + + @Test + fun `Override value is set - Android M`() { + subject.testMaxSdkVersionM(mockActivity) + assertThat(subject.defaultValue).isEqualTo(Locale.US) + assertThat(subject.overrideValue).isEqualTo(Locale.CANADA_FRENCH) + + verify { mockActivity.updateResourcesLegacy(Locale.CANADA_FRENCH) } + verify(exactly = 0) { mockActivity.updateResources(any()) } + } + + @Test + fun `Override value can be changed before the activity launches - Android M`() { + subject.overrideValue = Locale.JAPAN + subject.testMaxSdkVersionM(mockActivity) + assertThat(subject.defaultValue).isEqualTo(Locale.US) + assertThat(subject.overrideValue).isEqualTo(Locale.JAPAN) + + verify { mockActivity.updateResourcesLegacy(Locale.JAPAN) } + verify(exactly = 0) { mockActivity.updateResources(any()) } + } +} diff --git a/Library/src/test/java/dev/testify/internal/helpers/WrappedResourceTest.kt b/Library/src/test/java/dev/testify/internal/helpers/WrappedResourceTest.kt new file mode 100644 index 00000000..0f806fad --- /dev/null +++ b/Library/src/test/java/dev/testify/internal/helpers/WrappedResourceTest.kt @@ -0,0 +1,53 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.internal.helpers + +import android.app.Activity +import android.os.Build +import io.mockk.every +import io.mockk.mockkStatic + +/** + * Simulate the lifecycle applied to WrappedResource on API >= N + */ +fun WrappedResource<*>.test(mockActivity: Activity) { + mockkStatic(::buildVersionSdkInt) + every { buildVersionSdkInt() } returns Build.VERSION_CODES.N + + beforeActivityLaunched() + updateContext(mockActivity) + afterTestFinished(mockActivity) +} + +/** + * Simulate the lifecycle applied to WrappedResource on API <= N + */ +fun WrappedResource<*>.testMaxSdkVersionM(mockActivity: Activity) { + mockkStatic(::buildVersionSdkInt) + every { buildVersionSdkInt() } returns Build.VERSION_CODES.M + + beforeActivityLaunched() + afterActivityLaunched(mockActivity) + afterTestFinished(mockActivity) +} diff --git a/Library/src/test/java/dev/testify/internal/modification/StaticPasswordTransformationMethodTest.kt b/Library/src/test/java/dev/testify/internal/modification/StaticPasswordTransformationMethodTest.kt new file mode 100644 index 00000000..dcf1a2ba --- /dev/null +++ b/Library/src/test/java/dev/testify/internal/modification/StaticPasswordTransformationMethodTest.kt @@ -0,0 +1,39 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.internal.modification + +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import org.junit.Test + +class StaticPasswordTransformationMethodTest { + + private val subject = StaticPasswordTransformationMethod() + + @Test + fun default() { + val replacement = subject.getTransformation("abcdef", mockk()) + assertThat(replacement.subSequence(0, 6)).isEqualTo("••••••") + } +} diff --git a/Library/src/test/java/dev/testify/report/ReporterTest.kt b/Library/src/test/java/dev/testify/report/ReporterTest.kt index 9c22cb85..286873d8 100644 --- a/Library/src/test/java/dev/testify/report/ReporterTest.kt +++ b/Library/src/test/java/dev/testify/report/ReporterTest.kt @@ -26,7 +26,10 @@ package dev.testify.report import android.app.Instrumentation import android.content.Context +import com.google.common.truth.Truth.assertThat import dev.testify.TestDescription +import dev.testify.core.getDeviceDescription +import dev.testify.output.Destination import dev.testify.output.getDestination import io.mockk.every import io.mockk.just @@ -67,23 +70,30 @@ internal open class ReporterTest { } mockContext = mockk(relaxed = true) { - every { getExternalFilesDir(any()) } returns File("foo") + every { getExternalFilesDir(any()) } returns File("ext") every { getDir("testify", any()) } returns File("/data/data/com.app.example/app_testify") every { getExternalFilesDir(any()) } returns File("/sdcard") } + val mockDestination = mockk(relaxed = true) { + every { assureDestination(any()) } returns true + } + reporter = spyk(Reporter.create(mockContext, mockSession)) reporter.configureMocks(BODY_LINES) mockkStatic(::getDestination) + mockkStatic(::getDeviceDescription) every { mockInstrumentation.context } returns mockContext every { mockFile.exists() } returns true + every { getDeviceDescription(any()) } returns "device" + every { getDestination(any(), any(), any(), any(), any()) } returns mockDestination + every { reporter.finalize() } returns true } private fun Reporter.configureMocks(body: List? = null) { - every { getBaselinePath() } returns "foo" - every { getOutputPath() } returns "bar" + every { getOutputPath() } returns "path" every { getReportFile() } returns mockFile every { writeToFile(any(), any()) } just runs every { clearFile(mockFile) } just runs @@ -99,22 +109,24 @@ internal open class ReporterTest { @Test fun `startTest() produces the expected yaml`() { reporter.startTest(mockDescription) + reporter.finalize() assertEquals( " - test:\n" + " name: startTest\n" + " class: ReporterTest\n" + - " package: dev.testify\n", + " package: dev.testify.report\n", reporter.yaml ) } @Test fun `captureOutput() produces the expected yaml`() { + every { reporter.getBaselinePath() } returns "path" reporter.captureOutput() assertEquals( - " baseline_image: assets/foo\n" + - " test_image: bar\n", + " baseline_image: assets/path\n" + + " test_image: path\n", reporter.yaml ) } @@ -177,13 +189,6 @@ internal open class ReporterTest { fun `output file destination`() { val reporter = spyk(Reporter.create(mockContext, mockSession)) every { reporter.getEnvironmentArguments() } returns mockk() - every { - getDestination( - any(), any(), any(), any(), any() - ) - } returns mockk(relaxed = true) { - every { assureDestination(any()) } returns true - } reporter.getReportFile() @@ -222,9 +227,9 @@ internal open class ReporterTest { " - test:\n" + " name: startTest\n" + " class: ReporterTest\n" + - " package: dev.testify\n" + - " baseline_image: assets/foo\n" + - " test_image: bar\n" + + " package: dev.testify.report\n" + + " baseline_image: assets/screenshots/device/startTest.png\n" + + " test_image: path\n" + " status: PASS\n", yaml ) @@ -265,16 +270,16 @@ internal open class ReporterTest { assertEquals(" - test:", lines[8]) assertEquals(" name: skipTest", lines[9]) assertEquals(" class: ReporterTest", lines[10]) - assertEquals(" package: dev.testify", lines[11]) - assertEquals(" baseline_image: assets/foo", lines[12]) - assertEquals(" test_image: bar", lines[13]) + assertEquals(" package: dev.testify.report", lines[11]) + assertEquals(" baseline_image: assets/screenshots/device/skipTest.png", lines[12]) + assertEquals(" test_image: path", lines[13]) assertEquals(" status: SKIP", lines[14]) assertEquals(" - test:", lines[15]) assertEquals(" name: failingTest", lines[16]) assertEquals(" class: ReporterTest", lines[17]) assertEquals(" package: dev.testify", lines[18]) - assertEquals(" baseline_image: assets/foo", lines[19]) - assertEquals(" test_image: bar", lines[20]) + assertEquals(" baseline_image: assets/device", lines[19]) + assertEquals(" test_image: path", lines[20]) assertEquals(" status: FAIL", lines[21]) assertEquals(" cause: UNKNOWN", lines[22]) assertEquals(" description: \"This is a failure\"", lines[23]) @@ -282,8 +287,8 @@ internal open class ReporterTest { assertEquals(" name: passingTest", lines[25]) assertEquals(" class: ReporterTest", lines[26]) assertEquals(" package: dev.testify", lines[27]) - assertEquals(" baseline_image: assets/foo", lines[28]) - assertEquals(" test_image: bar", lines[29]) + assertEquals(" baseline_image: assets/device", lines[28]) + assertEquals(" test_image: path", lines[29]) assertEquals(" status: PASS", lines[30]) } @@ -307,8 +312,8 @@ internal open class ReporterTest { " name: passingTest", " class: ReporterTest", " package: dev.testify", - " baseline_image: assets/foo", - " test_image: bar", + " baseline_image: assets/device", + " test_image: path", " status: PASS" ) @@ -341,8 +346,8 @@ internal open class ReporterTest { " name: failingTest", " class: ReporterTest", " package: dev.testify", - " baseline_image: assets/foo", - " test_image: bar", + " baseline_image: assets/device", + " test_image: path", " status: FAIL", " cause: UNKNOWN", " description: \"This is a failure\"", @@ -350,8 +355,8 @@ internal open class ReporterTest { " name: passingTest", " class: ReporterTest", " package: dev.testify", - " baseline_image: assets/foo", - " test_image: bar", + " baseline_image: assets/device", + " test_image: path", " status: PASS" ) @@ -408,4 +413,9 @@ internal open class ReporterTest { " status: PASS" ) } + + @Test + fun `verify headerLineCount`() { + assertThat(reporter.headerLineCount).isEqualTo(8) + } } diff --git a/Samples/Legacy/src/androidTest/java/dev/testify/sample/TestingResourcesCounterExampleTest.kt b/Samples/Legacy/src/androidTest/java/dev/testify/sample/TestingResourcesCounterExampleTest.kt index 0e25cdb5..160fc60a 100644 --- a/Samples/Legacy/src/androidTest/java/dev/testify/sample/TestingResourcesCounterExampleTest.kt +++ b/Samples/Legacy/src/androidTest/java/dev/testify/sample/TestingResourcesCounterExampleTest.kt @@ -43,6 +43,7 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mockito.Mockito.spy import java.util.Locale /** @@ -63,7 +64,7 @@ class TestingResourcesCounterExampleTest { @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) @Test(expected = ActivityMustImplementResourceOverrideException::class) fun usingSetLocaleRequiresActivityToImplementResourceOverride() { - rule.isDebugMode = true + rule.assertSameInvoked = true val activity = mock().apply { doReturn("TestHarnessActivity").whenever(this).localClassName }