diff options
55 files changed, 18191 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95e126a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/todo.org +/docs/ +/tests/*-output.log @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>.
\ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..79bf631 --- /dev/null +++ b/Makefile @@ -0,0 +1,114 @@ +# Makefile for chime.el +# +# Usage: +# make test - Run all tests (unit + integration) +# make test-unit - Run unit tests only +# make test-integration - Run integration tests only +# make test-file FILE=test-chime-notify.el - Run specific test file +# make test-name TEST=test-chime-check-* - Run tests matching pattern +# make clean - Remove generated files + +# Emacs binary to use (override with: make EMACS=emacs29 test) +EMACS ?= emacs + +# Test directories and files +TEST_DIR = tests +UNIT_TESTS = $(filter-out $(TEST_DIR)/test-integration-%.el, $(wildcard $(TEST_DIR)/test-*.el)) +INTEGRATION_TESTS = $(wildcard $(TEST_DIR)/test-integration-*.el) +ALL_TESTS = $(UNIT_TESTS) $(INTEGRATION_TESTS) + +# Emacs batch flags +EMACS_BATCH = $(EMACS) --batch --no-site-file --no-site-lisp + +.PHONY: help test test-all test-unit test-integration test-file test-name clean + +# Default target +help: + @echo "Chime.el Test Targets:" + @echo "" + @echo " make test - Run all tests (unit + integration)" + @echo " make test-unit - Run unit tests only ($(words $(UNIT_TESTS)) files)" + @echo " make test-integration - Run integration tests only ($(words $(INTEGRATION_TESTS)) files)" + @echo " make test-file FILE=<filename> - Run specific test file" + @echo " make test-name TEST=<pattern> - Run tests matching pattern" + @echo " make clean - Remove generated files" + @echo "" + @echo "Examples:" + @echo " make test-file FILE=test-chime-notify.el" + @echo " make test-name TEST=test-chime-check-early-return" + @echo " make EMACS=emacs29 test # Use specific Emacs version" + +# Run all tests +test: test-all + +test-all: + @echo "[i] Running all tests ($(words $(ALL_TESTS)) files)..." + @$(MAKE) test-unit + @$(MAKE) test-integration + @echo "[✓] All tests complete" + +# Run unit tests only +test-unit: + @echo "[i] Running unit tests ($(words $(UNIT_TESTS)) files)..." + @failed=0; \ + for test in $(UNIT_TESTS); do \ + echo " Testing $$test..."; \ + (cd $(TEST_DIR) && $(EMACS_BATCH) -l ert -l $$(basename $$test) -f ert-run-tests-batch-and-exit) || failed=$$((failed + 1)); \ + done; \ + if [ $$failed -eq 0 ]; then \ + echo "[✓] All unit tests passed"; \ + else \ + echo "[✗] $$failed unit test file(s) failed"; \ + exit 1; \ + fi + +# Run integration tests only +test-integration: + @echo "[i] Running integration tests ($(words $(INTEGRATION_TESTS)) files)..." + @failed=0; \ + for test in $(INTEGRATION_TESTS); do \ + echo " Testing $$test..."; \ + (cd $(TEST_DIR) && $(EMACS_BATCH) -l ert -l $$(basename $$test) -f ert-run-tests-batch-and-exit) || failed=$$((failed + 1)); \ + done; \ + if [ $$failed -eq 0 ]; then \ + echo "[✓] All integration tests passed"; \ + else \ + echo "[✗] $$failed integration test file(s) failed"; \ + exit 1; \ + fi + +# Run specific test file +# Usage: make test-file FILE=test-chime-notify.el +test-file: +ifndef FILE + @echo "[✗] Error: FILE parameter required" + @echo "Usage: make test-file FILE=test-chime-notify.el" + @exit 1 +endif + @echo "[i] Running tests in $(FILE)..." + @cd $(TEST_DIR) && $(EMACS_BATCH) -l ert -l $(FILE) -f ert-run-tests-batch-and-exit + @echo "[✓] Tests in $(FILE) complete" + +# Run specific test by name/pattern +# Usage: make test-name TEST=test-chime-check-early-return +# make test-name TEST="test-chime-check-*" +test-name: +ifndef TEST + @echo "[✗] Error: TEST parameter required" + @echo "Usage: make test-name TEST=test-chime-check-early-return" + @echo " make test-name TEST='test-chime-check-*'" + @exit 1 +endif + @echo "[i] Running tests matching pattern: $(TEST)..." + @cd $(TEST_DIR) && $(EMACS_BATCH) \ + -l ert \ + $(foreach test,$(ALL_TESTS),-l $(notdir $(test))) \ + --eval '(ert-run-tests-batch-and-exit "$(TEST)")' + @echo "[✓] Tests matching '$(TEST)' complete" + +# Clean generated files +clean: + @echo "[i] Cleaning generated files..." + @find . -name "*.elc" -delete + @find $(TEST_DIR) -name "chime-test-*" -delete + @echo "[✓] Clean complete" diff --git a/README.org b/README.org new file mode 100644 index 0000000..56c061d --- /dev/null +++ b/README.org @@ -0,0 +1,1081 @@ + +* *CHIME Heralds Imminent Events* + +Customizable org notifications for Emacs with visual alerts, audible chimes, and modeline display. + +** Table of Contents + +- [[#about][About]] +- [[#features][Features]] +- [[#installation][Installation]] +- [[#quick-start][Quick Start]] +- [[#configuration][Configuration]] +- [[#usage][Usage]] +- [[#known-limitations][Known Limitations]] +- [[#full-example-configuration][Full Example Configuration]] +- [[#manual-check][Manual Check]] +- [[#troubleshooting][Troubleshooting]] +- [[#requirements][Requirements]] +- [[#testing][Testing]] +- [[#license][License]] +- [[#credits][Credits]] +- [[#migration-from-org-wild-notifier][Migration from org-wild-notifier]] +- [[#development][Development]] + +** About +:PROPERTIES: +:CUSTOM_ID: about +:END: + +CHIME (backronym: *CHIME Heralds Imminent Events*) provides notification support for your org-agenda events. Get visual notifications, a pleasant chime sound, and see your next upcoming event in your modeline. + +This is an updated and maintained fork of the abandoned [[https://github.com/akhramov/org-wild-notifier.el][org-wild-notifier]] project, renamed to CHIME with bug-fixes and new features. + +Note: while I've found this package to be quite reliable, it's still undergoing feature development and will be changing frequently. + +** Features +:PROPERTIES: +:CUSTOM_ID: features +:END: + +- *Visual notifications* with customizable alert times +- *Audible chime sound* when notifications are displayed +- *Interactive modeline display* of next upcoming event with extensive customization: + - Enable/disable modeline modifications + - Hover tooltip showing all upcoming events grouped by day + - Click to jump directly to event's org entry + - Customize notification text format (show/hide time, countdown, or title) + - Choose 12-hour or 24-hour time display + - Customize time-until format (verbose or compact) + - Configurable lookahead window and tooltip event limit +- Multiple notification times per event (e.g., 5 minutes before and at event time) +- Works with SCHEDULED and DEADLINE and just plain ol' regular timestamps +- Supports repeating timestamps (=+1w=, =.+1d=, =++1w=) +- Async background checking (runs every minute) +- Configurable notification filtering by keywords and tags +- [[https://github.com/cjennings/chime.el/tree/main/tests][Well-tested]], including with org-gcal + +** Installation +:PROPERTIES: +:CUSTOM_ID: installation +:END: + +This package is NOT YET available on MELPA. + +*** package-vc-install (Emacs 29+) + +#+BEGIN_SRC elisp +(unless (package-installed-p 'chime) + (package-vc-install "https://github.com/cjennings/chime.el")) +#+END_SRC + +*** use-package with :vc (Emacs 29+) + +#+BEGIN_SRC elisp +(use-package chime + :vc (:url "https://github.com/cjennings/chime.el" :rev :newest) + :after alert + :commands (chime-mode chime-check) + :bind ("C-c A" . chime-check) + :config + ;; Alert intervals: (minutes . severity) pairs + ;; Notify 5 minutes before (medium urgency) and at event time (high urgency) + (setq chime-alert-intervals '((5 . medium) (0 . high))) + + ;; Chime sound + (setq chime-play-sound t) + + ;; Modeline display (see "Modeline Display" section for more options) + (setq chime-enable-modeline t) + (setq chime-modeline-lookahead-minutes 60) + (setq chime-modeline-format " ⏰ %s") + + ;; Notification settings + (setq chime-notification-title "Reminder") + + ;; Don't filter by TODO keywords - notify for all events + (setq chime-keyword-whitelist nil) + (setq chime-keyword-blacklist nil) + + ;; Only notify for non-done items + (setq chime-predicate-blacklist + '(chime-done-keywords-predicate)) + + ;; Enable chime-mode automatically + (chime-mode 1)) +#+END_SRC + +*** Manual Installation (Recommended for Development) + +#+BEGIN_SRC elisp +;; Add to load-path +(add-to-list 'load-path "~/path/to/chime.el") + +;; Load and configure +(require 'chime) +#+END_SRC + +** Quick Start +:PROPERTIES: +:CUSTOM_ID: quick-start +:END: + +Minimal configuration: + +clone the package somewhere, then add + +#+BEGIN_SRC elisp + (add-to-list 'load-path "/path/to/chime.el/") + (require 'chime) + + ;; Alert intervals: notify 5 minutes before (medium) and at event time (high) + (setq chime-alert-intervals '((5 . medium) (0 . high))) + + ;; Enable notifications + (chime-mode 1) +#+END_SRC + +** Configuration +:PROPERTIES: +:CUSTOM_ID: configuration +:END: + +*** Polling Interval + +Control how often chime checks for upcoming events: + +#+BEGIN_SRC elisp +;; Default: check every 60 seconds (1 minute) - recommended for most users +(setq chime-check-interval 60) + +;; More responsive: check every 30 seconds +(setq chime-check-interval 30) + +;; Reduce polling overhead: check every 5 minutes +(setq chime-check-interval 300) +#+END_SRC + +Lower values make notifications more responsive but increase system load. Higher values reduce polling overhead but may delay notifications slightly. + +**Choosing a polling interval:** + +- *120-300 seconds (2-5 minutes)*: Okay for reducing system load, but most people require more timely notifications. +- *60 seconds (default)*: Ideal for most users. Matches org's minute-based timestamps and provides timely notifications with minimal overhead. +- *30 seconds*: Fine if you want quicker notification delivery. Reasonable resource usage. +- *15-10 seconds*: Maximum responsiveness, but you're polling 4-6 times more frequently for marginal precision gain on minute-based events. +- *Below 10 seconds*: Not recommended or supported. Org events are scheduled to the minute. Faster polling provides near-zero benefit while significantly increasing CPU, disk I/O, and battery usage. + +**Note:** Changes take effect after restarting chime-mode (=M-x chime-mode= twice, or restart Emacs). + +*** Alert Intervals + +Set when to receive notifications and their urgency levels using (minutes . severity) pairs: + +#+BEGIN_SRC elisp +;; Single notification 10 minutes before with medium urgency +(setq chime-alert-intervals '((10 . medium))) + +;; Multiple notifications with escalating urgency +(setq chime-alert-intervals '((10 . low) ;; 10 min before: low urgency + (5 . medium) ;; 5 min before: medium urgency + (0 . high))) ;; At event time: high urgency +#+END_SRC + +Severity levels (=high=, =medium=, =low=) control notification urgency and may affect how your notification system displays them. + +*** Chime Sound + +Control the audible chime that plays when notifications appear: + +#+BEGIN_SRC elisp +;; Enable/disable chime sound (default: t) +(setq chime-play-sound t) + +;; Use custom sound file (defaults to bundled chime.wav) +(setq chime-sound-file "/path/to/your/chime.wav") + +;; Disable sound completely (no sound file, no beep) +(setq chime-sound-file nil) +#+END_SRC + +The package includes a pleasant chime sound in GPL-compatible WAV format. You can use your own sound file if preferred. + +*** Modeline Display + +Display your next upcoming event in your modeline: + +#+BEGIN_SRC elisp +;; Enable/disable modeline display (default: t) +(setq chime-enable-modeline t) + +;; Show events up to 60 minutes ahead (default: 60) +(setq chime-modeline-lookahead-minutes 60) + +;; Customize the modeline prefix format (default: " ⏰ %s") +(setq chime-modeline-format " [Next: %s]") +#+END_SRC + +The modeline will display the soonest event within the lookahead window, formatted as: +- Default: =⏰ Meeting with Team at 02:30 PM (in 15 minutes)= +- Updates automatically every minute + +**** Interactive Modeline Features + +The modeline text is interactive - you can click it and hover for more information: + +***** Tooltip + +Hover your mouse over the modeline event to see a tooltip showing all upcoming events within the lookahead window, grouped by day: + +#+BEGIN_EXAMPLE +Upcoming Events as of Mon Oct 28 2024 @ 02:00 PM + +Today, Oct 28: +───────────── +Team Meeting at 02:10 PM (in 10 minutes) +Code Review at 02:30 PM (in 30 minutes) +Coffee break at 02:45 PM (in 45 minutes) + +Tomorrow, Oct 29: +───────────── +Sprint Planning at 09:00 AM (tomorrow) +Quarterly Review at 02:00 PM (tomorrow) +#+END_EXAMPLE + +The tooltip displays up to 5 events by default. Configure the maximum with: + +#+BEGIN_SRC elisp +;; Show up to 10 events in tooltip +(setq chime-modeline-tooltip-max-events 10) + +;; Show all events in lookahead window (beware -- no limit!) +(setq chime-modeline-tooltip-max-events nil) +#+END_SRC + +***** Tooltip Lookahead Window + +The tooltip can show events beyond the modeline lookahead window. By default, it shows events up to 1 year (8760 hours) in the future, while the modeline only shows events within the next hour: + +#+BEGIN_SRC elisp +;; Modeline shows events within next 60 minutes (default) +(setq chime-modeline-lookahead-minutes 60) + +;; Tooltip shows events within next 8760 hours / 1 year (default) +(setq chime-tooltip-lookahead-hours 8760) + +;; Example: Show only today's events in tooltip (24 hours) +(setq chime-tooltip-lookahead-hours 24) + +;; Example: Show events for the next week in tooltip +(setq chime-tooltip-lookahead-hours 168) ; 7 days × 24 hours +#+END_SRC + +This separation allows you to: +- Keep the modeline focused on imminent events (tactical view) +- See a broader timeline in the tooltip (strategic view) + +***** Click Actions + +The modeline supports two click actions: + +- **Left-click**: Opens your calendar in a web browser (if configured) +- **Right-click**: Jumps directly to the event's org entry in its file + +To enable left-click calendar access, set your calendar URL: + +#+BEGIN_SRC elisp +;; Open Google Calendar on left-click +(setq chime-calendar-url "https://calendar.google.com") + +;; Or Outlook Calendar +(setq chime-calendar-url "https://outlook.office.com/calendar") + +;; Or any custom calendar web interface +(setq chime-calendar-url "https://your-calendar-url") +#+END_SRC + +When no calendar URL is set (default), left-click does nothing. Right-click always jumps to the next event in your org file. + +**** Customizing Modeline Content + +Control what information appears in the modeline with fine-grained formatting: + +***** Notification Text Format + +Customize which components are shown: + +#+BEGIN_SRC elisp +;; Default: title, time, and countdown +(setq chime-notification-text-format "%t at %T (%u)") +;; → "Meeting with Team at 02:30 PM (in 15 minutes)" + +;; Title and time only (no countdown) +(setq chime-notification-text-format "%t at %T") +;; → "Meeting with Team at 02:30 PM" + +;; Title and countdown only (no time) +(setq chime-notification-text-format "%t (%u)") +;; → "Meeting with Team (in 15 minutes)" + +;; Title only (minimal) +(setq chime-notification-text-format "%t") +;; → "Meeting with Team" + +;; Custom separator +(setq chime-notification-text-format "%t - %T") +;; → "Meeting with Team - 02:30 PM" + +;; Time first +(setq chime-notification-text-format "%T: %t") +;; → "02:30 PM: Meeting with Team" +#+END_SRC + +Available placeholders: +- =%t= - Event title +- =%T= - Event time (formatted per =chime-display-time-format-string=) +- =%u= - Time until event (formatted per =chime-time-left-format-*=) + +***** Event Time Format + +Choose between 12-hour and 24-hour time display: + +#+BEGIN_SRC elisp +;; 12-hour with AM/PM (default) +(setq chime-display-time-format-string "%I:%M %p") +;; → "02:30 PM" + +;; 24-hour format +(setq chime-display-time-format-string "%H:%M") +;; → "14:30" + +;; 12-hour without space before AM/PM +(setq chime-display-time-format-string "%I:%M%p") +;; → "02:30PM" + +;; 12-hour with lowercase am/pm +(setq chime-display-time-format-string "%I:%M %P") +;; → "02:30 pm" +#+END_SRC + +Available format codes: +- =%I= - Hour (01-12, 12-hour format) +- =%H= - Hour (00-23, 24-hour format) +- =%M= - Minutes (00-59) +- =%p= - AM/PM (uppercase) +- =%P= - am/pm (lowercase) + +***** Time-Until Format + +Customize how the countdown is displayed: + +#+BEGIN_SRC elisp +;; Default: verbose format +(setq chime-time-left-format-short "in %M") ; Under 1 hour +(setq chime-time-left-format-long "in %H %M") ; 1 hour or more +;; → "in 10 minutes" or "in 1 hour 30 minutes" + +;; Compact format +(setq chime-time-left-format-short "in %mm") +(setq chime-time-left-format-long "in %hh %mm") +;; → "in 10m" or "in 1h 30m" + +;; Very compact (no prefix) +(setq chime-time-left-format-short "%mm") +(setq chime-time-left-format-long "%hh%mm") +;; → "10m" or "1h30m" + +;; Custom "at event time" message +(setq chime-time-left-format-at-event "NOW!") +;; → "NOW!" instead of "right now" +#+END_SRC + +Available format codes (from =format-seconds=): +- =%h= / =%H= - Hours (number only / with unit name) +- =%m= / =%M= - Minutes (number only / with unit name) + +***** Title Truncation + +Limit the length of long event titles to conserve modeline space: + +#+BEGIN_SRC elisp +;; No truncation - show full title (default) +(setq chime-max-title-length nil) +;; → " ⏰ Very Long Meeting Title That Goes On And On ( in 10m)" + +;; Truncate to 25 characters +(setq chime-max-title-length 25) +;; → " ⏰ Very Long Meeting Titl... ( in 10m)" + +;; Truncate to 15 characters +(setq chime-max-title-length 15) +;; → " ⏰ Very Long Me... ( in 10m)" +#+END_SRC + +**Important:** This setting affects *only the event title* (%t), not the icon, time, or countdown. The icon comes from =chime-modeline-format= and is added separately. + +The truncation includes the "..." in the character count, so a 15-character limit means up to 12 characters of title plus "...". + +Minimum recommended value: 10 characters. + +***** Complete Compact Example + +For maximum modeline space savings: + +#+BEGIN_SRC elisp +(setq chime-enable-modeline t) +(setq chime-modeline-lookahead-minutes 60) +(setq chime-modeline-format " ⏰%s") ; Minimal prefix +(setq chime-notification-text-format "%t (%u)") ; No time shown +(setq chime-time-left-format-short "%mm") ; Compact short +(setq chime-time-left-format-long "%hh%mm") ; Compact long +(setq chime-max-title-length 20) ; Truncate long titles +;; Result: "⏰Meeting (10m)" or "⏰Very Long Meeti... (1h30m)" +#+END_SRC + +***** Disabling Modeline Display + +#+BEGIN_SRC elisp +;; Completely disable modeline modifications +(setq chime-enable-modeline nil) + +;; Alternative: set lookahead to 0 (legacy method) +(setq chime-modeline-lookahead-minutes 0) +#+END_SRC + +*** Notification Settings + +#+BEGIN_SRC elisp +;; Notification title +(setq chime-notification-title "Reminder") + +;; Note: Severity is now configured per-interval in chime-alert-intervals +;; See "Alert Intervals" section above +#+END_SRC + +*** Filtering + +#+BEGIN_SRC elisp +;; Only notify for specific TODO keywords +(setq chime-keyword-whitelist '("TODO" "NEXT")) + +;; Never notify for these keywords +(setq chime-keyword-blacklist '("DONE" "CANCELLED")) + +;; Only notify for specific tags +(setq chime-tags-whitelist '("@important")) + +;; Never notify for these tags +(setq chime-tags-blacklist '("someday")) +#+END_SRC + +**** Whitelist and Blacklist Precedence + +If the same keyword or tag appears in both a whitelist and blacklist, the **blacklist takes precedence** and the item will be filtered out. This ensures sensitive information cannot accidentally be exposed in notifications. + +Examples: +- Item with =TODO= keyword when =TODO= is in both ~chime-keyword-whitelist~ and ~chime-keyword-blacklist~ → **filtered out** (blacklist wins) +- Item with =:urgent:= tag when =urgent= is in both ~chime-tags-whitelist~ and ~chime-tags-blacklist~ → **filtered out** (blacklist wins) +- Item with whitelisted keyword but blacklisted tag → **filtered out** (blacklist wins) + +Most users configure either whitelists or blacklists, not both. If you use both, ensure they don't overlap to avoid confusion. + +*** All-Day Events + +Chime distinguishes between *timed events* (with specific times like =10:00=) and *all-day events* (without times, such as birthdays or holidays). + +**** What are All-Day Events? + +All-day events are org timestamps without a time component: + +#+BEGIN_SRC org +,* Blake's Birthday +<2025-12-19 Fri> + +,* Holiday: Christmas +<2025-12-25 Thu> + +,* Multi-day Conference +<2025-11-10 Mon>--<2025-11-13 Thu> +#+END_SRC + +Compare with timed events: + +#+BEGIN_SRC org +,* Team Meeting +<2025-10-28 Tue 14:30-15:30> + +,* Doctor Appointment +SCHEDULED: <2025-10-30 Thu 10:00> +#+END_SRC + +**** Current Behavior + +**Modeline:** +- All-day events are *never* shown in the modeline +- Only timed events with specific times appear +- Rationale: Modeline shows urgent, time-sensitive items + +**Notifications:** +- All-day events can trigger notifications at configured times +- By default, =chime-day-wide-alert-times= is =nil= (notifications disabled) +- When set, chime will notify you of all-day events happening *today* at those times + +**** Configuring All-Day Event Notifications + +To receive notifications for all-day events (like birthdays): + +#+BEGIN_SRC elisp +;; Notify at 8:00 AM for all-day events happening today +(setq chime-day-wide-alert-times '("08:00")) + +;; Multiple notification times +(setq chime-day-wide-alert-times '("08:00" "17:00")) ; Morning and evening + +;; Disable all-day event notifications (default) +(setq chime-day-wide-alert-times nil) +#+END_SRC + +**Example workflow:** +1. You have =* Blake's Birthday <2025-12-19 Fri>= in your org file +2. On December 19th at 8:00 AM, chime notifies: "Blake's Birthday is due or scheduled today" +3. This gives you a reminder to send birthday wishes or buy a gift + +**** Showing Overdue TODOs + +Control whether overdue TODO items and past events appear alongside all-day event notifications: + +#+BEGIN_SRC elisp +;; Show overdue items with all-day event notifications (default: t) +(setq chime-show-any-overdue-with-day-wide-alerts t) + +;; Only show today's events, not overdue items from past days +(setq chime-show-any-overdue-with-day-wide-alerts nil) +#+END_SRC + +**When enabled (default =t=):** +- Shows today's DEADLINE/SCHEDULED tasks that have passed (e.g., 9am deadline when it's now 2pm) +- Shows today's all-day events even if you launch Emacs after the alert time (e.g., launch at 10am when alert was 8am) +- Shows all-day events from past days (e.g., yesterday's birthday, last week's holiday) + +**When disabled (=nil=):** +- Shows today's DEADLINE/SCHEDULED tasks that have passed ✓ +- Shows today's all-day events even if you launch Emacs late ✓ +- Hides all-day events from past days (prevents old birthday/holiday spam) ✓ + +Most users want the default (=t=) to catch overdue items. Disable it if you only want to see today's events and don't want past birthdays/holidays cluttering notifications. + +***** Understanding the Interplay with Alert Times + +The relationship between =chime-day-wide-alert-times= and =chime-show-any-overdue-with-day-wide-alerts= can be confusing: + +- =chime-day-wide-alert-times= controls **when** notifications fire (e.g., 8:00 AM) +- =chime-show-any-overdue-with-day-wide-alerts= controls **what happens if you miss that time** + +**Example scenario:** +#+BEGIN_EXAMPLE +You have: + (setq chime-day-wide-alert-times '("08:00")) + (setq chime-show-any-overdue-with-day-wide-alerts t) + +Today's birthday: * Blake's Birthday <2025-10-28 Tue> + +Timeline: +- 8:00 AM: Chime fires notification "Blake's Birthday is due or scheduled today" ✓ +- You close Emacs at 9:00 AM +- You relaunch Emacs at 2:00 PM (afternoon) +- Because overdue alerts are ENABLED (t), chime shows the notification again ✓ + → This catches you up on today's events you might have missed +#+END_EXAMPLE + +**If you disable overdue alerts:** +#+BEGIN_EXAMPLE + (setq chime-show-any-overdue-with-day-wide-alerts nil) + +Same scenario, but now: +- 8:00 AM: Chime fires notification ✓ +- You close Emacs at 9:00 AM +- You relaunch Emacs at 2:00 PM +- Because overdue alerts are DISABLED (nil), chime STILL shows today's birthday ✓ + → Today's events are always shown regardless of this setting + → This setting only hides events from PAST DAYS (yesterday, last week, etc.) +#+END_EXAMPLE + +**Key insight:** You'll always see today's all-day events when you launch Emacs, even if you missed the configured alert time. The =chime-show-any-overdue-with-day-wide-alerts= setting only controls whether you see events from *previous days*. + +**** Common Use Cases + +**Birthdays:** +#+BEGIN_SRC org +,* Blake Michael's Birthday +<2025-02-20 Thu> +#+END_SRC + +With =chime-day-wide-alert-times= set to ='("08:00")=, you'll get a morning reminder on the birthday. + +**Holidays:** +#+BEGIN_SRC org +,* Holiday: Thanksgiving +<2025-11-27 Thu> +#+END_SRC + +**Multi-day Events:** +#+BEGIN_SRC org +,* Conference: EmacsCon 2025 +<2025-11-10 Mon>--<2025-11-13 Thu> +#+END_SRC + +You'll receive notifications on each day of the conference at your configured alert times. + +**** Integration with org-contacts + +If you use [[https://repo.or.cz/org-contacts.git][org-contacts]] for managing contacts and birthdays, chime provides built-in integration to ensure birthdays appear in your agenda and trigger notifications. + +**The Problem:** + +Org-contacts stores birthdays as properties (=:BIRTHDAY: 1985-03-15=) and uses diary sexps (=%%(org-contacts-anniversaries)=) to display them in your agenda. However, chime's async subprocess doesn't have org-contacts loaded, causing "Bad sexp" errors and preventing birthdays from appearing. + +**The Solution:** + +Chime provides a two-part solution: + +1. **One-time conversion** for existing contacts +2. **Automatic capture template** for new contacts + +Both approaches add plain org timestamps alongside the =:BIRTHDAY:= property, preserving vCard export compatibility while enabling chime notifications. + +**Step 1: Convert Existing Contacts** + +Use the included conversion script to add birthday timestamps to your existing contacts file: + +#+BEGIN_SRC elisp +;; Load conversion script +(require 'convert-org-contacts-birthdays + (expand-file-name "convert-org-contacts-birthdays.el" + (file-name-directory (locate-library "chime")))) + +;; Convert your contacts file IN-PLACE (creates timestamped backup) +M-x chime-convert-contacts-in-place RET ~/org/contacts.org RET +#+END_SRC + +This modifies your contacts.org file, transforming: + +#+BEGIN_SRC org +,* Alice Anderson +:PROPERTIES: +:EMAIL: alice@example.com +:BIRTHDAY: 1985-03-15 +:END: +#+END_SRC + +Into: + +#+BEGIN_SRC org +,* Alice Anderson +:PROPERTIES: +:EMAIL: alice@example.com +:BIRTHDAY: 1985-03-15 +:END: +<1985-03-15 Sat +1y> +#+END_SRC + +**Safety:** The script creates a timestamped backup (=contacts.org.backup-YYYY-MM-DD-HHMMSS=) before making any changes. + +After conversion, comment out the diary sexp in your schedule file: + +#+BEGIN_SRC org +# %%(org-contacts-anniversaries) +#+END_SRC + +**Step 2: Enable Capture Template for New Contacts** + +To automatically add birthday timestamps when capturing new contacts: + +#+BEGIN_SRC elisp +;; Enable org-contacts integration +(setq chime-org-contacts-file "~/org/contacts.org") + +;; Optional: customize capture key (default: "C") +(setq chime-org-contacts-capture-key "C") + +;; Optional: customize heading (default: "Contacts") +(setq chime-org-contacts-heading "Contacts") +#+END_SRC + +This adds an org-capture template that: +- Prompts for contact details (name, email, phone, birthday, etc.) +- Automatically inserts a yearly repeating timestamp if birthday is provided +- Preserves the =:BIRTHDAY:= property for vCard export + +**Using the Capture Template:** + +1. Press =C-c c= (or your org-capture binding) +2. Press =C= (or your configured capture key) +3. Fill in contact information +4. Birthday timestamps are added automatically on save + +**Template Fields:** +- Name, Email, Phone, Address +- Birthday (YYYY-MM-DD or MM-DD format) +- Nickname, Company, Title, Website +- Note (instead of free-form text below properties) + +**Disabling the Integration:** + +Set =chime-org-contacts-file= to =nil= to disable the capture template: + +#+BEGIN_SRC elisp +(setq chime-org-contacts-file nil) ; Disabled by default +#+END_SRC + +**Result:** + +After setup, birthdays will: +- ✓ Appear in org-agenda +- ✓ Trigger chime notifications at configured times +- ✓ Work with chime's async subprocess (no "Bad sexp" errors) +- ✓ Still export to vCard via org-contacts +- ✓ Automatically include timestamps for new contacts + +** Usage +:PROPERTIES: +:CUSTOM_ID: usage +:END: + +*** Basic Event with Timestamp + +#+BEGIN_SRC org +,* Meeting with Team +<2025-10-25 Sat 14:00> +#+END_SRC + +Will notify at 14:00 (if =chime-alert-intervals= includes =(0 . severity)=). + +*** Events with SCHEDULED or DEADLINE + +#+BEGIN_SRC org +,* TODO Call Doctor +SCHEDULED: <2025-10-25 Sat 10:00> +#+END_SRC + +*** Repeating Events + +Repeating timestamps are fully supported: + +#+BEGIN_SRC org +,* TODO Weekly Team Meeting +SCHEDULED: <2025-10-25 Sat 14:00 +1w> + +,* TODO Daily Standup +SCHEDULED: <2025-10-25 Sat 09:00 +1d> + +,* TODO Review Email +SCHEDULED: <2025-10-25 Sat 08:00 .+1d> +#+END_SRC + +Supported repeaters: +- =+1w= - Repeat weekly from original date +- =.+1d= - Repeat daily from completion +- =++1w= - Repeat weekly from scheduled date + +** Known Limitations +:PROPERTIES: +:CUSTOM_ID: known-limitations +:END: + +*** S-expression Diary Entries Are Not Supported + +Note: org-contacts users will quickly discover the above unsupported format is how org-contacts integrate birthdays into your calendar. If you use org-contacts, you will not be automatically notified about your contacts birthdays. + +Specifically, this format is *not supported*: + +#+BEGIN_SRC org +,* TODO Daily Standup +SCHEDULED: <%%(memq (calendar-day-of-week date) '(1 2 3 4 5))> +#+END_SRC + +For those using this format outside of org-contacts, your workaround is to use standard repeating timestamps instead: + +#+BEGIN_SRC org +,* TODO Daily Standup +SCHEDULED: <2025-10-24 Fri 09:00 +1d> +#+END_SRC + +For Monday-Friday events, you can either: +1. Accept weekend notifications (mark as DONE on weekends) +2. Create 5 separate entries, one for each weekday with =+1w= repeater + +** Full Example Configuration +:PROPERTIES: +:CUSTOM_ID: full-example-configuration +:END: + +#+BEGIN_SRC elisp + (use-package chime + :vc (:url "https://github.com/cjennings/chime.el" :rev :newest) + :after alert + :commands (chime-mode chime-check) + :config + ;; Polling interval: check every 60 seconds (default) + (setq chime-check-interval 60) + + ;; Alert intervals: 5 minutes before (medium) and at event time (high) + (setq chime-alert-intervals '((5 . medium) (0 . high))) + + ;; Chime sound + (setq chime-play-sound t) + ;; Uses bundled chime.wav by default + + ;; Modeline display - compact format + (setq chime-enable-modeline t) + (setq chime-modeline-lookahead-minutes 120) ; Show events 2 hrs ahead + (setq chime-modeline-format " ⏰%s") ; Minimal prefix + (setq chime-notification-text-format "%t (%u)") ; Title + countdown only + (setq chime-display-time-format-string "%H:%M") ; 24-hour time + (setq chime-time-left-format-short "in %mm") ; Compact: "in 5m" + (setq chime-time-left-format-long "%hh%mm") ; Compact: "1h30m" + (setq chime-time-left-format-at-event "NOW!") ; Custom at-event message + + ;; Notification settings + (setq chime-notification-title "Reminder") + + ;; Don't filter by TODO keywords - notify for all events + (setq chime-keyword-whitelist nil) + (setq chime-keyword-blacklist nil) + + ;; Only notify for non-done items + (setq chime-predicate-blacklist + '(chime-done-keywords-predicate)) + + ;; Enable chime-mode automatically + (chime-mode 1)) +#+END_SRC + +** Manual Check +:PROPERTIES: +:CUSTOM_ID: manual-check +:END: + +You can manually trigger a notification check: + +#+BEGIN_SRC elisp +M-x chime-check +#+END_SRC + +** Troubleshooting +:PROPERTIES: +:CUSTOM_ID: troubleshooting +:END: + +*** No notifications appearing + +1. Verify chime-mode is enabled: =M-: chime-mode= +2. Check that alert is configured correctly: + #+BEGIN_SRC elisp + (setq alert-default-style 'libnotify) ; or 'notifications on some systems + #+END_SRC +3. Manually test: =M-x chime-check= +4. Check =*Messages*= buffer for error messages + +*** No sound playing + +1. Verify sound is enabled: =M-: chime-play-sound= should return =t= +2. Check sound file exists: =M-: (file-exists-p chime-sound-file)= +3. Test sound directly: =M-: (play-sound-file chime-sound-file)= +4. Ensure your system has audio support configured + +*** Events not being detected + +1. Ensure files are in =org-agenda-files= +2. Verify timestamps have time components: =<2025-10-25 Sat 14:00>= not =<2025-10-25 Sat>= +3. Check filtering settings (keyword/tag whitelist/blacklist) +4. Timestamps support both 24-hour (=14:00=) and 12-hour (=2:00pm=, =2:00 PM=) formats + +*** org-contacts diary sexp errors + +If you see errors like "Bad sexp at line 2: (let ((entry) (date '(10 29 2025))) (org-contacts-anniversaries))" in your =*emacs:err*= buffer, this is because chime's async subprocess doesn't have org-contacts loaded. + +*Symptoms:* +- No events appear in modeline despite having scheduled items +- =*emacs:err*= buffer shows "Bad sexp" errors for org-contacts +- Errors appear repeatedly (every minute during chime checks) + +*Solution 1: Load org-contacts in your config (Recommended)* + +Add this to your config BEFORE chime loads: + +#+BEGIN_SRC elisp +(require 'org-contacts nil t) ; Load if available, don't error if missing +#+END_SRC + +Chime will automatically load org-contacts in its async subprocess if it's installed. + +*Solution 2: Comment out the sexp line* + +In your org file (usually =schedule.org=), comment out or remove: + +#+BEGIN_SRC org +# %%(org-contacts-anniversaries) +#+END_SRC + +*Solution 3: Convert to plain timestamps* + +Use the conversion script to generate plain org entries from your contacts. + +*Safety Note:* This script is READ-ONLY. It reads from your org-contacts database but never modifies it. The output is written to a new file. + +*Step-by-step process:* + +1. (Optional but recommended) Backup your org files + +2. Load and run the conversion script: + #+BEGIN_SRC elisp + ;; Load the conversion script (included in chime.el repo) + (require 'convert-org-contacts-birthdays + (expand-file-name "convert-org-contacts-birthdays.el" + (file-name-directory (locate-library "chime")))) + + ;; Convert contacts to plain org entries + M-x chime-convert-contacts-to-file RET ~/birthdays.org RET + #+END_SRC + + This creates a NEW file (~/birthdays.org) with entries like: + #+BEGIN_SRC org + *** John Doe's Birthday + <2026-03-15 Sun +1y> + #+END_SRC + +3. When prompted, allow the script to add =birthdays.org= to =org-agenda-files= + (or add it manually later) + +4. Comment out or remove the sexp line from your schedule file: + #+BEGIN_SRC org + # %%(org-contacts-anniversaries) + #+END_SRC + +5. Restart chime or run =M-x chime-check= to verify birthdays appear without errors + +*Why this happens:* + +Org-mode diary sexps like =%%(org-contacts-anniversaries)= are dynamic expressions evaluated during agenda building. Chime runs agenda building in an async subprocess for performance, but that subprocess needs the generating package (org-contacts) loaded. Chime includes org-contacts as a soft dependency, but it must be installed for the sexp to work. + +*** Multiple Emacs instances producing duplicate notifications + +If you receive duplicate notifications for every event, you likely have multiple Emacs processes running with chime-mode enabled. + +*Symptoms:* +- Receiving 2 (or more) identical notifications for each event +- Notifications appear at the same time but as separate alerts + +*Common Scenario:* + +You're running chime with an emacsclient connected to an emacs daemon (=emacs --daemon=), then launch a separate Emacs process from the command line. Each process runs its own instance of chime-mode, resulting in duplicate notifications. + +*Example:* +#+BEGIN_SRC bash +# Start emacs daemon with chime-mode enabled +emacs --daemon + +# Connect with emacsclient (uses daemon - chime runs here) +emacsclient -c + +# Later, accidentally launch standalone Emacs process +emacs & # This creates a SECOND chime instance! +#+END_SRC + +*Solution:* + +1. Check for multiple Emacs processes: + #+BEGIN_SRC bash + ps aux | grep emacs + #+END_SRC + +2. Decide on your preferred architecture: + - **Option A**: Use emacs daemon + emacsclient exclusively (recommended for consistency) + - **Option B**: Use standalone Emacs processes only (simpler, but separate configs) + +3. Kill extra processes: + #+BEGIN_SRC bash + # To stop the daemon + emacsclient -e "(kill-emacs)" + + # Or kill specific process by PID + kill <PID> + #+END_SRC + +4. Verify only one Emacs process is running after cleanup + +*Prevention:* + +- If using emacs daemon, always connect with =emacsclient -c= instead of launching =emacs= +- Add shell aliases to prevent accidents: + #+BEGIN_SRC bash + alias emacs="emacsclient -c -a ''" # Auto-start daemon if not running + #+END_SRC + +** Requirements +:PROPERTIES: +:CUSTOM_ID: requirements +:END: + +- Emacs 26.1+ +- Org-mode 9.0+ +- =alert= package +- =dash= package +- =async= package + +** Testing +:PROPERTIES: +:CUSTOM_ID: testing +:END: + +Chime includes a comprehensive test suite with 339 tests covering all functionality. For detailed information about running tests, test architecture, and development workflows, see [[file:TESTING.org][TESTING.org]]. + +Quick start: +#+BEGIN_SRC bash +cd tests +make test # Run all tests +make test-file FILE=modeline # Run specific test file +#+END_SRC + +** License +:PROPERTIES: +:CUSTOM_ID: license +:END: + +GPL-3.0 + +** Credits +:PROPERTIES: +:CUSTOM_ID: credits +:END: + +All credit and thanks should go to Artem Khramov for his work on [[https://github.com/akhramov/org-wild-notifier.el][org-wild-notifier]], which served me well for some time. Sadly, the author deprecated org-wild-notifier on Aug 2, 2025 in favor of [[https://github.com/spegoraro/org-alert][org-alert]]. I begain fixing bugs and enhancing the feature set into what is now CHIME. + +I plan to maintain this in appreciation and gratitude of Artem's work, and for the larger Emacs community. + +** Migration from org-wild-notifier +:PROPERTIES: +:CUSTOM_ID: migration-from-org-wild-notifier +:END: + +If you're migrating from org-wild-notifier, you'll need to update your configuration: + +1. Change package name: + - =(require 'org-wild-notifier)= → =(require 'chime)= + +2. Update all configured variable names: + - =org-wild-notifier-*= → =chime-*= + +3. Update configured function names: + - =org-wild-notifier-mode= → =chime-mode= + - =org-wild-notifier-check= → =chime-check= + +4. Note: The =:WILD_NOTIFIER_NOTIFY_BEFORE:= / =:CHIME_NOTIFY_BEFORE:= property has been removed. Use the global =chime-alert-intervals= variable instead (e.g., =(setq chime-alert-intervals '((30 . low) (15 . medium) (5 . medium) (0 . high)))=). + +** Development +:PROPERTIES: +:CUSTOM_ID: development +:END: + +For information about running tests, test architecture, and development workflows, see [[file:TESTING.org][TESTING.org]]. + diff --git a/TESTING.org b/TESTING.org new file mode 100644 index 0000000..bae4096 --- /dev/null +++ b/TESTING.org @@ -0,0 +1,221 @@ +#+TITLE: Chime Test Suite Documentation +#+AUTHOR: Chime Development Team + +* Overview + +CHIME includes a comprehensive, future-proof test suite to ensure reliability and prevent regressions: + +- *339 total tests* across 23 test files + - Fast, isolated unit tests of individual functions + - Comprehensive integration scenarios using real org-gcal patterns + - *Dynamic timestamp generation* - tests work regardless of current date + - No hardcoded dates that expire or cause failures over time + +The test suite covers: +- Timestamp parsing and time calculations +- All-day event detection and notifications +- Event filtering (keywords, tags, predicates) +- Tooltip and modeline formatting +- Notification text generation +- Title sanitization and edge cases +- Real-world org-gcal integration scenarios +- Overdue TODO handling and day-wide alerts +- Whitelist/blacklist conflict resolution + +* Running Tests with the Makefile + +The =tests/= directory includes a comprehensive Makefile for easy test execution and validation: + +** Run All Tests + +#+BEGIN_SRC bash +cd tests +make test +#+END_SRC + +This runs all 339 tests across 23 test files. Expected output: + +#+BEGIN_EXAMPLE +✓ All dependencies found +Running 339 tests... +Ran 339 tests, 339 results as expected, 0 unexpected +✓ All 339 tests passed! +#+END_EXAMPLE + +** Run Tests for a Specific File + +Use fuzzy matching to run tests from a single file: + +#+BEGIN_SRC bash +cd tests +make test-file FILE=modeline +#+END_SRC + +The =FILE= parameter supports partial matching, so these all work: +- =make test-file FILE=modeline= → runs =test-chime-modeline.el= +- =make test-file FILE=overdue= → runs =test-chime-overdue-todos.el= +- =make test-file FILE=notification-text= → runs =test-chime-notification-text.el= + +** Run a Single Test + +Run one specific test by name (uses fuzzy matching): + +#+BEGIN_SRC bash +cd tests +make test-one TEST=all-day +#+END_SRC + +Examples: +- =make test-one TEST=all-day= → runs first test matching "all-day" +- =make test-one TEST=overdue-disabled= → runs test for overdue disabled behavior +- =make test-one TEST=sanitize-opening-paren= → runs specific sanitization test + +** Run Unit Tests Only + +#+BEGIN_SRC bash +make test-unit +#+END_SRC + +** Run Integration Tests Only + +#+BEGIN_SRC bash +make test-integration +#+END_SRC + +** Run a Specific Test by Name + +#+BEGIN_SRC bash +make test-name TEST=test-chime-check-early-return-on-validation-failure +#+END_SRC + +** Use a Specific Emacs Version + +#+BEGIN_SRC bash +make EMACS=emacs29 test +#+END_SRC + +** Syntax Validation + +Validate all Emacs Lisp syntax (checks parentheses balance): + +#+BEGIN_SRC bash +cd tests +make validate +#+END_SRC + +This runs =check-parens= on all 26 =.el= files (23 test files + chime.el + 2 testutil files). + +** Check Test Inventory + +See a breakdown of tests by file: + +#+BEGIN_SRC bash +cd tests +make count +#+END_SRC + +Example output: + +#+BEGIN_EXAMPLE +Test Count by File: +────────────────────────────────────────────── +30 tests - test-chime-notification-text.el +30 tests - test-chime-sanitize-title.el +25 tests - test-chime-timestamp-parse.el +... +────────────────────────────────────────────── +Total: 339 tests across 23 files +#+END_EXAMPLE + +** Other Makefile Targets + +#+BEGIN_SRC bash +make help # Show all available targets with descriptions +make check-deps # Verify required ELPA dependencies are installed +make lint # Run byte-compilation warnings (optional - requires setup) +#+END_SRC + +* Test Architecture + +** Dynamic Timestamp Generation + +Tests use a dynamic timestamp generation system (=testutil-time.el=) that creates timestamps relative to a stable base time: + +#+BEGIN_SRC elisp +;; Instead of hardcoded dates: +(encode-time 0 0 14 24 10 2025) ; Fails after Oct 2025 + +;; Tests use dynamic generation: +(test-time-today-at 14 0) ; Always works +(test-time-tomorrow-at 9 0) ; Relative to stable base +(test-time-days-from-now 7) ; 7 days from base time +#+END_SRC + +This ensures: +- Tests never expire or fail due to date changes +- Time relationships remain consistent +- Tests can run in any year without modification +- Easier to understand test intent ("tomorrow at 9am" vs "2025-10-25 09:00") + +** Time Mocking + +Tests use =with-test-time= macro to mock =current-time= for deterministic testing: + +#+BEGIN_SRC elisp +(let ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 30))) + (with-test-time now + ;; Inside this block, current-time returns our mocked "now" + ;; Event is 30 minutes in the future + (should (chime--should-notify-p event-time 30)))) +#+END_SRC + +** Validation Infrastructure + +The test suite includes validation to prevent syntax errors: + +*** Git Pre-commit Hook + +Automatically validates syntax before each commit: +- Runs =check-parens= on all staged =.el= files +- Blocks commits with syntax errors +- Can be bypassed with =--no-verify= if needed + +*** Makefile Integration + +- =make validate= - Quick syntax check (no external dependencies) +- =make lint= - Comprehensive linting (requires =elisp-lint= setup) + +* Running Tests Manually + +If you prefer not to use the Makefile: + +#+BEGIN_SRC bash +cd tests + +# Run all tests +emacs --batch -Q \ + -L . \ + -L ~/.emacs.d/elpa/dash-2.20.0 \ + -L ~/.emacs.d/elpa/alert-20240105.2046 \ + -L ~/.emacs.d/elpa/async-20250107.2200 \ + --eval '(dolist (f (directory-files "." t "^test-.*\\.el$")) (load f))' \ + --eval '(ert-run-tests-batch-and-exit)' + +# Run one test file +emacs --batch -Q -L . -L /path/to/deps \ + -l test-chime-modeline.el \ + -f ert-run-tests-batch-and-exit +#+END_SRC + +**Note:** The Makefile is recommended as it automatically finds your ELPA dependencies and provides better output formatting. + +* For Developers + +For more options and details, run: + +#+BEGIN_SRC bash +make help +#+END_SRC + +This will show all available Makefile targets with descriptions. diff --git a/chime-debug.el b/chime-debug.el new file mode 100644 index 0000000..657d218 --- /dev/null +++ b/chime-debug.el @@ -0,0 +1,374 @@ +;;; chime-debug.el --- Debug functions for chime.el -*- lexical-binding: t; -*- + +;; Copyright (C) 2024-2025 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> +;; Keywords: notification alert org org-agenda debug +;; URL: https://github.com/cjennings/chime.el + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; This file contains debug functions for troubleshooting chime.el behavior. +;; It is only loaded when `chime-debug' is non-nil. +;; +;; Enable with: +;; (setq chime-debug t) +;; (require 'chime) +;; +;; Available debug functions: +;; - `chime--debug-dump-events' - Show all stored upcoming events +;; - `chime--debug-dump-tooltip' - Show tooltip content +;; - `chime--debug-config' - Show complete configuration dump +;; - `chime-debug-monitor-event-loading' - Monitor when events are first loaded +;; +;; All functions write to *Messages* buffer without cluttering echo area. +;; +;; The event loading monitor is particularly useful for diagnosing timing +;; issues where the modeline takes a while to populate after Emacs startup. +;; It will send a libnotify notification and log detailed timing information +;; when events are first loaded. + +;;; Code: + +;; chime-debug.el is loaded by chime.el, so chime is already loaded +;; No need for (require 'chime) here + +;;;###autoload +(defun chime--debug-dump-events () + "Dump all upcoming events to *Messages* buffer for debugging. +Shows events stored in `chime--upcoming-events' with their times and titles." + (interactive) + (if (not chime--upcoming-events) + (message "Chime: No upcoming events stored") + (chime--log-silently "=== Chime Debug: Upcoming Events (%d total) ===" + (length chime--upcoming-events)) + (let ((grouped (chime--group-events-by-day chime--upcoming-events))) + (dolist (day-group grouped) + (let ((day-label (car day-group)) + (events (cdr day-group))) + (chime--log-silently "\n%s:" day-label) + (dolist (event-item events) + (let* ((event (car event-item)) + (time-info (nth 1 event-item)) + (minutes (nth 2 event-item)) + (title (cdr (assoc 'title event))) + (timestamp-str (car time-info))) + (chime--log-silently " [%s] %s (%s)" + timestamp-str + title + (chime--time-left (* minutes 60)))))))) + (chime--log-silently "=== End Chime Debug ===\n") + (message "Dumped %d events to *Messages* buffer" (length chime--upcoming-events)))) + +;;;###autoload +(defun chime--debug-dump-tooltip () + "Dump current tooltip content to *Messages* buffer for debugging. +Shows the tooltip text that would appear when hovering over the modeline." + (interactive) + (if (not chime--upcoming-events) + (message "Chime: No upcoming events stored") + (let ((tooltip-text (chime--make-tooltip chime--upcoming-events))) + (if (not tooltip-text) + (message "Chime: No tooltip content available") + (chime--log-silently "=== Chime Debug: Tooltip Content ===") + (chime--log-silently "%s" tooltip-text) + (chime--log-silently "=== End Chime Debug ===\n") + (message "Dumped tooltip content to *Messages* buffer"))))) + +;;;###autoload +(defun chime--debug-config () + "Dump chime configuration and status to *Messages* buffer. +Shows all relevant settings, agenda files, and current state." + (interactive) + (chime--log-silently "=== Chime Debug: Configuration ===") + (chime--log-silently "Mode enabled: %s" chime-mode) + (chime--log-silently "Process running: %s" (process-live-p chime--process)) + (chime--log-silently "Last check: %s" + (if chime--last-check-time + (format-time-string "%Y-%m-%d %H:%M:%S" chime--last-check-time) + "never")) + (chime--log-silently "\nModeline settings:") + (chime--log-silently " chime-enable-modeline: %s" chime-enable-modeline) + (chime--log-silently " chime-modeline-lookahead-minutes: %s" chime-modeline-lookahead-minutes) + (chime--log-silently " chime-modeline-string: %s" + (if chime-modeline-string + (format "\"%s\"" chime-modeline-string) + "nil")) + (chime--log-silently "\nNotification settings:") + (chime--log-silently " chime-alert-intervals: %s" chime-alert-intervals) + (chime--log-silently " chime-notification-title: %s" chime-notification-title) + (chime--log-silently "\nFilters:") + (chime--log-silently " chime-keyword-blacklist: %s" chime-keyword-blacklist) + (chime--log-silently " chime-keyword-whitelist: %s" chime-keyword-whitelist) + (chime--log-silently " chime-tags-blacklist: %s" chime-tags-blacklist) + (chime--log-silently " chime-tags-whitelist: %s" chime-tags-whitelist) + (chime--log-silently "\nOrg agenda files (%d):" (length org-agenda-files)) + (dolist (file org-agenda-files) + (chime--log-silently " - %s %s" + file + (if (file-exists-p file) "" "[MISSING]"))) + (chime--log-silently "\nStored events: %s" + (if chime--upcoming-events + (format "%d" (length chime--upcoming-events)) + "none")) + (chime--log-silently "=== End Chime Debug ===\n") + (message "Chime: Configuration dumped to *Messages* buffer")) + +;;; Event Loading Monitor + +(defvar chime--debug-startup-time nil + "Time when Emacs finished starting, for measuring event load delay.") + +(defvar chime--debug-first-load-notified nil + "Whether we've already notified about the first event load.") + +(defun chime--debug-notify-first-load () + "Send notification and log when events are first loaded. +This helps diagnose timing issues with event hydration after Emacs startup." + (when (and chime--upcoming-events + (not chime--debug-first-load-notified)) + (setq chime--debug-first-load-notified t) + (let* ((now (current-time)) + (startup-delay (if chime--debug-startup-time + (float-time (time-subtract now chime--debug-startup-time)) + nil)) + (event-count (length chime--upcoming-events)) + (first-event (car chime--upcoming-events)) + (first-title (when first-event + (cdr (assoc 'title (car first-event)))))) + ;; Log to *Messages* + (chime--log-silently "=== Chime Debug: First Event Load ===") + (chime--log-silently "Time: %s" (format-time-string "%Y-%m-%d %H:%M:%S" now)) + (when startup-delay + (chime--log-silently "Delay after Emacs startup: %.2f seconds" startup-delay)) + (chime--log-silently "Events loaded: %d" event-count) + (chime--log-silently "Modeline string: %s" + (if chime-modeline-string + (format "\"%s\"" chime-modeline-string) + "nil")) + (when first-title + (chime--log-silently "First event: %s" first-title)) + (chime--log-silently "=== End Chime Debug ===\n") + + ;; Send libnotify notification + (let ((summary (format "Chime: Events Loaded (%d)" event-count)) + (body (if startup-delay + (format "Loaded %.2fs after startup\nFirst: %s" + startup-delay + (or first-title "Unknown")) + (format "First: %s" (or first-title "Unknown"))))) + (alert body :title summary :severity 'moderate))))) + +;;;###autoload +(defun chime-debug-monitor-event-loading () + "Enable monitoring of event loading timing. +Logs to *Messages* and sends libnotify notification when events are first +loaded after Emacs startup. Useful for diagnosing hydration delays. + +To enable: + (setq chime-debug t) + (require \\='chime) + (chime-debug-monitor-event-loading)" + (interactive) + ;; Record startup time + (setq chime--debug-startup-time (current-time)) + (setq chime--debug-first-load-notified nil) + + ;; Add advice to chime--update-modeline to monitor when events are populated + (advice-add 'chime--update-modeline :after + (lambda (&rest _) + (chime--debug-notify-first-load))) + + (chime--log-silently "Chime debug: Event loading monitor enabled") + (chime--log-silently " Startup time recorded: %s" + (format-time-string "%Y-%m-%d %H:%M:%S" chime--debug-startup-time)) + (message "Chime: Event loading monitor enabled")) + +;;;###autoload +(defun chime-debug-stop-monitor-event-loading () + "Disable monitoring of event loading timing." + (interactive) + (advice-remove 'chime--update-modeline + (lambda (&rest _) + (chime--debug-notify-first-load))) + (chime--log-silently "Chime debug: Event loading monitor disabled") + (message "Chime: Event loading monitor disabled")) + +;;; Async Process Debugging + +(defvar chime--debug-async-start-time nil + "Time when the last async check started.") + +(defvar chime--debug-async-check-count 0 + "Number of async checks performed since Emacs started.") + +(defvar chime--debug-async-failures 0 + "Number of async check failures since Emacs started.") + +(defvar chime--debug-async-timeout-threshold 30 + "Warn if async process takes longer than this many seconds.") + +(defun chime--debug-log-async-start () + "Log when an async check starts." + (setq chime--debug-async-check-count (1+ chime--debug-async-check-count)) + (setq chime--debug-async-start-time (current-time)) + (chime--log-silently "[Chime Async #%d] Starting event check at %s" + chime--debug-async-check-count + (format-time-string "%H:%M:%S" chime--debug-async-start-time))) + +(defun chime--debug-log-async-complete (events) + "Log when an async check completes successfully. +EVENTS is the list of events returned." + (when chime--debug-async-start-time + (let* ((duration (float-time (time-subtract (current-time) + chime--debug-async-start-time))) + (event-count (length events))) + (chime--log-silently "[Chime Async #%d] Completed in %.2fs - found %d event%s" + chime--debug-async-check-count + duration + event-count + (if (= event-count 1) "" "s")) + (when (> duration chime--debug-async-timeout-threshold) + (chime--log-silently "[Chime Async #%d] WARNING: Slow async process (%.2fs)" + chime--debug-async-check-count + duration) + (message "Chime: Slow event check took %.2fs" duration)) + (setq chime--debug-async-start-time nil)))) + +(defun chime--debug-log-async-error (error-data) + "Log when an async check fails with an error. +ERROR-DATA is the error information from the async process." + (setq chime--debug-async-failures (1+ chime--debug-async-failures)) + (chime--log-silently "[Chime Async #%d] ERROR: %s" + chime--debug-async-check-count + (prin1-to-string error-data)) + (message "Chime: Event check failed - see *Messages* for details") + (setq chime--debug-async-start-time nil)) + +;;;###autoload +(defun chime--debug-show-async-stats () + "Show statistics about async process performance." + (interactive) + (chime--log-silently "=== Chime Debug: Async Stats ===") + (chime--log-silently "Total checks: %d" chime--debug-async-check-count) + (chime--log-silently "Failures: %d" chime--debug-async-failures) + (chime--log-silently "Success rate: %.1f%%" + (if (> chime--debug-async-check-count 0) + (* 100.0 (/ (float (- chime--debug-async-check-count + chime--debug-async-failures)) + chime--debug-async-check-count)) + 0.0)) + (chime--log-silently "Currently running: %s" + (if (and chime--process (process-live-p chime--process)) + (format "yes (started %s)" + (if chime--debug-async-start-time + (format "%.1fs ago" + (float-time (time-subtract (current-time) + chime--debug-async-start-time))) + "unknown")) + "no")) + (chime--log-silently "=== End Chime Debug ===\n") + (message "Chime: Async stats dumped to *Messages*")) + +;;; Feature/Package Loading Tracker + +(defun chime--debug-show-loaded-features () + "Show which chime-related features and packages are currently loaded." + (let ((chime-features '(org org-agenda org-duration org-contacts + dash alert async chime chime-debug)) + (loaded '()) + (not-loaded '())) + (dolist (feature chime-features) + (if (featurep feature) + (push feature loaded) + (push feature not-loaded))) + (list :loaded (nreverse loaded) + :not-loaded (nreverse not-loaded)))) + +;;;###autoload +(defun chime--debug-dump-loaded-features () + "Dump which chime-related features are currently loaded. +Useful for diagnosing lazy-loading issues." + (interactive) + (let ((result (chime--debug-show-loaded-features))) + (chime--log-silently "=== Chime Debug: Loaded Features ===") + (chime--log-silently "Loaded features:") + (dolist (feature (plist-get result :loaded)) + (chime--log-silently " ✓ %s" feature)) + (when (plist-get result :not-loaded) + (chime--log-silently "\nNot loaded:") + (dolist (feature (plist-get result :not-loaded)) + (chime--log-silently " ✗ %s" feature))) + (chime--log-silently "=== End Chime Debug ===\n") + (message "Chime: Feature list dumped to *Messages*"))) + +;;;###autoload +(defun chime-debug-enable-async-monitoring () + "Enable comprehensive async process monitoring. +Logs every async check start, completion, and failure. +Warns about slow processes and tracks statistics." + (interactive) + ;; Add advice to chime--fetch-and-process to track async lifecycle + (advice-add 'chime--fetch-and-process :before + (lambda (&rest _) + (chime--debug-log-async-start))) + + (chime--log-silently "Chime debug: Async process monitoring enabled") + (message "Chime: Async monitoring enabled - use M-x chime--debug-show-async-stats")) + +;;;###autoload +(defun chime-debug-disable-async-monitoring () + "Disable async process monitoring." + (interactive) + (advice-remove 'chime--fetch-and-process + (lambda (&rest _) + (chime--debug-log-async-start))) + (chime--log-silently "Chime debug: Async process monitoring disabled") + (message "Chime: Async monitoring disabled")) + +;;;###autoload +(defun chime-debug-force-check () + "Force an immediate chime-check and dump diagnostics. +Shows loaded features before the check and logs async process details." + (interactive) + (chime--log-silently "\n=== Chime Debug: Forced Check ===") + (chime--log-silently "Time: %s" (format-time-string "%Y-%m-%d %H:%M:%S")) + + ;; Show what's loaded + (let ((result (chime--debug-show-loaded-features))) + (chime--log-silently "Loaded features: %s" + (mapconcat #'symbol-name (plist-get result :loaded) ", ")) + (when (plist-get result :not-loaded) + (chime--log-silently "Not loaded: %s" + (mapconcat #'symbol-name (plist-get result :not-loaded) ", ")))) + + ;; Show process state + (chime--log-silently "Process alive: %s" + (if (and chime--process (process-live-p chime--process)) + "yes (check already running)" + "no")) + + ;; Show org-agenda-files + (chime--log-silently "Org agenda files: %d" (length org-agenda-files)) + (chime--log-silently "=== Starting Check ===\n") + + ;; Run the check + (chime-check) + + (message "Chime: Forced check initiated - see *Messages* for details")) + +(provide 'chime-debug) +;;; chime-debug.el ends here diff --git a/chime-org-contacts.el b/chime-org-contacts.el new file mode 100644 index 0000000..e21bdf3 --- /dev/null +++ b/chime-org-contacts.el @@ -0,0 +1,188 @@ +;;; chime-org-contacts.el --- Optional org-contacts integration for chime -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> +;; Version: 1.0.0 +;; Package-Requires: ((emacs "27.1") (org "9.0")) +;; Keywords: calendar, org-mode, contacts +;; URL: https://github.com/cjennings/chime.el + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Optional org-contacts integration for chime.el +;; +;; This module provides an org-capture template that automatically inserts +;; birthday timestamps when creating new contacts. This complements the +;; chime conversion script (convert-org-contacts-birthdays.el) which handles +;; existing contacts. +;; +;; Usage: +;; (setq chime-org-contacts-file "~/org/contacts.org") +;; +;; This will: +;; - Add an org-capture template (default key: "C") +;; - Automatically insert yearly repeating timestamps for birthdays +;; - Enable birthdays to appear in org-agenda without org-contacts loaded +;; +;; The integration is disabled by default. Set `chime-org-contacts-file' +;; to enable it. + +;;; Code: + +(require 'org) +(require 'org-capture) + +;;; Customization + +(defgroup chime-org-contacts nil + "Org-contacts integration for chime.el." + :group 'chime + :prefix "chime-org-contacts-") + +(defcustom chime-org-contacts-file nil + "Path to org-contacts file for birthday timestamp integration. + +When nil, org-contacts capture integration is disabled. + +When set to a file path, chime will add an org-capture template +that automatically inserts birthday timestamps for new contacts, +enabling them to appear in org-agenda without requiring org-contacts +to be loaded in the async subprocess. + +Example: + (setq chime-org-contacts-file \"~/org/contacts.org\")" + :type '(choice (const :tag "Disabled" nil) + (file :tag "Contacts file path")) + :group 'chime-org-contacts) + +(defcustom chime-org-contacts-capture-key "C" + "Key binding for chime org-contacts capture template. + +This is the key you press after invoking org-capture (C-c c by default). +Change this if you already have a capture template using \"C\"." + :type 'string + :group 'chime-org-contacts) + +(defcustom chime-org-contacts-heading "Contacts" + "Heading under which to file new contacts. + +New contacts will be filed under this heading in `chime-org-contacts-file'." + :type 'string + :group 'chime-org-contacts) + +;;; Implementation + +(defun chime-org-contacts--parse-birthday (birthday-string) + "Parse BIRTHDAY-STRING into (YEAR MONTH DAY) list. +YEAR may be current year if not present in the string. +Returns nil if parsing fails." + (cond + ;; Format: YYYY-MM-DD + ((string-match "^\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday-string) + (list (string-to-number (match-string 1 birthday-string)) + (string-to-number (match-string 2 birthday-string)) + (string-to-number (match-string 3 birthday-string)))) + ;; Format: MM-DD (use current year) + ((string-match "^\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday-string) + (list (nth 5 (decode-time)) + (string-to-number (match-string 1 birthday-string)) + (string-to-number (match-string 2 birthday-string)))) + (t nil))) + +(defun chime-org-contacts--format-timestamp (year month day) + "Format YEAR MONTH DAY as yearly repeating org timestamp." + (let* ((time (encode-time 0 0 0 day month year)) + (dow (format-time-string "%a" time))) + (format "<%04d-%02d-%02d %s +1y>" year month day dow))) + +(defun chime-org-contacts--insert-timestamp-after-drawer (timestamp) + "Insert TIMESTAMP after properties drawer if not already present." + (let ((heading-end (save-excursion (outline-next-heading) (point)))) + (when (re-search-forward "^[ \t]*:END:[ \t]*$" heading-end t) + (let ((end-pos (point))) + ;; Only insert if no yearly timestamp already exists + (unless (save-excursion + (goto-char end-pos) + (re-search-forward "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}[^>]*\\+1y>" heading-end t)) + (goto-char end-pos) + (end-of-line) + (insert "\n" timestamp)))))) + +(defun chime-org-contacts--finalize-birthday-timestamp () + "Add yearly repeating timestamp after properties drawer if BIRTHDAY is set. + +This function is called during org-capture finalization to automatically +insert a plain timestamp for birthdays, enabling them to appear in org-agenda +without requiring org-contacts to be loaded in the async subprocess." + (when (string= (plist-get org-capture-plist :key) chime-org-contacts-capture-key) + (save-excursion + (goto-char (point-min)) + (let ((birthday (org-entry-get (point) "BIRTHDAY"))) + (when (and birthday (not (string-blank-p birthday))) + (let ((parsed (chime-org-contacts--parse-birthday birthday))) + (when parsed + (let* ((year (nth 0 parsed)) + (month (nth 1 parsed)) + (day (nth 2 parsed)) + (timestamp (chime-org-contacts--format-timestamp year month day))) + (chime-org-contacts--insert-timestamp-after-drawer timestamp))))))))) + +(defun chime-org-contacts--setup-capture-template () + "Add org-capture template for contacts with birthday timestamps. + +This template will only be added if: +1. `chime-org-contacts-file' is set +2. `org-capture-templates' is available +3. The capture key is not already in use (warns if it is)" + (when (and chime-org-contacts-file + (boundp 'org-capture-templates)) + ;; Check if key is already in use + (when (assoc chime-org-contacts-capture-key org-capture-templates) + (warn "chime-org-contacts: Capture key \"%s\" already in use. Change `chime-org-contacts-capture-key' to use a different key." + chime-org-contacts-capture-key)) + + ;; Add the capture template + (add-to-list 'org-capture-templates + `(,chime-org-contacts-capture-key + "Contact (chime)" + entry + (file+headline chime-org-contacts-file ,chime-org-contacts-heading) + "* %^{Name} +:PROPERTIES: +:EMAIL: %^{Email} +:PHONE: %^{Phone} +:ADDRESS: %^{Address} +:BIRTHDAY: %^{Birthday (YYYY-MM-DD or MM-DD)} +:NICKNAME: %^{Nickname} +:COMPANY: %^{Company} +:TITLE: %^{Title} +:WEBSITE: %^{Website} +:NOTE: %^{Note} +:END: +Added: %U" + :prepare-finalize chime-org-contacts--finalize-birthday-timestamp)))) + +;;; Activation + +;; Set up the capture template when org-capture is loaded, +;; but only if chime-org-contacts-file is configured +(with-eval-after-load 'org-capture + (when chime-org-contacts-file + (chime-org-contacts--setup-capture-template))) + +(provide 'chime-org-contacts) +;;; chime-org-contacts.el ends here diff --git a/chime.el b/chime.el new file mode 100644 index 0000000..08c9632 --- /dev/null +++ b/chime.el @@ -0,0 +1,1825 @@ +;;; chime.el --- CHIME Heralds Imminent Events -*- lexical-binding: t -*- + +;; Copyright (C) 2017 Artem Khramov +;; Copyright (C) 2024-2025 Craig Jennings + +;; Current Author/Maintainer: Craig Jennings <c@cjennings.net> +;; Original Author: Artem Khramov <akhramov+emacs@pm.me> +;; Created: 6 Jan 2017 +;; Version: 0.6.0 +;; Package-Requires: ((alert "1.2") (async "1.9.3") (dash "2.18.0") (emacs "26.1")) +;; Keywords: notification alert org org-agenda agenda calendar chime sound +;; URL: https://github.com/cjennings/chime.el + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; CHIME (CHIME Heralds Imminent Events) - Customizable org-agenda notifications +;; +;; This package provides visual and audible notifications for upcoming org-agenda +;; events with modeline display of the next upcoming event. +;; +;; Features: +;; - Visual notifications with customizable alert times +;; - Audible chime sound when notifications appear +;; - Modeline display of next upcoming event +;; - Support for SCHEDULED, DEADLINE, and plain timestamps +;; - Repeating timestamp support (+1w, .+1d, ++1w) +;; - Async background checking (runs every minute) +;; +;; Quick Start: +;; (require 'chime) +;; (setq chime-alert-intervals '((5 . medium) (0 . high))) ; 5 min before and at event time +;; (chime-mode 1) +;; +;; Manual check: M-x chime-check +;; +;; Notification intervals and severity can be customized globally via +;; `chime-alert-intervals'. +;; +;; Filter notifications using `chime-keyword-whitelist' and +;; `chime-keyword-blacklist' variables. +;; +;; See README.org for complete documentation. + +;;; Code: + +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) +(require 'org-duration) +(require 'cl-lib) + +;; Declare functions from chime-debug.el (loaded conditionally) +(declare-function chime-debug-monitor-event-loading "chime-debug") + +(defgroup chime nil + "Chime customization options." + :group 'org) + +(defcustom chime-alert-intervals '((10 . medium)) + "Alert intervals with severity levels for upcoming events. +Each element is a cons cell (MINUTES . SEVERITY) where: +- MINUTES: Number of minutes before event to notify (0 = at event time) +- SEVERITY: Alert urgency level (high, medium, or low) + +Example configurations: + ;; Single notification at event time with high urgency + \\='((0 . high)) + + ;; Multiple notifications with escalating urgency + \\='((60 . low) ;; 1 hour before: low urgency + (30 . low) ;; 30 min before: low urgency + (10 . medium) ;; 10 min before: medium urgency + (0 . high)) ;; At event: high urgency + + ;; Same severity for all notifications + \\='((15 . medium) (5 . medium) (0 . medium)) + +Each interval's severity affects how the notification is displayed +by your system's notification daemon." + :package-version '(chime . "0.7.0") + :group 'chime + :type '(repeat (cons (integer :tag "Minutes before event") + (symbol :tag "Severity"))) + :set (lambda (symbol value) + (unless (listp value) + (user-error "chime-alert-intervals must be a list of cons cells, got: %S" value)) + (dolist (interval value) + (unless (consp interval) + (user-error "Each interval must be a cons cell (MINUTES . SEVERITY), got: %S" interval)) + (let ((minutes (car interval)) + (severity (cdr interval))) + (unless (integerp minutes) + (user-error "Alert time must be an integer, got: %S" minutes)) + (when (< minutes 0) + (user-error "Alert time cannot be negative, got: %d" minutes)) + (unless (memq severity '(high medium low)) + (user-error "Severity must be high, medium, or low, got: %S" severity)))) + (set-default symbol value))) + +(defcustom chime-check-interval 60 + "How often to check for upcoming events, in seconds. +Chime will poll your agenda files at this interval to check for +notifications. Lower values make notifications more responsive but +increase system load. Higher values reduce polling overhead but may +delay notifications slightly. + +Minimum recommended value: 10 seconds. +Default: 60 seconds (1 minute). + +Note: Changes take effect after restarting chime-mode." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'integer + :set (lambda (symbol value) + (unless (integerp value) + (user-error "Check interval must be an integer, got: %S" value)) + (when (< value 10) + (warn "chime-check-interval: Values below 10 seconds may cause excessive polling and system load")) + (when (<= value 0) + (user-error "Check interval must be positive, got: %d" value)) + (set-default symbol value))) + +(defcustom chime-notification-title "Agenda" + "Notifications title." + :package-version '(chime . "0.1.0") + :group 'chime + :type 'string) + +(defcustom chime-notification-icon nil + "Path to notification icon file." + :package-version '(chime . "0.4.1") + :group 'chime + :type 'string) + +(defcustom chime-keyword-whitelist nil + "Receive notifications for these keywords only. +Leave this variable blank if you do not want to filter anything." + :package-version '(chime . "0.2.2") + :group 'chime + :type '(repeat string)) + +(defcustom chime-keyword-blacklist nil + "Never receive notifications for these keywords." + :package-version '(chime . "0.2.2") + :group 'chime + :type '(repeat string)) + +(defcustom chime-tags-whitelist nil + "Receive notifications for these tags only. +Leave this variable blank if you do not want to filter anything." + :package-version '(chime . "0.3.1") + :group 'chime + :type '(repeat string)) + +(defcustom chime-tags-blacklist nil + "Never receive notifications for these tags." + :package-version '(chime . "0.3.1") + :group 'chime + :type '(repeat string)) + +(defcustom chime-display-time-format-string "%I:%M %p" + "Format string for displaying event times. +Passed to `format-time-string' when displaying notification times. +Uses standard time format codes: + %I - Hour (01-12, 12-hour format) + %H - Hour (00-23, 24-hour format) + %M - Minutes (00-59) + %p - AM/PM designation (uppercase) + %P - am/pm designation (lowercase) + +Common formats: + \"%I:%M %p\" -> \"02:30 PM\" (12-hour with AM/PM, default) + \"%H:%M\" -> \"14:30\" (24-hour) + \"%I:%M%p\" -> \"02:30PM\" (12-hour, no space before AM/PM) + \"%l:%M %p\" -> \" 2:30 PM\" (12-hour, space-padded hour) + +Note: Avoid using seconds (%S) as chime polls once per minute." + :package-version '(chime . "0.5.0") + :group 'chime + :type 'string + :set (lambda (symbol value) + (when (and value (stringp value) (string-match-p "%S" value)) + (warn "chime-display-time-format-string: Using seconds (%%S) is not recommended as chime polls once per minute")) + (set-default symbol value))) + +(defcustom chime-time-left-format-at-event "right now" + "Format string for when event time has arrived (0 or negative seconds). +This is a literal string with no format codes." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'string) + +(defcustom chime-time-left-format-short "in %M" + "Format string for times under 1 hour. +Uses `format-seconds' codes: + %m - minutes as number only (e.g., \"37\") + %M - minutes with unit name (e.g., \"37 minutes\") + +Examples: + \"in %M\" -> \"in 37 minutes\" + \"in %mm\" -> \"in 37m\" + \"%m min\" -> \"37 min\"" + :package-version '(chime . "0.6.0") + :group 'chime + :type 'string) + +(defcustom chime-time-left-format-long "in %H %M" + "Format string for times 1 hour or longer. +Uses `format-seconds' codes: + %h - hours as number only (e.g., \"1\") + %H - hours with unit name (e.g., \"1 hour\") + %m - minutes as number only (e.g., \"37\") + %M - minutes with unit name (e.g., \"37 minutes\") + +Examples: + \"in %H %M\" -> \"in 1 hour 37 minutes\" + \"in %hh %mm\" -> \"in 1h 37m\" + \"(%h hr %m min)\" -> \"(1 hr 37 min)\" + \"%hh%mm\" -> \"1h37m\"" + :package-version '(chime . "0.6.0") + :group 'chime + :type 'string) + +(defcustom chime-predicate-whitelist nil + "Receive notifications for events matching these predicates only. +Each function should take an event POM and return non-nil iff that event should +trigger a notification. Leave this variable blank if you do not want to filter +anything." + :package-version '(chime . "0.5.0") + :group 'chime + :type '(function)) + +(defcustom chime-additional-environment-regexes nil + "Additional regular expressions for async environment injection. +These regexes are provided to `async-inject-environment' before +running the async command to check notifications." + :package-version '(chime . "0.5.0") + :group 'chime + :type '(string)) + +(defcustom chime-predicate-blacklist + '(chime-done-keywords-predicate) + "Never receive notifications for events matching these predicates. +Each function should take an event POM and return non-nil iff that event should +not trigger a notification." + :package-version '(chime . "0.5.0") + :group 'chime + :type '(function)) + +(defcustom chime-extra-alert-plist nil + "Additional arguments that should be passed to invocations of `alert'." + :package-version '(chime . "0.5.0") + :group 'chime + :type 'plist) + +(defcustom chime-day-wide-alert-times '("08:00") + "List of time strings for day-wide event alerts. +Each string specifies a time of day when day-wide events should trigger. +Defaults to 08:00 (morning reminder for all-day events happening today). +Set to nil to disable all-day event notifications entirely. + +Example: \\='(\"08:00\" \"17:00\") for morning and evening reminders." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(repeat string)) + +(defcustom chime-show-any-overdue-with-day-wide-alerts t + "Show any overdue TODO items along with day wide alerts whenever they are shown." + :package-version '(chime . "0.5.0") + :group 'chime + :type 'boolean) + +(defcustom chime-day-wide-advance-notice nil + "Number of days before all-day events to show advance notifications. +When nil, only notify on the day of the event. +When 1, also notify the day before at `chime-day-wide-alert-times'. +When 2, notify two days before, etc. + +Useful for events requiring preparation, such as birthdays (buying gifts) +or multi-day conferences (packing, travel arrangements). + +Note: This only affects notifications, not tooltip/modeline display. + +Example: With value 1 and alert times \\='(\"08:00\"), you'll get: + - \"Blake's birthday is tomorrow\" at 08:00 the day before + - \"Blake's birthday is today\" at 08:00 on the day" + :package-version '(chime . "0.6.0") + :group 'chime + :type '(choice (const :tag "Same day only" nil) + (integer :tag "Days in advance"))) + +(defcustom chime-tooltip-show-all-day-events t + "Whether to show all-day events in the tooltip. +When nil, all-day events (birthdays, multi-day conferences, etc.) are +hidden from the tooltip but can still trigger notifications. +When t, all-day events appear in the tooltip for planning purposes. + +All-day events are never shown in the modeline (only in tooltip). + +This is useful for seeing upcoming birthdays, holidays, and multi-day +events without cluttering the modeline with non-time-sensitive items." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'boolean) + +(defcustom chime-enable-modeline t + "Whether to display upcoming events in the modeline. +When nil, chime will not modify the modeline at all." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'boolean) + +(defcustom chime-modeline-lighter " 🔔" + "Text to display in the modeline when chime-mode is enabled. +This is the mode lighter that appears in the modeline to indicate +chime-mode is active." + :package-version '(chime . "0.7.0") + :group 'chime + :type 'string) + +(defcustom chime-modeline-lookahead-minutes 60 + "Minutes ahead to look for next event to display in modeline. +Should be larger than notification alert times for advance awareness. +Set to 0 to disable modeline display. +This setting only takes effect when `chime-enable-modeline' is non-nil." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(integer :tag "Minutes")) + +(defcustom chime-modeline-format " ⏰ %s" + "Format string for modeline display. +%s will be replaced with the event description (time and title)." + :package-version '(chime . "0.5.1") + :group 'chime + :type 'string) + +(defcustom chime-calendar-url nil + "URL to your calendar for browser access. +When set, left-clicking the modeline icon/text opens this URL in your +browser. Right-clicking jumps to the next event in your org file. + +Set this to your calendar's web interface, such as: + - Google Calendar: \"https://calendar.google.com\" + - Outlook: \"https://outlook.office.com/calendar\" + - Custom calendar URL + +When nil (default), left-click does nothing." + :package-version '(chime . "0.7.0") + :group 'chime + :type '(choice (const :tag "No calendar URL" nil) + (string :tag "Calendar URL"))) + +(defcustom chime-tooltip-lookahead-hours 8760 + "Hours ahead to look for events in tooltip. +Separate from modeline lookahead window. +Default is 8760 hours (1 year), showing all future events. +The actual number of events shown is limited by +`chime-modeline-tooltip-max-events'. + +Set to a smaller value to limit tooltip by time as well as count. +Example: Set to 24 to show only today's and tomorrow's events, +or keep at default to show next N events regardless of distance." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(integer :tag "Hours")) + +(defcustom chime-modeline-tooltip-max-events 5 + "Maximum number of events to show in modeline tooltip. +Set to nil to show all events within tooltip lookahead window." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(choice (integer :tag "Maximum events") + (const :tag "Show all" nil))) + +(defcustom chime-modeline-no-events-text " ⏰" + "Text to display in modeline when no events are within lookahead window. +Shows an alarm icon by default. +When nil, nothing is shown in the modeline when no upcoming events. +When a string, that text is displayed. + +This only applies when events exist beyond the lookahead window. +If there are no events at all, the modeline is always empty. + +Examples: + \" ⏰\" - Alarm icon (default) + \" 🔕\" - Muted bell emoji + nil - Show nothing (clean modeline) + \" No events\" - Show text message" + :package-version '(chime . "0.6.0") + :group 'chime + :type '(choice (const :tag "Show nothing" nil) + (string :tag "Custom text"))) + +(defcustom chime-notification-text-format "%t at %T (%u)" + "Format string for notification text display. +Available placeholders: + %t - Event title + %T - Event time (formatted per `chime-display-time-format-string') + %u - Time until event (formatted per time-left format settings) + +Examples: + \"%t at %T (%u)\" -> \"Team Meeting at 02:30 PM (in 10 minutes)\" (default) + \"%t at %T\" -> \"Team Meeting at 02:30 PM\" (no countdown) + \"%t (%u)\" -> \"Team Meeting (in 10 minutes)\" (no time) + \"%t - %T\" -> \"Team Meeting - 02:30 PM\" (custom separator) + \"%t\" -> \"Team Meeting\" (title only)" + :package-version '(chime . "0.6.0") + :group 'chime + :type 'string) + +(defcustom chime-max-title-length nil + "Maximum length for event titles in notifications. +When non-nil, truncate titles longer than this value with \"...\". +When nil, show full title without truncation. + +This affects ONLY the event title (%t in `chime-notification-text-format'), +NOT the icon, time, or countdown. The icon is part of +`chime-modeline-format' and is added separately. + +Examples (assuming format \"%t (%u)\" and icon \" ⏰ \"): + nil -> \" ⏰ Very Long Meeting Title That Goes On ( in 10m)\" + 25 -> \" ⏰ Very Long Meeting Titl... ( in 10m)\" + 15 -> \" ⏰ Very Long Me... ( in 10m)\" + 10 -> \" ⏰ Very Lo... ( in 10m)\" + +The limit includes the \"...\" suffix (3 chars), so a limit of 15 +means up to 12 chars of title plus \"...\". + +Minimum recommended value: 10 characters." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(choice (const :tag "No truncation (show full title)" nil) + (integer :tag "Maximum title length")) + :set (lambda (symbol value) + (when (and value (integerp value) (< value 5)) + (warn "chime-max-title-length: Values below 5 may produce illegible titles")) + (set-default symbol value))) + +(defcustom chime-tooltip-header-format "Upcoming Events as of %a %b %d %Y @ %I:%M %p" + "Format string for tooltip header showing current date/time. +Uses `format-time-string' codes. +See Info node `(elisp)Time Parsing' for details. + +Common format codes: + %a - Abbreviated weekday (Mon, Tue, ...) + %A - Full weekday name (Monday, Tuesday, ...) + %b - Abbreviated month (Jan, Feb, ...) + %B - Full month name (January, February, ...) + %d - Day of month, zero-padded (01-31) + %e - Day of month, space-padded ( 1-31) + %Y - Four-digit year (2025) + %I - Hour (01-12, 12-hour format) + %H - Hour (00-23, 24-hour format) + %M - Minute (00-59) + %p - AM/PM indicator + +Default: \"Upcoming Events as of %a %b %d %Y @ %I:%M %p\" +Result: \"Upcoming Events as of Tue Nov 04 2025 @ 08:25 PM\"" + :package-version '(chime . "0.7.0") + :group 'chime + :type 'string) + +(defcustom chime-play-sound t + "Whether to play a sound when notifications are displayed. +When non-nil, plays the sound file specified in `chime-sound-file'." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'boolean) + +(defcustom chime-sound-file + (expand-file-name "sounds/chime.wav" + (file-name-directory (or load-file-name buffer-file-name))) + "Path to sound file to play when notifications are displayed. +Defaults to the bundled chime.wav file. +Set to nil to disable sound completely (no sound file, no beep). +Should be an absolute path to a .wav, .au, or other sound file +supported by your system." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(choice (const :tag "No sound" nil) + (file :tag "Sound file path"))) + +(defcustom chime-startup-delay 10 + "Seconds to wait before first event check after chime-mode is enabled. +This delay allows org-agenda-files and related infrastructure to finish +loading before chime attempts to check for events. + +Default of 10 seconds works well for most configurations. Adjust if: +- You have custom org-agenda-files setup that takes longer to initialize +- You want faster startup (reduce to 5) and know org is ready +- You see \"found 0 events\" messages (increase to 15 or 20) + +Set to 0 to check immediately (not recommended unless you're sure +org-agenda-files is populated at startup)." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'integer + :set (lambda (symbol value) + (unless (and (integerp value) (>= value 0)) + (user-error "chime-startup-delay must be a non-negative integer, got: %s" value)) + (set-default symbol value))) + +(defcustom chime-debug nil + "Enable debug functions for troubleshooting chime behavior. +When non-nil, loads chime-debug.el which provides: +- `chime--debug-dump-events' - Show all stored upcoming events +- `chime--debug-dump-tooltip' - Show tooltip content +- `chime--debug-config' - Show complete configuration dump +- `chime-debug-monitor-event-loading' - Monitor event loading timing + +These functions write detailed information to the *Messages* buffer +without cluttering the echo area. + +When enabled, automatically monitors event loading to help diagnose +timing issues where the modeline takes a while to populate after +Emacs startup. + +Set to t to enable debug functions: + (setq chime-debug t) + (require \\='chime)" + :package-version '(chime . "0.6.0") + :group 'chime + :type 'boolean) + +;; Load debug functions if enabled +(when chime-debug + (require 'chime-debug + (expand-file-name "chime-debug.el" + (file-name-directory (or load-file-name buffer-file-name))) + t)) + +;; Load org-contacts integration if configured +;; Note: The actual template setup happens in chime-org-contacts.el +;; when org-capture is loaded, so users can defer org loading +(with-eval-after-load 'org-capture + (when (and (boundp 'chime-org-contacts-file) + chime-org-contacts-file) + (require 'chime-org-contacts + (expand-file-name "chime-org-contacts.el" + (file-name-directory (or load-file-name buffer-file-name))) + t))) + +(defvar chime--timer nil + "Timer value.") + +(defvar chime--process nil + "Currently-running async process.") + +(defvar chime--agenda-buffer-name "*chime-agenda*" + "Name for temporary \\='org-agenda\\=' buffer.") + +(defvar chime--last-check-time (seconds-to-time 0) + "Last time checked for events.") + +(defvar chime--upcoming-events nil + "List of upcoming events with full data for tooltip and clicking. +Each event includes marker, title, times, and intervals.") + +(defvar chime--validation-done nil + "Whether configuration validation has been performed. +Validation runs on the first call to `chime-check', after `chime-startup-delay' +has elapsed. This gives startup hooks time to populate org-agenda-files.") + +(defvar chime--validation-retry-count 0 + "Number of times validation has failed and been retried. +Reset to 0 when validation succeeds. Used to provide graceful retry +behavior for users with async org-agenda-files initialization.") + +(defcustom chime-validation-max-retries 3 + "Maximum number of times to retry validation before showing error. +When org-agenda-files is empty on startup, chime will retry validation +on each check cycle (every `chime-check-interval' seconds) until either: + - Validation succeeds (org-agenda-files is populated) + - This retry limit is exceeded (error is shown) + +This accommodates users with async initialization code that populates +org-agenda-files after a delay (e.g., via idle timers). + +Set to 0 to show errors immediately without retrying. +Default is 3 retries (with 30-60s check intervals, this gives ~1.5-3 minutes +for org-agenda-files to be populated)." + :type 'integer + :group 'chime) + +(defvar chime-modeline-string nil + "Modeline string showing next upcoming event.") +;;;###autoload(put 'chime-modeline-string 'risky-local-variable t) +(put 'chime-modeline-string 'risky-local-variable t) + +(defun chime--time= (&rest list) + "Compare timestamps. +Comparison is performed by converted each element of LIST onto string +in order to ignore seconds." + (->> list + (--map (format-time-string "%d:%H:%M" it)) + (-uniq) + (length) + (= 1))) + +(defun chime--today () + "Get the timestamp for the beginning of current day." + (apply 'encode-time + (append '(0 0 0) (nthcdr 3 (decode-time (current-time)))))) + +(defun chime--timestamp-within-interval-p (timestamp interval) + "Check whether TIMESTAMP is within notification INTERVAL. +Returns non-nil if TIMESTAMP matches current time plus INTERVAL minutes. +Returns nil if TIMESTAMP or INTERVAL is invalid." + (and timestamp + interval + (numberp interval) + ;; Validate timestamp is a proper time value (list of integers) + (listp timestamp) + (chime--time= + (time-add (current-time) (seconds-to-time (* 60 interval))) + timestamp))) + +(defun chime--notifications (event) + "Get notifications for given EVENT. +Returns a list of time information interval pairs. +Each pair is ((TIMESTAMP . TIME-VALUE) (MINUTES . SEVERITY))." + (->> (list + (chime--filter-day-wide-events (cdr (assoc 'times event))) + (cdr (assoc 'intervals event))) + (apply '-table-flat (lambda (ts int) (list ts int))) + ;; When no values are provided for table flat, we get the second values + ;; paired with nil. + (--filter (not (null (car it)))) + ;; Extract minutes from (minutes . severity) cons for time matching + (--filter (chime--timestamp-within-interval-p (cdar it) (car (cadr it)))))) + +(defun chime--has-timestamp (s) + "Check if S contain a timestamp with a time component. +Returns non-nil only if the timestamp includes HH:MM time information." + (and s + (stringp s) + (string-match org-ts-regexp0 s) + (match-beginning 7))) + +(defun chime--filter-day-wide-events (times) + "Filter TIMES list to include only events with timestamps." + (--filter (chime--has-timestamp (car it)) times)) + +(defun chime--time-left (seconds) + "Human-friendly representation for SECONDS. +Format is controlled by `chime-time-left-format-at-event', +`chime-time-left-format-short', and `chime-time-left-format-long'." + (-> seconds + (pcase + ((pred (>= 0)) chime-time-left-format-at-event) + ((pred (>= 3600)) chime-time-left-format-short) + (_ chime-time-left-format-long)) + + (format-seconds seconds))) + +(defun chime--get-hh-mm-from-org-time-string (time-string) + "Convert given org time-string TIME-STRING into string with \\='hh:mm\\=' format." + (format-time-string + chime-display-time-format-string + (encode-time (org-parse-time-string time-string)))) + +(defun chime--truncate-title (title) + "Truncate TITLE to `chime-max-title-length' if set. +Returns the truncated title with \"...\" appended if truncated, +or the original title if no truncation is needed. +Returns empty string if TITLE is nil." + (let ((title-str (or title ""))) + (if (and chime-max-title-length + (integerp chime-max-title-length) + (> chime-max-title-length 0) + (> (length title-str) chime-max-title-length)) + (concat (substring title-str 0 (max 0 (- chime-max-title-length 3))) "...") + title-str))) + +(defun chime--notification-text (str-interval event) + "For given STR-INTERVAL list and EVENT get notification wording. +STR-INTERVAL is (TIMESTAMP-STRING . (MINUTES . SEVERITY)). +Format is controlled by `chime-notification-text-format'. +Title is truncated per `chime-max-title-length' if set." + (let* ((title (cdr (assoc 'title event))) + (minutes (car (cdr str-interval)))) + (format-spec chime-notification-text-format + `((?t . ,(chime--truncate-title title)) + (?T . ,(chime--get-hh-mm-from-org-time-string (car str-interval))) + (?u . ,(chime--time-left (* 60 minutes))))))) + +(defun chime-get-minutes-into-day (time) + "Get minutes elapsed since midnight for TIME string." + (org-duration-to-minutes (org-get-time-of-day time t))) + +(defun chime-get-hours-minutes-from-time (time-string) + "Extract hours and minutes from TIME-STRING. +Returns a list of (HOURS MINUTES)." + (let ((total-minutes (truncate (chime-get-minutes-into-day time-string)))) + (list (/ total-minutes 60) + (mod total-minutes 60)))) + +(defun chime-set-hours-minutes-for-time (time hours minutes) + "Set HOURS and MINUTES for TIME, preserving date components." + (cl-destructuring-bind (_s _m _h day month year dow dst utcoff) (decode-time time) + (encode-time 0 minutes hours day month year dow dst utcoff))) + +(defun chime-current-time-matches-time-of-day-string (time-of-day-string) + "Check if current time matches TIME-OF-DAY-STRING." + (let ((now (current-time))) + (chime--time= + now + (apply 'chime-set-hours-minutes-for-time + now + (chime-get-hours-minutes-from-time time-of-day-string))))) + +(defun chime-current-time-is-day-wide-time () + "Check if current time matches any day-wide alert time." + (--any (chime-current-time-matches-time-of-day-string it) + chime-day-wide-alert-times)) + +(defun chime-day-wide-notifications (events) + "Generate notification texts for day-wide EVENTS. +Returns a list of (MESSAGE . SEVERITY) cons cells with \\='medium severity." + (->> events + (-filter 'chime-display-as-day-wide-event) + (-map 'chime--day-wide-notification-text) + (-uniq) + ;; Wrap messages in cons cells with default 'medium' severity + (--map (cons it 'medium)))) + +(defun chime-display-as-day-wide-event (event) + "Check if EVENT should be displayed as a day-wide event. +Considers both events happening today and advance notices for future events. + +When `chime-show-any-overdue-with-day-wide-alerts' is t (default): + - Shows overdue TODO items (timed events that passed) + - Shows all-day events from today or earlier + +When nil: + - Shows only today's events (both timed and all-day) + - Hides overdue items from past days" + (or + ;; Events happening today or in the past + (and (chime-event-has-any-passed-time event) + (or chime-show-any-overdue-with-day-wide-alerts + ;; When overdue alerts disabled, only show today's events + (chime-event-is-today event))) + ;; Advance notice for upcoming all-day events + (and chime-day-wide-advance-notice + (chime-event-has-any-day-wide-timestamp event) + (chime-event-within-advance-notice-window event)))) + +(defun chime-event-has-any-day-wide-timestamp (event) + "Check if EVENT has any day-wide (no time component) timestamps." + (--any (not (chime--has-timestamp (car it))) + (cdr (assoc 'times event)))) + +(defun chime-event-within-advance-notice-window (event) + "Check if EVENT has any day-wide timestamps within advance notice window. +Returns t if any all-day timestamp is between tomorrow and N days from now, +where N is `chime-day-wide-advance-notice'." + (when chime-day-wide-advance-notice + (let* ((now (current-time)) + ;; Calculate time range: start of tomorrow to end of N days from now + (window-end (time-add now (seconds-to-time + (* 86400 (1+ chime-day-wide-advance-notice))))) + (all-times (cdr (assoc 'times event)))) + (--any + (when-let* ((timestamp-str (car it)) + ;; Only check all-day events (those without time component) + (is-all-day (not (chime--has-timestamp timestamp-str))) + ;; Parse the date portion even without time + (parsed (org-parse-time-string timestamp-str)) + ;; Use nth accessors for Emacs 26 compatibility + (year (nth 5 parsed)) + (month (nth 4 parsed)) + (day (nth 3 parsed))) + ;; Convert to time at start of day (00:00:00) + (let ((event-time (encode-time 0 0 0 day month year))) + ;; Check if event is within the advance notice window + (and (time-less-p now event-time) ;; Event is in future + (time-less-p event-time window-end)))) ;; Event is within window + all-times)))) + +(defun chime-event-has-any-passed-time (event) + "Check if EVENT has any timestamps in the past or today. +For all-day events, checks if the date is today or earlier." + (--any + (let ((timestamp-str (car it)) + (parsed-time (cdr it))) + (if parsed-time + ;; Timed event: check if time has passed + (time-less-p parsed-time (current-time)) + ;; All-day event: check if date is today or earlier + (when-let* ((parsed (org-parse-time-string timestamp-str)) + (year (nth 5 parsed)) + (month (nth 4 parsed)) + (day (nth 3 parsed))) + (let* ((event-date (encode-time 0 0 0 day month year)) + (today-start (let ((now (decode-time (current-time)))) + (encode-time 0 0 0 + (decoded-time-day now) + (decoded-time-month now) + (decoded-time-year now))))) + (not (time-less-p today-start event-date)))))) + (cdr (assoc 'times event)))) + +(defun chime-event-is-today (event) + "Check if EVENT has any timestamps that are specifically today (not past days). +For all-day events, checks if the date is exactly today. +For timed events, checks if the time is today (past or future)." + (--any + (let ((timestamp-str (car it)) + (parsed-time (cdr it))) + (if parsed-time + ;; Timed event: check if it's today (could be future time today) + (let* ((decoded (decode-time parsed-time)) + (event-day (decoded-time-day decoded)) + (event-month (decoded-time-month decoded)) + (event-year (decoded-time-year decoded)) + (today (decode-time)) + (today-day (decoded-time-day today)) + (today-month (decoded-time-month today)) + (today-year (decoded-time-year today))) + (and (= event-day today-day) + (= event-month today-month) + (= event-year today-year))) + ;; All-day event: check if date is exactly today + (when-let* ((parsed (org-parse-time-string timestamp-str)) + (year (nth 5 parsed)) + (month (nth 4 parsed)) + (day (nth 3 parsed))) + (let* ((event-date (encode-time 0 0 0 day month year)) + (today-start (let ((now (decode-time (current-time)))) + (encode-time 0 0 0 + (decoded-time-day now) + (decoded-time-month now) + (decoded-time-year now))))) + (time-equal-p event-date today-start))))) + (cdr (assoc 'times event)))) + +(defun chime--day-wide-notification-text (event) + "Generate notification text for day-wide EVENT. +Handles both same-day events and advance notices." + (let* ((title (cdr (assoc 'title event))) + (all-times (cdr (assoc 'times event))) + (is-today (chime-event-has-any-passed-time event)) + (is-advance-notice (and chime-day-wide-advance-notice + (chime-event-within-advance-notice-window event)))) + (cond + ;; Event is today + (is-today + (format "%s is due or scheduled today" title)) + ;; Event is within advance notice window + (is-advance-notice + ;; Calculate days until event + (let* ((now (current-time)) + (days-until + (-min + (--map + (when-let* ((timestamp-str (car it)) + (is-all-day (not (chime--has-timestamp timestamp-str))) + (parsed (org-parse-time-string timestamp-str)) + ;; Use nth accessors for Emacs 26 compatibility + (year (nth 5 parsed)) + (month (nth 4 parsed)) + (day (nth 3 parsed))) + (let* ((event-time (encode-time 0 0 0 day month year)) + (seconds-until (time-subtract event-time now)) + (days (/ (float-time seconds-until) 86400.0))) + (ceiling days))) + all-times)))) + (cond + ((= days-until 1) + (format "%s is tomorrow" title)) + ((= days-until 2) + (format "%s is in 2 days" title)) + (t + (format "%s is in %d days" title days-until))))) + ;; Fallback (shouldn't happen) + (t + (format "%s is due or scheduled today" title))))) + +(defun chime--check-event (event) + "Get notifications for given EVENT. +Returns a list of (MESSAGE . SEVERITY) cons cells." + (->> (chime--notifications event) + (--map (let* ((notif it) + (timestamp-str (caar notif)) + (interval-cons (cadr notif)) ; (minutes . severity) + (severity (cdr interval-cons)) + (message (chime--notification-text + `(,timestamp-str . ,interval-cons) + event))) + (cons message severity))))) + +(defun chime--jump-to-event (event) + "Jump to EVENT's org entry in its file. +Reconstructs marker from serialized file path and position." + (interactive) + (when-let* ((file (cdr (assoc 'marker-file event))) + (pos (cdr (assoc 'marker-pos event)))) + (when (file-exists-p file) + (find-file file) + (goto-char pos) + ;; Use org-fold-show-entry (Org 9.6+) if available, fallback to org-show-entry + (if (fboundp 'org-fold-show-entry) + (org-fold-show-entry) + (with-no-warnings + (org-show-entry)))))) + +(defun chime--open-calendar-url () + "Open calendar URL in browser if `chime-calendar-url' is set." + (interactive) + (when chime-calendar-url + (browse-url chime-calendar-url))) + +(defun chime--jump-to-first-event () + "Jump to first event in `chime--upcoming-events' list." + (interactive) + (when-let* ((first-event (car chime--upcoming-events)) + (event (car first-event))) + (chime--jump-to-event event))) + +(defun chime--format-event-for-tooltip (event-time-str minutes-until title) + "Format a single event line for tooltip display. +EVENT-TIME-STR is the time string, MINUTES-UNTIL is minutes until event, +TITLE is the event title." + (let ((time-display (chime--get-hh-mm-from-org-time-string event-time-str)) + (countdown (cond + ((< minutes-until 1440) ;; Less than 24 hours + (format "(%s)" (chime--time-left (* minutes-until 60)))) + (t + ;; 24+ hours: show days and hours + (let* ((days (truncate (/ minutes-until 1440))) + (remaining-minutes (truncate (mod minutes-until 1440))) + (hours (truncate (/ remaining-minutes 60)))) + (if (> hours 0) + (format "(in %d day%s %d hour%s)" + days (if (= days 1) "" "s") + hours (if (= hours 1) "" "s")) + (format "(in %d day%s)" + days (if (= days 1) "" "s")))))))) + (format "%s at %s %s" title time-display countdown))) + +(defun chime--group-events-by-day (upcoming-events) + "Group UPCOMING-EVENTS by day. +Returns an alist of (DATE-STRING . EVENTS-LIST)." + (let ((grouped '()) + (now (current-time))) + (dolist (item upcoming-events) + (let* ((event-time (cdr (nth 1 item))) + (_minutes-until (nth 2 item)) + ;; Get date components for calendar day comparison + (now-decoded (decode-time now)) + (event-decoded (decode-time event-time))) + (when event-decoded + (let* ((now-day (decoded-time-day now-decoded)) + (now-month (decoded-time-month now-decoded)) + (now-year (decoded-time-year now-decoded)) + (event-day (decoded-time-day event-decoded)) + (event-month (decoded-time-month event-decoded)) + (event-year (decoded-time-year event-decoded)) + ;; Check if same calendar day (not just < 24 hours) + (same-day-p (and (= now-day event-day) + (= now-month event-month) + (= now-year event-year))) + ;; Check if tomorrow (next calendar day) + (tomorrow-decoded (decode-time (time-add now (days-to-time 1)))) + (tomorrow-p (and (= event-day (decoded-time-day tomorrow-decoded)) + (= event-month (decoded-time-month tomorrow-decoded)) + (= event-year (decoded-time-year tomorrow-decoded)))) + (date-string (cond + (same-day-p + (format-time-string "Today, %b %d" now)) + (tomorrow-p + (format-time-string "Tomorrow, %b %d" + (time-add now (days-to-time 1)))) + (t ;; Future days + (format-time-string "%A, %b %d" event-time))))) + (let ((day-group (assoc date-string grouped))) + (if day-group + (setcdr day-group (append (cdr day-group) (list item))) + (push (cons date-string (list item)) grouped))))))) + (nreverse grouped))) + +(defun chime--make-tooltip (upcoming-events) + "Generate tooltip text showing UPCOMING-EVENTS grouped by day." + (if (null upcoming-events) + nil + (let* ((max-events (or chime-modeline-tooltip-max-events (length upcoming-events))) + (events-to-show (seq-take upcoming-events max-events)) + (remaining (- (length upcoming-events) (length events-to-show))) + (grouped (chime--group-events-by-day events-to-show)) + (header (concat (format-time-string chime-tooltip-header-format) "\n")) + (lines (list header))) + ;; Build tooltip text + (dolist (day-group grouped) + (let ((date-str (car day-group)) + (day-events (cdr day-group))) + (push (format "\n%s:\n" date-str) lines) + (push "─────────────\n" lines) + (dolist (item day-events) + (let* ((event (car item)) + (event-time-str (car (nth 1 item))) + (minutes-until (nth 2 item)) + (title (cdr (assoc 'title event)))) + (push (format "%s\n" + (chime--format-event-for-tooltip + event-time-str minutes-until title)) + lines))))) + ;; Add "... and N more" if needed + (when (> remaining 0) + (push (format "\n... and %d more event%s" + remaining + (if (> remaining 1) "s" "")) + lines)) + (apply #'concat (nreverse lines))))) + +(defun chime--make-no-events-tooltip (lookahead-minutes) + "Generate tooltip text when no events exist within LOOKAHEAD-MINUTES." + (let* ((hours (/ lookahead-minutes 60)) + (days (/ hours 24)) + (timeframe (cond + ((>= days 7) (format "%d days" days)) + ((>= hours 24) (format "%.1f days" (/ hours 24.0))) + ((>= hours 1) (format "%d hours" hours)) + (t (format "%d minutes" lookahead-minutes)))) + (header (format-time-string chime-tooltip-header-format)) + (increase-var "chime-tooltip-lookahead-hours")) + (concat header "\n" + "─────────────────────\n" + (format "No calendar events in\nthe next %s.\n\n" timeframe) + (format "Increase `%s`\nto expand scope.\n\n" increase-var) + "Left-click: Open calendar"))) + +(defun chime--propertize-modeline-string (text soonest-event) + "Add tooltip and click handlers to modeline TEXT for SOONEST-EVENT. +Left-click opens calendar URL (if set), right-click jumps to event." + (if (null chime--upcoming-events) + text + (let ((map (make-sparse-keymap)) + (tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Left-click: open calendar URL + (define-key map [mode-line mouse-1] #'chime--open-calendar-url) + ;; Right-click: jump to event + (define-key map [mode-line mouse-3] + (lambda () + (interactive) + (chime--jump-to-event soonest-event))) + (propertize text + 'help-echo tooltip + 'mouse-face 'mode-line-highlight + 'local-map map)))) + +(defun chime--deduplicate-events-by-title (upcoming-events) + "Deduplicate UPCOMING-EVENTS by title, keeping soonest occurrence. + +UPCOMING-EVENTS should be a list where each element is +\(EVENT TIME-INFO MINUTES). +Returns a new list with only the soonest occurrence of each +unique title. + +This prevents recurring events from appearing multiple times in +the tooltip when `org-agenda-list' expands them into separate +event objects." + (let ((title-hash (make-hash-table :test 'equal))) + (dolist (item upcoming-events) + (let* ((event (car item)) + (title (cdr (assoc 'title event))) + (minutes (caddr item)) + (existing (gethash title title-hash))) + ;; Only keep if this is the first occurrence or soonest so far + (when (or (not existing) + (< minutes (caddr existing))) + (puthash title item title-hash)))) + (hash-table-values title-hash))) + +(defun chime--find-soonest-time-in-window (times now lookahead-minutes) + "Find soonest time from TIMES list within LOOKAHEAD-MINUTES from NOW. +TIMES is a list of (TIME-STRING . TIME-OBJECT) cons cells. +Returns (TIME-STRING . TIME-OBJECT MINUTES-UNTIL) or nil if none found." + (let ((soonest-time-info nil) + (soonest-minutes nil)) + (dolist (time-info times) + (when-let* ((time-str (car time-info)) + (event-time (cdr time-info)) + (seconds-until (- (float-time event-time) (float-time now))) + (minutes-until (/ seconds-until 60))) + (when (and (> minutes-until 0) + (<= minutes-until lookahead-minutes)) + (when (or (not soonest-minutes) + (< minutes-until soonest-minutes)) + (setq soonest-minutes minutes-until) + (setq soonest-time-info time-info))))) + (when soonest-time-info + (list (car soonest-time-info) (cdr soonest-time-info) soonest-minutes)))) + +(defun chime--build-upcoming-events-list (events now tooltip-lookahead-minutes show-all-day-p) + "Build list of upcoming events within TOOLTIP-LOOKAHEAD-MINUTES from NOW. +EVENTS is the list of events to process. +If SHOW-ALL-DAY-P is non-nil, include all-day events in the list. +Returns sorted, deduplicated list of (EVENT TIME-INFO MINUTES-UNTIL) tuples." + (let ((upcoming '())) + ;; Collect events with their soonest timestamp within tooltip window + (dolist (event events) + (let* ((all-times (cdr (assoc 'times event))) + (times-for-tooltip (if show-all-day-p + all-times + (chime--filter-day-wide-events all-times))) + (soonest (chime--find-soonest-time-in-window + times-for-tooltip now tooltip-lookahead-minutes))) + (when soonest + (push (list event (cons (nth 0 soonest) (nth 1 soonest)) (nth 2 soonest)) + upcoming)))) + ;; Sort by time (soonest first) + (setq upcoming (sort upcoming (lambda (a b) (< (nth 2 a) (nth 2 b))))) + ;; Deduplicate by title - keep only soonest occurrence + (setq upcoming (chime--deduplicate-events-by-title upcoming)) + ;; Re-sort after deduplication + (sort upcoming (lambda (a b) (< (nth 2 a) (nth 2 b)))))) + +(defun chime--find-soonest-modeline-event (events now modeline-lookahead-minutes) + "Find soonest timed event for modeline from EVENTS within MODELINE-LOOKAHEAD-MINUTES. +NOW is the current time. +Returns (EVENT TIME-STR MINUTES-UNTIL EVENT-TEXT) or nil if none found." + (let ((soonest-event nil) + (soonest-event-text nil) + (soonest-minutes nil) + (soonest-time-info nil)) + (dolist (event events) + (let* ((all-times (cdr (assoc 'times event))) + ;; Always filter all-day events for modeline (need specific time) + (times-for-modeline (chime--filter-day-wide-events all-times)) + (soonest (chime--find-soonest-time-in-window + times-for-modeline now modeline-lookahead-minutes))) + (when soonest + (let ((minutes (nth 2 soonest))) + (when (or (not soonest-minutes) + (< minutes soonest-minutes)) + (setq soonest-minutes minutes) + (setq soonest-event event) + (setq soonest-time-info (cons (nth 0 soonest) (nth 1 soonest))) + (setq soonest-event-text + (chime--notification-text + `(,(car soonest-time-info) . (,soonest-minutes . medium)) + event))))))) + (when soonest-event + (list soonest-event (car soonest-time-info) soonest-minutes soonest-event-text)))) + +(defun chime--update-modeline (events) + "Update modeline with next upcoming event from EVENTS. +Orchestrates filtering, finding soonest event, and updating display. +Shows soonest event within `chime-modeline-lookahead-minutes' in modeline. +Tooltip shows events within `chime-tooltip-lookahead-hours' hours." + (if (or (not chime-enable-modeline) + (not chime-modeline-lookahead-minutes) + (zerop chime-modeline-lookahead-minutes)) + (progn + (setq chime-modeline-string nil) + (setq chime--upcoming-events nil)) + (let* ((now (current-time)) + (tooltip-lookahead-minutes (if chime-tooltip-lookahead-hours + (* chime-tooltip-lookahead-hours 60) + chime-modeline-lookahead-minutes)) + ;; Build list of upcoming events for tooltip + (upcoming (chime--build-upcoming-events-list + events now tooltip-lookahead-minutes + chime-tooltip-show-all-day-events)) + ;; Find soonest event for modeline display + (soonest-modeline (chime--find-soonest-modeline-event + events now chime-modeline-lookahead-minutes))) + ;; Store upcoming events for tooltip + (setq chime--upcoming-events upcoming) + ;; Format and set modeline string + (setq chime-modeline-string + (if soonest-modeline + ;; Show soonest event in modeline + (chime--propertize-modeline-string + (format chime-modeline-format (nth 3 soonest-modeline)) + (nth 0 soonest-modeline)) + ;; Show icon when no event in modeline window + (when chime-modeline-no-events-text + (let ((map (make-sparse-keymap)) + (tooltip-text (if upcoming + (chime--make-tooltip upcoming) + (chime--make-no-events-tooltip tooltip-lookahead-minutes)))) + (define-key map [mode-line mouse-1] #'chime--open-calendar-url) + (when upcoming + (define-key map [mode-line mouse-3] #'chime--jump-to-first-event)) + (propertize chime-modeline-no-events-text + 'help-echo tooltip-text + 'mouse-face 'mode-line-highlight + 'local-map map))))) + ;; Force update ALL windows/modelines + (force-mode-line-update t)))) + +(defun chime--get-tags (marker) + "Retrieve tags of MARKER." + (-> (org-entry-get marker "TAGS") + (or "") + (org-split-string ":"))) + +(defun chime--whitelist-predicates () + "Return list of whitelist predicate functions. +Combines keyword, tag, and custom predicate whitelists." + (->> `([,chime-keyword-whitelist + (lambda (it) + (-contains-p chime-keyword-whitelist + (org-with-point-at it (org-get-todo-state))))] + + [,chime-tags-whitelist + (lambda (it) + (-intersection chime-tags-whitelist + (chime--get-tags it)))] + + [,chime-predicate-whitelist + (lambda (marker) + (--some? (funcall it marker) chime-predicate-whitelist))]) + (--filter (aref it 0)) + (--map (aref it 1)))) + +(defun chime--blacklist-predicates () + "Return list of blacklist predicate functions. +Combines keyword, tag, and custom predicate blacklists." + (->> `([,chime-keyword-blacklist + (lambda (it) + (-contains-p chime-keyword-blacklist + (org-with-point-at it (org-get-todo-state))))] + + [,chime-tags-blacklist + (lambda (it) + (-intersection chime-tags-blacklist + (chime--get-tags it)))] + + [,chime-predicate-blacklist + (lambda (marker) + (--some? (funcall it marker) chime-predicate-blacklist))]) + (--filter (aref it 0)) + (--map (aref it 1)))) + +(defun chime-done-keywords-predicate (marker) + "Check if entry at MARKER has a done keyword." + (with-current-buffer (marker-buffer marker) + (save-excursion + (goto-char (marker-position marker)) + (member (nth 2 (org-heading-components)) org-done-keywords)))) + +(defun chime--apply-whitelist (markers) + "Apply whitelist to MARKERS." + (-if-let (whitelist-predicates (chime--whitelist-predicates)) + (-> (apply '-orfn whitelist-predicates) + (-filter markers)) + markers)) + +(defun chime--apply-blacklist (markers) + "Apply blacklist to MARKERS." + (-if-let (blacklist-predicates (chime--blacklist-predicates)) + (-> (apply '-orfn blacklist-predicates) + (-remove markers)) + markers)) + +(defconst chime-default-environment-regex + (macroexpand + `(rx string-start + (or ,@(mapcar (lambda (literal) (list 'literal literal)) + (list + "org-agenda-files" + "load-path" + "org-todo-keywords" + "chime-alert-intervals" + "chime-keyword-whitelist" + "chime-keyword-blacklist" + "chime-tags-whitelist" + "chime-tags-blacklist" + "chime-predicate-whitelist" + "chime-predicate-blacklist"))) + string-end))) + + +(defun chime-environment-regex () + "Generate regex for environment variables to inject into async process." + (macroexpand + `(rx (or + ,@(mapcar (lambda (regexp) (list 'regexp regexp)) + (cons chime-default-environment-regex + chime-additional-environment-regexes)))))) + +(defun chime--retrieve-events () + "Get events from agenda view." + `(lambda () + (setf org-agenda-use-time-grid nil) + (setf org-agenda-compact-blocks t) + ,(async-inject-variables (chime-environment-regex)) + + (package-initialize) + (require 'chime) + + ;; Load optional dependencies for org-mode diary sexps + ;; Many users have sexp entries like %%(org-contacts-anniversaries) in their + ;; org files, which generate dynamic agenda entries. These sexps are evaluated + ;; when org-agenda-list runs, so the required packages must be loaded in this + ;; async subprocess. We use (require ... nil t) to avoid errors if packages + ;; aren't installed - the sexp will simply fail gracefully with a "Bad sexp" + ;; warning that won't break event retrieval. + (require 'org-contacts nil t) + + ;; Calculate agenda span based on max lookahead (convert to days, round up) + ;; Use the larger of modeline-lookahead (minutes) and tooltip-lookahead (hours) to ensure + ;; we fetch enough events for both. Add 1 day buffer to account for partial days. + (let* ((tooltip-lookahead-minutes (if chime-tooltip-lookahead-hours + (* chime-tooltip-lookahead-hours 60) + chime-modeline-lookahead-minutes)) + (max-lookahead-minutes (max chime-modeline-lookahead-minutes tooltip-lookahead-minutes)) + (max-lookahead-days (ceiling (/ max-lookahead-minutes 1440.0))) + (agenda-span (+ max-lookahead-days 1))) + (org-agenda-list agenda-span (org-read-date nil nil "today"))) + + (->> (org-split-string (buffer-string) "\n") + (--map (plist-get + (org-fix-agenda-info (text-properties-at 0 it)) + 'org-marker)) + (-non-nil) + (chime--apply-whitelist) + (chime--apply-blacklist) + (-map 'chime--gather-info)))) + +(defun chime--notify (msg-severity) + "Notify about an event using `alert' library. +MSG-SEVERITY is a cons cell (MESSAGE . SEVERITY) where MESSAGE is the +notification text and SEVERITY is one of high, medium, or low." + (let* ((event-msg (if (consp msg-severity) (car msg-severity) msg-severity)) + (severity (if (consp msg-severity) (cdr msg-severity) 'medium))) + ;; Play sound if enabled and sound file is specified + (when (and chime-play-sound chime-sound-file) + (condition-case err + (when (file-exists-p chime-sound-file) + (play-sound-file chime-sound-file)) + (error + (message "chime: Failed to play sound: %s" + (error-message-string err))))) + ;; Show visual notification + (apply + 'alert event-msg + :icon chime-notification-icon + :title chime-notification-title + :severity severity + :category 'chime + chime-extra-alert-plist))) + +(defun chime--convert-12hour-to-24hour (timestamp hour) + "Convert HOUR from 12-hour to 24-hour format based on TIMESTAMP's am/pm suffix. +TIMESTAMP is the original timestamp string (e.g., \"<2025-11-05 Wed 1:30pm>\"). +HOUR is the hour value from org-parse-time-string (1-12 for 12-hour format). + +Returns converted hour in 24-hour format (0-23): +- 12pm → 12 (noon) +- 1-11pm → 13-23 (add 12) +- 12am → 0 (midnight) +- 1-11am → 1-11 (no change) +- No am/pm → HOUR unchanged (24-hour format)" + (let ((is-pm (string-match-p "[0-9]:[0-9]\\{2\\}[[:space:]]*pm" (downcase timestamp))) + (is-am (string-match-p "[0-9]:[0-9]\\{2\\}[[:space:]]*am" (downcase timestamp)))) + (cond + ;; 12pm = 12:00 (noon), don't add 12 + ((and is-pm (= hour 12)) 12) + ;; 1-11pm: add 12 to get 13-23 + (is-pm (+ hour 12)) + ;; 12am = 00:00 (midnight) + ((and is-am (= hour 12)) 0) + ;; 1-11am or 24-hour format: use as-is + (t hour)))) + +(defun chime--timestamp-parse (timestamp) + "Parse TIMESTAMP and return time in list-of-integer format. +Returns nil if parsing fails or timestamp is malformed." + (condition-case err + (when (and timestamp + (stringp timestamp) + (not (string-empty-p timestamp)) + ;; Validate angle bracket format + (string-match-p "<.*>" timestamp) + ;; Ensure timestamp has time component (HH:MM format) + (string-match-p "[0-9]\\{1,2\\}:[0-9]\\{2\\}" timestamp)) + (let ((parsed (org-parse-time-string timestamp)) + (today (format-time-string "<%Y-%m-%d>"))) + (when (and parsed + (decoded-time-hour parsed) + (decoded-time-minute parsed)) + ;; Validate date components are in reasonable ranges + (let* ((month (decoded-time-month parsed)) + (day (decoded-time-day parsed)) + (raw-hour (decoded-time-hour parsed)) + (minute (decoded-time-minute parsed)) + ;; Convert 12-hour am/pm format to 24-hour format + (hour (chime--convert-12hour-to-24hour timestamp raw-hour))) + (when (and month day hour minute + (>= month 1) (<= month 12) + (>= day 1) (<= day 31) + (>= hour 0) (<= hour 23) + (>= minute 0) (<= minute 59)) + ;; seconds-to-time returns also milliseconds and nanoseconds so we + ;; have to "trim" the list + (butlast + (seconds-to-time + (time-add + ;; we get the cycled absolute day (not hour and minutes) + (org-time-from-absolute (org-closest-date timestamp today 'past)) + ;; so we have to add the minutes too + (+ (* hour 3600) + (* minute 60)))) + 2)))))) + (error + (message "chime: Failed to parse timestamp '%s': %s" + timestamp (error-message-string err)) + nil))) + +(defun chime--extract-time (marker) + "Extract timestamps from MARKER using source-aware extraction. + +For org-gcal events (those with :entry-id: property): + - Extract ONLY from :org-gcal: drawer + (ignores SCHEDULED/DEADLINE and body text) + - This prevents showing stale timestamps after rescheduling + +For regular org events: + - Prefer SCHEDULED and DEADLINE from properties + - Fall back to plain timestamps in entry body + +Timestamps are extracted as cons cells: +\(org-formatted-string . parsed-time)." + (org-with-point-at marker + (let ((is-gcal-event (org-entry-get marker "entry-id"))) + (if is-gcal-event + ;; org-gcal event: extract ONLY from :org-gcal: drawer + (let ((timestamps nil)) + (save-excursion + (org-back-to-heading t) + ;; Search for :org-gcal: drawer + (when (re-search-forward "^[ \t]*:org-gcal:" + (save-excursion (org-end-of-subtree t) (point)) + t) + (let ((drawer-start (point)) + (drawer-end (save-excursion + (if (re-search-forward "^[ \t]*:END:" + (save-excursion (org-end-of-subtree t) (point)) + t) + (match-beginning 0) + (point))))) + ;; Extract timestamps within drawer boundaries + (goto-char drawer-start) + (while (re-search-forward org-ts-regexp drawer-end t) + (let ((timestamp-str (match-string 0))) + (push (cons timestamp-str + (chime--timestamp-parse timestamp-str)) + timestamps)))))) + (-non-nil (nreverse timestamps))) + ;; Regular org event: prefer SCHEDULED/DEADLINE, fall back to plain timestamps + (let ((property-timestamps + ;; Extract SCHEDULED and DEADLINE from properties + (-non-nil + (--map + (let ((org-timestamp (org-entry-get marker it))) + (and org-timestamp + (cons org-timestamp + (chime--timestamp-parse org-timestamp)))) + '("DEADLINE" "SCHEDULED")))) + (plain-timestamps + ;; Extract plain timestamps from entry body + ;; Skip planning lines (SCHEDULED, DEADLINE, CLOSED) to avoid duplicates + (let ((timestamps nil)) + (save-excursion + ;; Skip heading and planning lines, but NOT other drawers (nil arg) + (org-end-of-meta-data nil) + (let ((start (point)) + (end (save-excursion (org-end-of-subtree t) (point)))) + ;; Only search if there's content after metadata + (when (< start end) + (goto-char start) + ;; Search for timestamps until end of entry + (while (re-search-forward org-ts-regexp end t) + (let ((timestamp-str (match-string 0))) + (push (cons timestamp-str + (chime--timestamp-parse timestamp-str)) + timestamps)))))) + (nreverse timestamps)))) + ;; Combine property and plain timestamps, removing duplicates and nils + (-non-nil (append property-timestamps plain-timestamps))))))) + +(defun chime--sanitize-title (title) + "Sanitize TITLE to prevent Lisp read syntax errors during async serialization. +Balances unmatched parentheses, brackets, and braces by adding matching pairs. +Returns sanitized title or empty string if TITLE is nil." + (if (not title) + "" + (let ((chars (string-to-list title)) + (stack '()) ; Stack to track opening delimiters in order + (result '())) + ;; Process each character + (dolist (char chars) + (cond + ;; Opening delimiters - add to stack and result + ((memq char '(?\( ?\[ ?\{)) + (push char stack) + (push char result)) + ;; Closing delimiters - check if they match + ((eq char ?\)) + (if (and stack (eq (car stack) ?\()) + (progn + (pop stack) + (push char result)) + ;; Unmatched closing paren - skip it + nil)) + ((eq char ?\]) + (if (and stack (eq (car stack) ?\[)) + (progn + (pop stack) + (push char result)) + ;; Unmatched closing bracket - skip it + nil)) + ((eq char ?\}) + (if (and stack (eq (car stack) ?\{)) + (progn + (pop stack) + (push char result)) + ;; Unmatched closing brace - skip it + nil)) + ;; Regular characters - add to result + (t + (push char result)))) + ;; Add closing delimiters for any remaining opening delimiters + (dolist (opener stack) + (cond + ((eq opener ?\() (push ?\) result)) + ((eq opener ?\[) (push ?\] result)) + ((eq opener ?\{) (push ?\} result)))) + ;; Convert back to string (reverse because we built it backwards) + (concat (nreverse result))))) + +(defun chime--extract-title (marker) + "Extract event title from MARKER. +MARKER acts like the event's identifier. +Title is sanitized to prevent Lisp read syntax errors." + (org-with-point-at marker + (-let (((_lvl _reduced-lvl _todo _priority title _tags) + (org-heading-components))) + (chime--sanitize-title title)))) + +(defun chime--gather-info (marker) + "Collect information about an event. +MARKER acts like event's identifier. +Returns file path and position instead of marker object for proper +async serialization (markers can't be serialized across processes, +especially when buffer names contain angle brackets)." + `((times . ,(chime--extract-time marker)) + (title . ,(chime--extract-title marker)) + (intervals . ,chime-alert-intervals) + (marker-file . ,(buffer-file-name (marker-buffer marker))) + (marker-pos . ,(marker-position marker)))) + +;;;###autoload +(defun chime-validate-configuration () + "Validate chime's runtime environment and configuration. +Returns a list of (SEVERITY MESSAGE) pairs, or nil if all checks pass. +SEVERITY is one of: :error :warning :info + +Checks performed: +- org-agenda-files is set and non-empty +- org-agenda-files exist on disk +- org-agenda package is loadable +- global-mode-string available (for modeline display) + +When called interactively, displays results via message/warning system. +When called programmatically, returns structured validation results." + (interactive) + (let ((issues '())) + + ;; Critical: org-agenda-files must be set and non-empty + (unless (and (boundp 'org-agenda-files) + org-agenda-files + (listp org-agenda-files) + (> (length org-agenda-files) 0)) + (push '(:error "org-agenda-files is not set or empty.\nChime cannot check for events without org files to monitor.\n\nSet org-agenda-files in your config:\n (setq org-agenda-files '(\"~/org/inbox.org\" \"~/org/work.org\"))") + issues)) + + ;; Warning: Check if files actually exist + (when (and (boundp 'org-agenda-files) + org-agenda-files + (listp org-agenda-files)) + (let ((missing-files + (cl-remove-if #'file-exists-p org-agenda-files))) + (when missing-files + (push `(:warning ,(format "%d org-agenda-files don't exist:\n %s\n\nChime will skip these files during event checks." + (length missing-files) + (mapconcat #'identity missing-files "\n "))) + issues)))) + + ;; Check org-agenda is loadable + (unless (require 'org-agenda nil t) + (push '(:error "Cannot load org-agenda.\nEnsure org-mode is installed and available in load-path.") + issues)) + + ;; Check modeline support (if enabled) + (when (and chime-enable-modeline + (not (boundp 'global-mode-string))) + (push '(:warning "global-mode-string not available.\nModeline display may not work in this Emacs version.") + issues)) + + ;; Display results if interactive + (when (called-interactively-p 'any) + (if (null issues) + (message "Chime: ✓ All validation checks passed!") + ;; Show errors and warnings + (let ((errors (cl-remove-if-not (lambda (i) (eq (car i) :error)) issues)) + (warnings (cl-remove-if-not (lambda (i) (eq (car i) :warning)) issues))) + (when errors + (dolist (err errors) + (display-warning 'chime (cadr err) :error))) + (when warnings + (dolist (warn warnings) + (display-warning 'chime (cadr warn) :warning)))))) + + ;; Return issues for programmatic use + issues)) + +(defun chime--stop () + "Stop the notification timer and cancel any in-progress check." + (-some-> chime--timer (cancel-timer)) + (when chime--process + (interrupt-process chime--process) + (setq chime--process nil)) + ;; Reset validation state so it runs again on next start + (setq chime--validation-done nil) + (setq chime--validation-retry-count 0)) + +(defun chime--start () + "Start the notification timer. Cancel old one, if any. +Timer interval is controlled by `chime-check-interval'. +First check runs after `chime-startup-delay' seconds to allow +org-agenda-files to load. + +Configuration validation happens on the first `chime-check' call, +after the startup delay has elapsed. This gives startup hooks time +to populate org-agenda-files." + (chime--stop) + + ;; Wait chime-startup-delay seconds before first check + ;; This allows org-agenda-files and related infrastructure to finish loading + (when (featurep 'chime-debug) + (chime--log-silently "Chime: Scheduling first check in %d seconds" chime-startup-delay)) + + ;; Schedule repeating timer: first run at t=chime-startup-delay, then every chime-check-interval + (--> (run-at-time chime-startup-delay chime-check-interval 'chime-check) + (setf chime--timer it))) + +(defun chime--process-notifications (events) + "Process EVENTS and send notifications for upcoming items. +Handles both regular event notifications and day-wide alerts." + (-each + (->> events + (-map 'chime--check-event) + (-flatten) + (-uniq)) + 'chime--notify) + (when (chime-current-time-is-day-wide-time) + (mapc 'chime--notify + (chime-day-wide-notifications events)))) + +(defun chime--fetch-and-process (callback) + "Asynchronously fetch events from agenda and invoke CALLBACK with them. +Manages async process state and last-check-time internally. +Does nothing if a check is already in progress." + (unless (and chime--process + (process-live-p chime--process)) + (setq chime--process + (let ((default-directory user-emacs-directory) + (async-prompt-for-password nil) + (async-process-noquery-on-exit t)) + (async-start + (chime--retrieve-events) + (lambda (events) + (setq chime--process nil) + (setq chime--last-check-time (current-time)) + ;; Handle errors from async process + (condition-case err + (progn + ;; Check if events is an error signal from async process + (if (and (listp events) + (eq (car events) 'async-signal)) + (progn + ;; Async process returned an error + (when (featurep 'chime-debug) + (chime--debug-log-async-error (cdr events))) + (chime--log-silently "Chime: Async error: %s" + (error-message-string (cdr events))) + (message "Chime: Event check failed - see *Messages* for details")) + ;; Success - process events normally + (when (featurep 'chime-debug) + (chime--debug-log-async-complete events)) + (funcall callback events))) + (error + ;; Error occurred in callback processing + (when (featurep 'chime-debug) + (chime--debug-log-async-error err)) + (chime--log-silently "Chime: Error processing events: %s" + (error-message-string err)) + (message "Chime: Error processing events - see *Messages* for details"))))))))) + +(defun chime--log-silently (format-string &rest args) + "Append formatted message to *Messages* buffer without echoing. +FORMAT-STRING and ARGS are passed to `format'." + (let ((inhibit-read-only t)) + (with-current-buffer (get-buffer-create "*Messages*") + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert (apply #'format format-string args)) + (unless (bolp) (insert "\n"))))) + +;;;###autoload +(cl-defun chime-check () + "Parse agenda view and notify about upcoming events. + +Do nothing if a check is already in progress in the background. + +On the first call after `chime-mode' is enabled, validates the runtime +configuration. This happens after `chime-startup-delay', giving startup +hooks time to populate org-agenda-files. If validation fails, logs an +error and skips the check." + (interactive) + + ;; Validate configuration on first check only + (unless chime--validation-done + (let ((issues (chime-validate-configuration))) + (if (cl-some (lambda (i) (eq (car i) :error)) issues) + (progn + ;; Critical errors found - increment retry counter + (setq chime--validation-retry-count (1+ chime--validation-retry-count)) + + ;; Check if we've exceeded max retries + (if (> chime--validation-retry-count chime-validation-max-retries) + ;; Max retries exceeded - show full error + (let ((errors (cl-remove-if-not (lambda (i) (eq (car i) :error)) issues))) + (chime--log-silently "Chime: Configuration validation failed with %d error(s) after %d retries:" + (length errors) + chime--validation-retry-count) + (dolist (err errors) + (chime--log-silently "") + (chime--log-silently "ERROR: %s" (cadr err))) + (message "Chime: Configuration errors detected (see *Messages* buffer for details)")) + ;; Still within retry limit - show friendly waiting message + (message "Chime: Waiting for org-agenda-files to load... (attempt %d/%d)" + chime--validation-retry-count + chime-validation-max-retries)) + + ;; Don't mark validation as done - will retry on next check + ;; in case dependencies load later + ;; Don't proceed with check + (cl-return-from chime-check nil)) + ;; No errors - mark validation as done and reset retry counter + (setq chime--validation-done t) + (setq chime--validation-retry-count 0)))) + + ;; Validation passed or already done - proceed with check + (chime--fetch-and-process + (lambda (events) + (chime--process-notifications events) + (chime--update-modeline events)))) + +;;;###autoload +(defun chime-refresh-modeline () + "Update modeline display with latest events without sending notifications. + +Useful after external calendar sync operations (e.g., org-gcal-sync). +Does nothing if a check is already in progress in the background." + (interactive) + (chime--fetch-and-process + (lambda (events) + (chime--update-modeline events)))) + +;;;###autoload +(define-minor-mode chime-mode + "Toggle org notifications globally. +When enabled parses your agenda once a minute and emits notifications +if needed." + :global + :lighter chime-modeline-lighter + (if chime-mode + (progn + (chime--start) + ;; Add modeline string to global-mode-string + (when (and chime-enable-modeline + (> chime-modeline-lookahead-minutes 0)) + (if global-mode-string + (add-to-list 'global-mode-string 'chime-modeline-string 'append) + (setq global-mode-string '("" chime-modeline-string))))) + (progn + (chime--stop) + ;; Remove modeline string from global-mode-string + (setq global-mode-string + (delq 'chime-modeline-string global-mode-string)) + (setq chime-modeline-string nil) + ;; Force update ALL windows/modelines, not just current buffer + (force-mode-line-update t)))) + +;; Automatically enable debug features when debug mode is on +;; Only enable in the main Emacs process, not in async subprocesses. +;; We detect async context by checking if this is an interactive session. +;; Async child processes run in batch mode with noninteractive=t. +(when (and chime-debug + (not noninteractive)) + (chime-debug-monitor-event-loading) + (chime-debug-enable-async-monitoring)) + +(provide 'chime) +;;; chime.el ends here diff --git a/convert-org-contacts-birthdays.el b/convert-org-contacts-birthdays.el new file mode 100644 index 0000000..670e492 --- /dev/null +++ b/convert-org-contacts-birthdays.el @@ -0,0 +1,236 @@ +;;; convert-org-contacts-birthdays.el --- Convert org-contacts birthdays to plain timestamps -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> +;; Version: 1.0.0 +;; Package-Requires: ((emacs "27.1") (org "9.0")) +;; Keywords: calendar, org-mode +;; URL: https://github.com/cjennings/chime.el + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; This utility converts org-contacts files IN-PLACE, adding birthday timestamps. +;; +;; Problem: +;; When using chime.el with org-contacts diary sexps like +;; %%(org-contacts-anniversaries), the async subprocess needs org-contacts +;; loaded or you get "Bad sexp" errors. +;; +;; Solution: +;; Convert your contacts.org file in-place by adding plain yearly repeating +;; timestamps after each contact's properties drawer. The :BIRTHDAY: property +;; is kept for vCard export compatibility. +;; +;; Usage: +;; M-x chime-convert-contacts-in-place RET ~/org/contacts.org RET +;; +;; Input: Contact with :BIRTHDAY: property +;; * Alice Anderson +;; :PROPERTIES: +;; :EMAIL: alice@example.com +;; :BIRTHDAY: 1985-03-15 +;; :END: +;; +;; Output: Same contact with added timestamp +;; * Alice Anderson +;; :PROPERTIES: +;; :EMAIL: alice@example.com +;; :BIRTHDAY: 1985-03-15 +;; :END: +;; <1985-03-15 Fri +1y> +;; +;; After conversion: +;; 1. Comment out %%(org-contacts-anniversaries) from schedule.org +;; 2. Birthdays appear in agenda via plain timestamps +;; 3. Chime works without errors +;; 4. vCard export still works + +;;; Code: + +(require 'org) + +(defun chime--extract-birthday-year (birthday-string) + "Extract year from BIRTHDAY-STRING, handling various formats. +Returns nil if no year is present or if year should be ignored. +Handles formats like: + 2000-03-15 + 03-15 + 1985-12-25" + (when (string-match "^\\([0-9]\\{4\\}\\)-[0-9]\\{2\\}-[0-9]\\{2\\}$" birthday-string) + (string-to-number (match-string 1 birthday-string)))) + +(defun chime--parse-birthday (birthday-string) + "Parse BIRTHDAY-STRING into (YEAR MONTH DAY) list. +YEAR may be nil if not present in the string. +Handles formats: + 2000-03-15 → (2000 3 15) + 03-15 → (nil 3 15) + 1985-12-25 → (1985 12 25)" + (cond + ;; Format: YYYY-MM-DD + ((string-match "^\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday-string) + (list (string-to-number (match-string 1 birthday-string)) + (string-to-number (match-string 2 birthday-string)) + (string-to-number (match-string 3 birthday-string)))) + ;; Format: MM-DD + ((string-match "^\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday-string) + (list nil + (string-to-number (match-string 1 birthday-string)) + (string-to-number (match-string 2 birthday-string)))) + (t + (user-error "Cannot parse birthday format: %s (expected YYYY-MM-DD or MM-DD)" birthday-string)))) + +(defun chime--format-birthday-timestamp (year month day) + "Format birthday as org timestamp. +If YEAR is nil, uses current year. +Returns yearly repeating timestamp like <2026-03-15 Sun +1y>." + (let* ((use-year (or year (nth 5 (decode-time)))) + (time (encode-time 0 0 0 day month use-year)) + (dow (format-time-string "%a" time)) + (date-str (format "%04d-%02d-%02d" use-year month day))) + (format "<%s %s +1y>" date-str dow))) + +(defun chime--backup-contacts-file (contacts-file) + "Create timestamped backup of CONTACTS-FILE. +Returns the backup file path." + (let* ((backup-name (format "%s.backup-%s" + contacts-file + (format-time-string "%Y-%m-%d-%H%M%S"))) + (backup-path (expand-file-name backup-name))) + (copy-file contacts-file backup-path) + backup-path)) + +(defun chime--insert-birthday-timestamp-after-drawer (birthday-value) + "Insert birthday timestamp after current properties drawer. +BIRTHDAY-VALUE is the value from :BIRTHDAY: property (YYYY-MM-DD or MM-DD). +Point should be at the heading with the properties drawer. +Does not insert if a yearly repeating timestamp already exists." + (let* ((parsed (chime--parse-birthday birthday-value)) + (year (nth 0 parsed)) + (month (nth 1 parsed)) + (day (nth 2 parsed)) + (timestamp (chime--format-birthday-timestamp year month day)) + (heading-end (save-excursion (outline-next-heading) (point)))) + ;; Find end of properties drawer + (when (re-search-forward "^[ \t]*:END:[ \t]*$" heading-end t) + (let ((drawer-end (point))) + ;; Check if a yearly repeating timestamp already exists between drawer end and next heading + (goto-char drawer-end) + (unless (re-search-forward "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}[^>]*\\+1y>" heading-end t) + ;; No existing yearly timestamp found, insert new one + (goto-char drawer-end) + (end-of-line) + (insert "\n" timestamp)))))) + +(defun chime--process-contact-entry () + "Process current contact entry, adding birthday timestamp if needed. +Returns t if birthday was added, nil otherwise. +Point should be at the heading." + (let ((birthday-value (org-entry-get (point) "BIRTHDAY"))) + (when (and birthday-value + (not (string-blank-p birthday-value))) + (chime--insert-birthday-timestamp-after-drawer birthday-value) + t))) + +(defun chime--convert-contacts-file-in-place (contacts-file) + "Convert CONTACTS-FILE in-place, adding birthday timestamps. +Creates a backup first. Returns number of birthdays converted." + (let ((count 0) + (backup-file (chime--backup-contacts-file contacts-file))) + (with-current-buffer (find-file-noselect contacts-file) + (save-excursion + (goto-char (point-min)) + ;; Process each heading + (while (re-search-forward "^\\* " nil t) + (beginning-of-line) + (when (chime--process-contact-entry) + (setq count (1+ count))) + (outline-next-heading))) + (save-buffer)) + (cons count backup-file))) + +;;;###autoload +(defun chime-convert-contacts-in-place (contacts-file) + "Convert org-contacts file IN-PLACE, adding birthday timestamps. + +SAFETY: Creates timestamped backup before modifying the file. + +What this does: +- Creates backup: contacts.org.backup-YYYY-MM-DD-HHMMSS +- For each contact with :BIRTHDAY: property: + - Adds plain timestamp after properties drawer: <YYYY-MM-DD DayOfWeek +1y> + - KEEPS the :BIRTHDAY: property (for vCard export) +- Writes changes to original file + +After conversion: +1. Comment out %%(org-contacts-anniversaries) in your schedule file +2. Birthdays will appear in agenda via plain timestamps +3. Chime will work without errors +4. vCard export still works via :BIRTHDAY: property + +Example: + Before: + * Alice Anderson + :PROPERTIES: + :EMAIL: alice@example.com + :BIRTHDAY: 1985-03-15 + :END: + + After: + * Alice Anderson + :PROPERTIES: + :EMAIL: alice@example.com + :BIRTHDAY: 1985-03-15 + :END: + <1985-03-15 Fri +1y> + +Example usage: + M-x chime-convert-contacts-in-place RET ~/org/contacts.org RET" + (interactive "fContacts file to convert: ") + + (let ((contacts-file (expand-file-name contacts-file))) + ;; Validate file exists + (unless (file-exists-p contacts-file) + (user-error "File does not exist: %s" contacts-file)) + + ;; Safety confirmation + (unless (yes-or-no-p + (format "This will MODIFY %s (backup will be created). Continue? " + contacts-file)) + (user-error "Conversion cancelled")) + + ;; Perform conversion + (let* ((result (chime--convert-contacts-file-in-place contacts-file)) + (count (car result)) + (backup-file (cdr result))) + + (if (= count 0) + (progn + (message "No birthdays found to convert in %s" contacts-file) + (message "Backup created at: %s (you may want to delete it)" backup-file)) + (message "Converted %d birthday%s in %s" + count + (if (= count 1) "" "s") + contacts-file) + (message "Backup saved to: %s" backup-file) + (message "Next steps:") + (message " 1. Comment out: %%(org-contacts-anniversaries)") + (message " 2. Run M-x chime-check to verify") + (message " 3. Delete backup if conversion looks good"))))) + +(provide 'convert-org-contacts-birthdays) +;;; convert-org-contacts-birthdays.el ends here diff --git a/sounds/chime.wav b/sounds/chime.wav Binary files differnew file mode 100644 index 0000000..5b9f3ca --- /dev/null +++ b/sounds/chime.wav diff --git a/tests/Makefile b/tests/Makefile new file mode 100644 index 0000000..18df12e --- /dev/null +++ b/tests/Makefile @@ -0,0 +1,244 @@ +# Makefile for chime.el test suite +# Usage: +# make test - Run all tests +# make test-file FILE=overdue - Run tests in one file +# make test-one TEST=name - Run one specific test +# make test-unit - Run unit tests only +# make test-integration - Run integration tests only +# make clean - Remove byte-compiled files + +# Configuration +EMACS ?= emacs +EMACSFLAGS = --batch -Q +TESTFLAGS = -l ert + +# Dependency paths (adjust if needed) +ELPA_DIR = $(HOME)/.emacs.d/elpa +DASH_DIR = $(shell find $(ELPA_DIR) -maxdepth 1 -name 'dash-*' -type d 2>/dev/null | head -1) +ALERT_DIR = $(shell find $(ELPA_DIR) -maxdepth 1 -name 'alert-*' -type d 2>/dev/null | head -1) +ASYNC_DIR = $(shell find $(ELPA_DIR) -maxdepth 1 -name 'async-*' -type d 2>/dev/null | head -1) + +# Build load path +LOADPATH = -L $(DASH_DIR) -L $(ALERT_DIR) -L $(ASYNC_DIR) + +# Test files +ALL_TESTS = $(wildcard test-*.el) +UNIT_TESTS = $(filter-out test-chime-gcal% test-chime-notifications.el test-chime-process-notifications.el,$(ALL_TESTS)) +INTEGRATION_TESTS = test-chime-notifications.el test-chime-process-notifications.el + +# Colors for output (if terminal supports it) +RED = \033[0;31m +GREEN = \033[0;32m +YELLOW = \033[1;33m +NC = \033[0m # No Color + +.PHONY: all test test-file test-one test-unit test-integration validate lint clean help check-deps + +# Default target +all: test + +# Check if dependencies are available +check-deps: + @if [ -z "$(DASH_DIR)" ]; then \ + echo "$(RED)Error: dash package not found in $(ELPA_DIR)$(NC)"; \ + exit 1; \ + fi + @if [ -z "$(ALERT_DIR)" ]; then \ + echo "$(RED)Error: alert package not found in $(ELPA_DIR)$(NC)"; \ + exit 1; \ + fi + @if [ -z "$(ASYNC_DIR)" ]; then \ + echo "$(RED)Error: async package not found in $(ELPA_DIR)$(NC)"; \ + exit 1; \ + fi + @echo "$(GREEN)✓ All dependencies found$(NC)" + +# Run all tests +test: check-deps + @echo "$(YELLOW)Running all tests ($(words $(ALL_TESTS)) files, ~339 tests)...$(NC)" + @$(EMACS) $(EMACSFLAGS) $(LOADPATH) $(TESTFLAGS) \ + --eval "(dolist (f (directory-files \".\" t \"^test-.*\\\\.el$$\")) (load f))" \ + --eval '(ert-run-tests-batch-and-exit)' 2>&1 | tee test-output.log + @if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ All tests passed!$(NC)"; \ + else \ + echo "$(RED)✗ Some tests failed. See test-output.log for details.$(NC)"; \ + exit 1; \ + fi + +# Run tests in one file +test-file: check-deps +ifndef FILE + @echo "$(RED)Error: FILE not specified$(NC)" + @echo "Usage: make test-file FILE=overdue" + @echo " make test-file FILE=test-chime-overdue-todos.el" + @exit 1 +endif + @TESTFILE=$$(find . -maxdepth 1 -name "*$(FILE)*.el" -type f | head -1); \ + if [ -z "$$TESTFILE" ]; then \ + echo "$(RED)Error: No test file matching '$(FILE)' found$(NC)"; \ + exit 1; \ + fi; \ + echo "$(YELLOW)Running tests in $$TESTFILE...$(NC)"; \ + $(EMACS) $(EMACSFLAGS) $(LOADPATH) $(TESTFLAGS) -l "$$TESTFILE" \ + --eval '(ert-run-tests-batch-and-exit)' 2>&1 | tee test-file-output.log; \ + if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ All tests in $$TESTFILE passed!$(NC)"; \ + else \ + echo "$(RED)✗ Some tests failed.$(NC)"; \ + exit 1; \ + fi + +# Run one specific test +test-one: check-deps +ifndef TEST + @echo "$(RED)Error: TEST not specified$(NC)" + @echo "Usage: make test-one TEST=pilot" + @echo " make test-one TEST=test-overdue-has-passed-time-today-all-day" + @exit 1 +endif + @echo "$(YELLOW)Searching for test matching '$(TEST)'...$(NC)" + @TESTFILE=$$(grep -l "ert-deftest.*$(TEST)" test-*.el 2>/dev/null | head -1); \ + if [ -z "$$TESTFILE" ]; then \ + echo "$(RED)Error: No test matching '$(TEST)' found$(NC)"; \ + exit 1; \ + fi; \ + TESTNAME=$$(grep "ert-deftest.*$(TEST)" "$$TESTFILE" | sed 's/^(ert-deftest \([^ ]*\).*/\1/' | head -1); \ + echo "$(YELLOW)Running test '$$TESTNAME' in $$TESTFILE...$(NC)"; \ + $(EMACS) $(EMACSFLAGS) $(LOADPATH) $(TESTFLAGS) -l "$$TESTFILE" \ + --eval "(ert-run-tests-batch-and-exit \"$$TESTNAME\")" 2>&1; \ + if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ Test $$TESTNAME passed!$(NC)"; \ + else \ + echo "$(RED)✗ Test $$TESTNAME failed.$(NC)"; \ + exit 1; \ + fi + +# Run only unit tests +test-unit: check-deps + @echo "$(YELLOW)Running unit tests ($(words $(UNIT_TESTS)) files)...$(NC)" + @for testfile in $(UNIT_TESTS); do \ + echo " Testing $$testfile..."; \ + done + @$(EMACS) $(EMACSFLAGS) $(LOADPATH) $(TESTFLAGS) \ + $(foreach file,$(UNIT_TESTS),-l $(file)) \ + --eval '(ert-run-tests-batch-and-exit)' 2>&1 | tee test-unit-output.log + @if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ All unit tests passed!$(NC)"; \ + else \ + echo "$(RED)✗ Some unit tests failed.$(NC)"; \ + exit 1; \ + fi + +# Run only integration tests +test-integration: check-deps + @echo "$(YELLOW)Running integration tests ($(words $(INTEGRATION_TESTS)) files)...$(NC)" + @$(EMACS) $(EMACSFLAGS) $(LOADPATH) $(TESTFLAGS) \ + $(foreach file,$(INTEGRATION_TESTS),-l $(file)) \ + --eval '(ert-run-tests-batch-and-exit)' 2>&1 | tee test-integration-output.log + @if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ All integration tests passed!$(NC)"; \ + else \ + echo "$(RED)✗ Some integration tests failed.$(NC)"; \ + exit 1; \ + fi + +# Count tests +count: + @echo "Test file inventory:" + @for f in $(ALL_TESTS); do \ + count=$$(grep -c "^(ert-deftest" "$$f" 2>/dev/null || echo 0); \ + printf "%3d tests - %s\n" "$$count" "$$f"; \ + done | sort -rn + @total=$$(find . -name "test-*.el" -exec grep -c "^(ert-deftest" {} \; | awk '{sum+=$$1} END {print sum}'); \ + echo "$(GREEN)Total: $$total tests across $(words $(ALL_TESTS)) files$(NC)" + +# List all available tests +list: + @echo "Available tests:" + @grep -h "^(ert-deftest" test-*.el | sed 's/^(ert-deftest \([^ ]*\).*/ \1/' | sort + +# Validate Emacs Lisp syntax +validate: + @echo "$(YELLOW)Validating Emacs Lisp syntax...$(NC)" + @failed=0; \ + total=0; \ + for file in ../chime.el test-*.el testutil-*.el; do \ + if [ -f "$$file" ] && [ ! -d "$$file" ]; then \ + total=$$((total + 1)); \ + output=$$($(EMACS) --batch $(LOADPATH) --eval "(progn \ + (setq byte-compile-error-on-warn nil) \ + (find-file \"$$file\") \ + (condition-case err \ + (progn \ + (check-parens) \ + (message \"✓ $$file - parentheses balanced\")) \ + (error \ + (message \"✗ $$file: %s\" (error-message-string err)) \ + (kill-emacs 1))))" 2>&1 | grep -E '(✓|✗)'); \ + if [ $$? -eq 0 ]; then \ + echo "$(GREEN)$$output$(NC)"; \ + else \ + echo "$(RED)$$output$(NC)"; \ + failed=$$((failed + 1)); \ + fi; \ + fi; \ + done; \ + if [ $$failed -eq 0 ]; then \ + echo "$(GREEN)✓ All $$total files validated successfully$(NC)"; \ + else \ + echo "$(RED)✗ $$failed of $$total files failed validation$(NC)"; \ + exit 1; \ + fi + +# Comprehensive linting with elisp-lint +lint: + @echo "$(YELLOW)Running elisp-lint...$(NC)" + @$(EMACS) --batch --eval "(progn \ + (require 'package) \ + (package-initialize) \ + (require 'elisp-lint))" \ + -f elisp-lint-files-batch \ + --no-checkdoc \ + ../chime.el test-*.el testutil-*.el 2>&1; \ + if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ Linting completed successfully$(NC)"; \ + else \ + echo "$(RED)✗ Linting found issues (see above)$(NC)"; \ + exit 1; \ + fi + +# Clean byte-compiled files +clean: + @echo "$(YELLOW)Cleaning byte-compiled files...$(NC)" + @rm -f *.elc ../*.elc + @rm -f test-output.log test-file-output.log test-unit-output.log test-integration-output.log + @echo "$(GREEN)✓ Cleaned$(NC)" + +# Show help +help: + @echo "Chime Test Suite Makefile" + @echo "" + @echo "Usage:" + @echo " make test - Run all tests (339 tests)" + @echo " make test-file FILE=overdue - Run tests in one file (fuzzy match)" + @echo " make test-one TEST=pilot - Run one specific test (fuzzy match)" + @echo " make test-unit - Run unit tests only" + @echo " make test-integration - Run integration tests only" + @echo " make validate - Validate Emacs Lisp syntax (parens balance)" + @echo " make lint - Comprehensive linting with elisp-lint" + @echo " make count - Count tests per file" + @echo " make list - List all test names" + @echo " make clean - Remove byte-compiled files and logs" + @echo " make check-deps - Verify all dependencies are installed" + @echo " make help - Show this help message" + @echo "" + @echo "Examples:" + @echo " make test # Run everything" + @echo " make test-file FILE=overdue # Run test-chime-overdue-todos.el" + @echo " make test-one TEST=pilot # Run the pilot test" + @echo " make test-one TEST=test-overdue-has-passed # Run specific test" + @echo "" + @echo "Environment variables:" + @echo " EMACS - Emacs executable (default: emacs)" + @echo " ELPA_DIR - ELPA package directory (default: ~/.emacs.d/elpa)" diff --git a/tests/test-chime--deduplicate-events-by-title.el b/tests/test-chime--deduplicate-events-by-title.el new file mode 100644 index 0000000..494db57 --- /dev/null +++ b/tests/test-chime--deduplicate-events-by-title.el @@ -0,0 +1,200 @@ +;;; test-chime--deduplicate-events-by-title.el --- Tests for event deduplication by title -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--deduplicate-events-by-title. +;; Tests that recurring events (expanded into multiple instances by org-agenda-list) +;; are deduplicated to show only the soonest occurrence of each title. +;; +;; This fixes bug001: Recurring Events Show Duplicate Entries in Tooltip + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Enable debug mode and load chime +(setq chime-debug t) +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Test Helpers + +(defun test-make-event (title) + "Create a test event object with TITLE." + `((title . ,title))) + +(defun test-make-upcoming-item (title minutes) + "Create a test upcoming-events item with TITLE and MINUTES until event. +Returns format: (EVENT TIME-INFO MINUTES)" + (list (test-make-event title) + '("dummy-time-string" . nil) ; TIME-INFO (not used in deduplication) + minutes)) + +;;; Normal Cases + +(ert-deftest test-chime--deduplicate-events-by-title-normal-recurring-daily-keeps-soonest () + "Test that recurring daily event keeps only the soonest occurrence." + (let* ((events (list + (test-make-upcoming-item "Daily Standup" 60) ; 1 hour away + (test-make-upcoming-item "Daily Standup" 1500) ; tomorrow + (test-make-upcoming-item "Daily Standup" 2940))) ; day after + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (string= "Daily Standup" (cdr (assoc 'title (car (car result)))))) + (should (= 60 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-normal-multiple-different-events () + "Test that different event titles are all preserved." + (let* ((events (list + (test-make-upcoming-item "Meeting A" 30) + (test-make-upcoming-item "Meeting B" 60) + (test-make-upcoming-item "Meeting C" 90))) + (result (chime--deduplicate-events-by-title events))) + (should (= 3 (length result))) + ;; All three events should be present + (should (cl-find-if (lambda (item) (string= "Meeting A" (cdr (assoc 'title (car item))))) result)) + (should (cl-find-if (lambda (item) (string= "Meeting B" (cdr (assoc 'title (car item))))) result)) + (should (cl-find-if (lambda (item) (string= "Meeting C" (cdr (assoc 'title (car item))))) result)))) + +(ert-deftest test-chime--deduplicate-events-by-title-normal-mixed-recurring-and-unique () + "Test mix of recurring (duplicated) and unique events." + (let* ((events (list + (test-make-upcoming-item "Daily Wrap Up" 120) ; 2 hours + (test-make-upcoming-item "Team Sync" 180) ; 3 hours (unique) + (test-make-upcoming-item "Daily Wrap Up" 1560) ; tomorrow + (test-make-upcoming-item "Daily Wrap Up" 3000))) ; day after + (result (chime--deduplicate-events-by-title events))) + (should (= 2 (length result))) + ;; Daily Wrap Up should appear once (soonest at 120 minutes) + (let ((daily-wrap-up (cl-find-if (lambda (item) + (string= "Daily Wrap Up" (cdr (assoc 'title (car item))))) + result))) + (should daily-wrap-up) + (should (= 120 (caddr daily-wrap-up)))) + ;; Team Sync should appear once + (let ((team-sync (cl-find-if (lambda (item) + (string= "Team Sync" (cdr (assoc 'title (car item))))) + result))) + (should team-sync) + (should (= 180 (caddr team-sync)))))) + +;;; Boundary Cases + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-empty-list-returns-empty () + "Test that empty list returns empty list." + (let ((result (chime--deduplicate-events-by-title '()))) + (should (null result)))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-single-event-returns-same () + "Test that single event is returned unchanged." + (let* ((events (list (test-make-upcoming-item "Solo Event" 45))) + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (string= "Solo Event" (cdr (assoc 'title (car (car result)))))) + (should (= 45 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-all-same-title-keeps-soonest () + "Test that when all events have same title, only the soonest is kept." + (let* ((events (list + (test-make-upcoming-item "Recurring Task" 300) + (test-make-upcoming-item "Recurring Task" 100) ; soonest + (test-make-upcoming-item "Recurring Task" 500) + (test-make-upcoming-item "Recurring Task" 200))) + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 100 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-two-events-same-title-keeps-soonest () + "Test that with two events of same title, soonest is kept." + (let* ((events (list + (test-make-upcoming-item "Daily Check" 200) + (test-make-upcoming-item "Daily Check" 50))) ; soonest + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 50 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-same-title-same-time () + "Test events with same title and same time (edge case). +One instance should be kept." + (let* ((events (list + (test-make-upcoming-item "Duplicate Time" 100) + (test-make-upcoming-item "Duplicate Time" 100))) + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 100 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-zero-minutes () + "Test event happening right now (0 minutes away)." + (let* ((events (list + (test-make-upcoming-item "Happening Now" 0) + (test-make-upcoming-item "Happening Now" 1440))) ; tomorrow + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 0 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-large-minute-values () + "Test with very large minute values (1 year lookahead)." + (let* ((events (list + (test-make-upcoming-item "Annual Review" 60) + (test-make-upcoming-item "Annual Review" 525600))) ; 365 days + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 60 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-title-with-special-chars () + "Test titles with special characters." + (let* ((events (list + (test-make-upcoming-item "Review: Q1 Report (Draft)" 100) + (test-make-upcoming-item "Review: Q1 Report (Draft)" 200))) + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 100 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-empty-title () + "Test events with empty string titles." + (let* ((events (list + (test-make-upcoming-item "" 100) + (test-make-upcoming-item "" 200))) + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 100 (caddr (car result)))))) + +;;; Error Cases + +(ert-deftest test-chime--deduplicate-events-by-title-error-nil-input-returns-empty () + "Test that nil input returns empty list." + (let ((result (chime--deduplicate-events-by-title nil))) + (should (null result)))) + +(provide 'test-chime--deduplicate-events-by-title) +;;; test-chime--deduplicate-events-by-title.el ends here diff --git a/tests/test-chime--time=.el b/tests/test-chime--time=.el new file mode 100644 index 0000000..1015abb --- /dev/null +++ b/tests/test-chime--time=.el @@ -0,0 +1,108 @@ +;;; test-chime--time=.el --- Tests for chime--time= -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--time= function. +;; Tests timestamp comparison ignoring seconds component. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'alert) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;;; Setup and Teardown + +(defun test-chime--time=-setup () + "Setup test environment." + ;; No special setup needed + ) + +(defun test-chime--time=-teardown () + "Teardown test environment." + ;; No special teardown needed + ) + +;;; Normal Cases + +(ert-deftest test-chime--time=-normal-two-equal-times-returns-true () + "Test that two timestamps with same day/hour/minute return true." + (test-chime--time=-setup) + (unwind-protect + (let* ((time1 (encode-time 15 30 14 8 11 2025)) ; 2025-11-08 14:30:15 + (time2 (encode-time 45 30 14 8 11 2025))) ; 2025-11-08 14:30:45 (different seconds) + (should (chime--time= time1 time2))) + (test-chime--time=-teardown))) + +(ert-deftest test-chime--time=-normal-three-equal-times-returns-true () + "Test that three timestamps with same day/hour/minute return true." + (test-chime--time=-setup) + (unwind-protect + (let* ((time1 (encode-time 10 45 9 8 11 2025)) + (time2 (encode-time 20 45 9 8 11 2025)) + (time3 (encode-time 55 45 9 8 11 2025))) + (should (chime--time= time1 time2 time3))) + (test-chime--time=-teardown))) + +(ert-deftest test-chime--time=-normal-two-different-times-returns-nil () + "Test that timestamps with different hour/minute return nil." + (test-chime--time=-setup) + (unwind-protect + (let* ((time1 (encode-time 0 30 14 8 11 2025)) ; 14:30 + (time2 (encode-time 0 31 14 8 11 2025))) ; 14:31 + (should-not (chime--time= time1 time2))) + (test-chime--time=-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime--time=-boundary-single-time-returns-true () + "Test that single timestamp returns true." + (test-chime--time=-setup) + (unwind-protect + (let ((time (encode-time 0 0 12 8 11 2025))) + (should (chime--time= time))) + (test-chime--time=-teardown))) + +(ert-deftest test-chime--time=-boundary-empty-list-returns-nil () + "Test that empty argument list returns nil." + (test-chime--time=-setup) + (unwind-protect + ;; Empty list has no elements, unique length is 0, not 1 + (should-not (chime--time= )) + (test-chime--time=-teardown))) + +(ert-deftest test-chime--time=-boundary-different-days-same-time-returns-nil () + "Test that same time on different days returns nil." + (test-chime--time=-setup) + (unwind-protect + (let* ((time1 (encode-time 0 30 14 8 11 2025)) ; Nov 8 + (time2 (encode-time 0 30 14 9 11 2025))) ; Nov 9 + (should-not (chime--time= time1 time2))) + (test-chime--time=-teardown))) + +(provide 'test-chime--time=) +;;; test-chime--time=.el ends here diff --git a/tests/test-chime--today.el b/tests/test-chime--today.el new file mode 100644 index 0000000..c053364 --- /dev/null +++ b/tests/test-chime--today.el @@ -0,0 +1,93 @@ +;;; test-chime--today.el --- Tests for chime--today -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--today function. +;; Tests retrieving beginning of current day timestamp. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'alert) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;;; Setup and Teardown + +(defun test-chime--today-setup () + "Setup test environment." + ;; No special setup needed + ) + +(defun test-chime--today-teardown () + "Teardown test environment." + ;; No special teardown needed + ) + +;;; Normal Cases + +(ert-deftest test-chime--today-normal-returns-current-date () + "Test that chime--today returns midnight of current day." + (test-chime--today-setup) + (unwind-protect + (let* ((now (encode-time 30 45 14 8 11 2025)) ; 2025-11-08 14:45:30 + (expected (encode-time 0 0 0 8 11 2025))) ; 2025-11-08 00:00:00 + (cl-letf (((symbol-function 'current-time) (lambda () now))) + (should (equal (chime--today) expected)))) + (test-chime--today-teardown))) + +(ert-deftest test-chime--today-normal-truncates-time-component () + "Test that chime--today zeros out hour/minute/second." + (test-chime--today-setup) + (unwind-protect + (let* ((now (encode-time 59 59 23 8 11 2025)) ; 2025-11-08 23:59:59 + (result (cl-letf (((symbol-function 'current-time) (lambda () now))) + (chime--today))) + (decoded (decode-time result))) + ;; Check that hour, minute, second are all 0 + (should (= 0 (decoded-time-second decoded))) + (should (= 0 (decoded-time-minute decoded))) + (should (= 0 (decoded-time-hour decoded))) + ;; Check date is preserved + (should (= 8 (decoded-time-day decoded))) + (should (= 11 (decoded-time-month decoded))) + (should (= 2025 (decoded-time-year decoded)))) + (test-chime--today-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime--today-boundary-midnight-returns-correct-day () + "Test that chime--today at midnight returns same day." + (test-chime--today-setup) + (unwind-protect + (let* ((now (encode-time 0 0 0 8 11 2025)) ; Already at midnight + (expected (encode-time 0 0 0 8 11 2025))) + (cl-letf (((symbol-function 'current-time) (lambda () now))) + (should (equal (chime--today) expected)))) + (test-chime--today-teardown))) + +(provide 'test-chime--today) +;;; test-chime--today.el ends here diff --git a/tests/test-chime--truncate-title.el b/tests/test-chime--truncate-title.el new file mode 100644 index 0000000..e888b43 --- /dev/null +++ b/tests/test-chime--truncate-title.el @@ -0,0 +1,120 @@ +;;; test-chime--truncate-title.el --- Tests for chime--truncate-title -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--truncate-title function. +;; Tests title truncation with ellipsis based on chime-max-title-length. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'alert) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;;; Setup and Teardown + +(defun test-chime--truncate-title-setup () + "Setup test environment." + ;; No special setup needed + ) + +(defun test-chime--truncate-title-teardown () + "Teardown test environment." + ;; No special teardown needed + ) + +;;; Normal Cases + +(ert-deftest test-chime--truncate-title-normal-short-title-unchanged () + "Test that short title under max length is unchanged." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 20)) + (should (equal (chime--truncate-title "Short title") + "Short title"))) + (test-chime--truncate-title-teardown))) + +(ert-deftest test-chime--truncate-title-normal-long-title-truncated-with-ellipsis () + "Test that long title is truncated with ... appended." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 15)) + (should (equal (chime--truncate-title "This is a very long title that needs truncation") + "This is a ve..."))) + (test-chime--truncate-title-teardown))) + +(ert-deftest test-chime--truncate-title-normal-unicode-characters-truncated-correctly () + "Test that unicode characters are handled correctly in truncation." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 10)) + (should (equal (chime--truncate-title "Meeting 🎉 with team") + "Meeting..."))) + (test-chime--truncate-title-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime--truncate-title-boundary-exact-max-length-unchanged () + "Test that title exactly at max length is unchanged." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 12)) + (should (equal (chime--truncate-title "Twelve chars") + "Twelve chars"))) + (test-chime--truncate-title-teardown))) + +(ert-deftest test-chime--truncate-title-boundary-one-char-over-max-truncated () + "Test that title one character over max is truncated." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 10)) + (should (equal (chime--truncate-title "Eleven char") + "Eleven ..."))) + (test-chime--truncate-title-teardown))) + +(ert-deftest test-chime--truncate-title-boundary-empty-string-returns-empty () + "Test that empty string returns empty string." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 20)) + (should (equal (chime--truncate-title "") + ""))) + (test-chime--truncate-title-teardown))) + +;;; Error Cases + +(ert-deftest test-chime--truncate-title-error-nil-title-returns-empty () + "Test that nil title returns empty string." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 20)) + (should (equal (chime--truncate-title nil) + ""))) + (test-chime--truncate-title-teardown))) + +(provide 'test-chime--truncate-title) +;;; test-chime--truncate-title.el ends here diff --git a/tests/test-chime-12hour-format.el b/tests/test-chime-12hour-format.el new file mode 100644 index 0000000..2b2a3e7 --- /dev/null +++ b/tests/test-chime-12hour-format.el @@ -0,0 +1,227 @@ +;;; test-chime-12hour-format.el --- Tests for 12-hour am/pm timestamp parsing -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for 12-hour am/pm timestamp format support. +;; Verifies that chime correctly parses timestamps like: +;; - <2025-11-05 Wed 1:30pm> +;; - <2025-11-05 Wed 1:30 PM> +;; - <2025-11-05 Wed 12:00pm> +;; - <2025-11-05 Wed 12:00am> + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Enable debug mode and load chime +(setq chime-debug t) +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Tests for chime--convert-12hour-to-24hour + +(ert-deftest test-12hour-convert-1pm-to-13 () + "Test that 1pm converts to hour 13." + (should (= 13 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 1:30pm>" 1)))) + +(ert-deftest test-12hour-convert-1pm-uppercase-to-13 () + "Test that 1PM (uppercase) converts to hour 13." + (should (= 13 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 1:30PM>" 1)))) + +(ert-deftest test-12hour-convert-1pm-with-space-to-13 () + "Test that 1 PM (with space) converts to hour 13." + (should (= 13 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 1:30 PM>" 1)))) + +(ert-deftest test-12hour-convert-11pm-to-23 () + "Test that 11pm converts to hour 23." + (should (= 23 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 11:59pm>" 11)))) + +(ert-deftest test-12hour-convert-12pm-noon-stays-12 () + "Test that 12pm (noon) stays as hour 12." + (should (= 12 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 12:00pm>" 12)))) + +(ert-deftest test-12hour-convert-12am-midnight-to-0 () + "Test that 12am (midnight) converts to hour 0." + (should (= 0 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 12:00am>" 12)))) + +(ert-deftest test-12hour-convert-1am-stays-1 () + "Test that 1am stays as hour 1." + (should (= 1 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 1:00am>" 1)))) + +(ert-deftest test-12hour-convert-11am-stays-11 () + "Test that 11am stays as hour 11." + (should (= 11 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 11:59am>" 11)))) + +(ert-deftest test-12hour-convert-24hour-format-unchanged () + "Test that 24-hour format (no am/pm) is unchanged." + (should (= 13 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 13:30>" 13)))) + +(ert-deftest test-12hour-convert-24hour-0-unchanged () + "Test that 24-hour format hour 0 (midnight) is unchanged." + (should (= 0 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 00:00>" 0)))) + +;;; Tests for chime--timestamp-parse with 12-hour format + +(ert-deftest test-12hour-parse-1-30pm () + "Test parsing 1:30pm returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 1:30pm>"))) + (should result) + ;; Check that hour is 13 (1pm) + (let ((time-decoded (decode-time result))) + (should (= 13 (decoded-time-hour time-decoded))) + (should (= 30 (decoded-time-minute time-decoded))) + (should (= 5 (decoded-time-day time-decoded))) + (should (= 11 (decoded-time-month time-decoded))) + (should (= 2025 (decoded-time-year time-decoded)))))) + +(ert-deftest test-12hour-parse-2-00pm () + "Test parsing 2:00PM (uppercase) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 2:00PM>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 14 (decoded-time-hour time-decoded))) + (should (= 0 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-3-45-pm-with-space () + "Test parsing 3:45 PM (with space) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 3:45 PM>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 15 (decoded-time-hour time-decoded))) + (should (= 45 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-11-59pm () + "Test parsing 11:59pm (last minute before midnight) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 11:59pm>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 23 (decoded-time-hour time-decoded))) + (should (= 59 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-12-00pm-noon () + "Test parsing 12:00pm (noon) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 12:00pm>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 12 (decoded-time-hour time-decoded))) + (should (= 0 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-12-30pm () + "Test parsing 12:30pm (afternoon) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 12:30pm>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 12 (decoded-time-hour time-decoded))) + (should (= 30 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-12-00am-midnight () + "Test parsing 12:00am (midnight) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 12:00am>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 0 (decoded-time-hour time-decoded))) + (should (= 0 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-12-30am () + "Test parsing 12:30am (after midnight) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 12:30am>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 0 (decoded-time-hour time-decoded))) + (should (= 30 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-1-00am () + "Test parsing 1:00am returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 1:00am>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 1 (decoded-time-hour time-decoded))) + (should (= 0 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-11-59am () + "Test parsing 11:59am (last minute before noon) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 11:59am>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 11 (decoded-time-hour time-decoded))) + (should (= 59 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-24hour-still-works () + "Test that 24-hour format (13:30) still works correctly." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 13:30>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 13 (decoded-time-hour time-decoded))) + (should (= 30 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-24hour-midnight () + "Test that 24-hour format 00:00 (midnight) still works correctly." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 00:00>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 0 (decoded-time-hour time-decoded))) + (should (= 0 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-24hour-23-59 () + "Test that 24-hour format 23:59 still works correctly." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 23:59>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 23 (decoded-time-hour time-decoded))) + (should (= 59 (decoded-time-minute time-decoded)))))) + +;;; Mixed case and whitespace variations + +(ert-deftest test-12hour-parse-mixed-case-Pm () + "Test parsing with mixed case Pm." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 3:30Pm>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 15 (decoded-time-hour time-decoded)))))) + +(ert-deftest test-12hour-parse-mixed-case-pM () + "Test parsing with mixed case pM." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 3:30pM>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 15 (decoded-time-hour time-decoded)))))) + +(ert-deftest test-12hour-parse-multiple-spaces () + "Test parsing with multiple spaces before am/pm." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 3:30 pm>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 15 (decoded-time-hour time-decoded)))))) + +(provide 'test-chime-12hour-format) +;;; test-chime-12hour-format.el ends here diff --git a/tests/test-chime-all-day-events.el b/tests/test-chime-all-day-events.el new file mode 100644 index 0000000..2dbffd6 --- /dev/null +++ b/tests/test-chime-all-day-events.el @@ -0,0 +1,274 @@ +;;; test-chime-all-day-events.el --- Tests for all-day event handling -*- lexical-binding: t; -*- + +;; Tests for: +;; - All-day event detection +;; - Tooltip display configuration (chime-tooltip-show-all-day-events) +;; - Day-wide notifications +;; - Advance notice notifications + +;;; Code: + +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'org-agenda) +(load (expand-file-name "../chime.el") nil t) +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Helper Functions + +(defun test-allday--create-event (title &optional timestamp-str has-time) + "Create a test event with TITLE and TIMESTAMP-STR. +If HAS-TIME is t, timestamp includes time component." + (let* ((ts-str (or timestamp-str + (if has-time + "<2025-11-15 Sat 10:00-11:00>" + "<2025-11-15 Sat>"))) + (parsed-time (when has-time + (chime--timestamp-parse ts-str)))) + `((title . ,title) + (times . ((,ts-str . ,parsed-time))) + (intervals . ((0 15 30))) + (marker-file . "/tmp/test.org") + (marker-pos . 1)))) + +;;; Tests: All-day event detection + +(ert-deftest test-chime-has-timestamp-with-time () + "Test that timestamps with time component are detected. + +REFACTORED: Uses dynamic timestamps" + (let ((time1 (test-time-tomorrow-at 10 0)) + (time2 (test-time-tomorrow-at 9 30))) + (should (chime--has-timestamp (test-timestamp-string time1))) + (should (chime--has-timestamp (test-timestamp-string time1))) + (should (chime--has-timestamp (format-time-string "<%Y-%m-%d %a %H:%M-%H:%M>" time2))))) + +(ert-deftest test-chime-has-timestamp-without-time () + "Test that all-day timestamps (no time) are correctly identified. + +REFACTORED: Uses dynamic timestamps" + (let ((time1 (test-time-tomorrow-at 0 0)) + (time2 (test-time-days-from-now 10)) + (time3 (test-time-days-from-now 30))) + (should-not (chime--has-timestamp (test-timestamp-string time1 t))) + (should-not (chime--has-timestamp (test-timestamp-string time2 t))) + (should-not (chime--has-timestamp (test-timestamp-string time3 t))))) + +(ert-deftest test-chime-event-has-day-wide-timestamp () + "Test detection of events with all-day timestamps. + +REFACTORED: Uses dynamic timestamps" + (let* ((all-day-time (test-time-days-from-now 10)) + (timed-time (test-time-tomorrow-at 10 0)) + (all-day-event (test-allday--create-event "Birthday" (test-timestamp-string all-day-time t) nil)) + (timed-event (test-allday--create-event "Meeting" (test-timestamp-string timed-time) t))) + (should (chime-event-has-any-day-wide-timestamp all-day-event)) + (should-not (chime-event-has-any-day-wide-timestamp timed-event)))) + +;;; Tests: Advance notice window + +(ert-deftest test-chime-advance-notice-nil () + "Test that advance notice is disabled when chime-day-wide-advance-notice is nil. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: TOMORROW (all-day) + Setting: chime-day-wide-advance-notice = nil + +EXPECTED: Should NOT be in advance notice window (disabled) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (tomorrow-timestamp (test-timestamp-string tomorrow t)) + (chime-day-wide-advance-notice nil) + (event (test-allday--create-event "Birthday Tomorrow" tomorrow-timestamp nil))) + (with-test-time now + (should-not (chime-event-within-advance-notice-window event))))) + +(ert-deftest test-chime-advance-notice-tomorrow () + "Test advance notice for event tomorrow when set to 1 day. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: TOMORROW (all-day) + Setting: chime-day-wide-advance-notice = 1 + +EXPECTED: Should be in advance notice window + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (tomorrow-timestamp (test-timestamp-string tomorrow t)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Birthday Tomorrow" tomorrow-timestamp nil))) + (with-test-time now + (should (chime-event-within-advance-notice-window event))))) + +(ert-deftest test-chime-advance-notice-two-days () + "Test advance notice for event in 2 days when set to 2 days. + +TIME: TODAY, Event: 2 DAYS FROM NOW, advance=2 +EXPECTED: Should be in window +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (two-days (test-time-days-from-now 2)) + (timestamp (test-timestamp-string two-days t)) + (chime-day-wide-advance-notice 2) + (event (test-allday--create-event "Birthday in 2 days" timestamp nil))) + (with-test-time now + (should (chime-event-within-advance-notice-window event))))) + +(ert-deftest test-chime-advance-notice-too-far-future () + "Test that events beyond advance notice window are not included. + +TIME: TODAY, Event: 5 DAYS FROM NOW, advance=1 +EXPECTED: Should NOT be in window (too far) +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (five-days (test-time-days-from-now 5)) + (timestamp (test-timestamp-string five-days t)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Birthday in 5 days" timestamp nil))) + (with-test-time now + (should-not (chime-event-within-advance-notice-window event))))) + +(ert-deftest test-chime-advance-notice-today-not-included () + "Test that today's events are not in advance notice window. + +TIME: TODAY, Event: TODAY, advance=1 +EXPECTED: Should NOT be in window (today is handled separately) +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (today-timestamp (test-timestamp-string now t)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Birthday Today" today-timestamp nil))) + (with-test-time now + ;; Today's event should NOT be in advance notice window + ;; It should be handled by regular day-wide logic + (should-not (chime-event-within-advance-notice-window event))))) + +(ert-deftest test-chime-advance-notice-timed-events-ignored () + "Test that timed events are not included in advance notice. + +TIME: TODAY, Event: TOMORROW with time, advance=1 +EXPECTED: Should NOT be in window (only all-day events qualify) +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 10 0)) + (timestamp (test-timestamp-string tomorrow)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Meeting Tomorrow" timestamp t))) + (with-test-time now + ;; Timed events should not trigger advance notices + (should-not (chime-event-within-advance-notice-window event))))) + +;;; Tests: Day-wide notification text + +(ert-deftest test-chime-day-wide-notification-today () + "Test notification text for all-day event today. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (today-timestamp (test-timestamp-string now t)) + (chime-day-wide-advance-notice nil) + (event (test-allday--create-event "Blake's Birthday" today-timestamp nil))) + (with-test-time now + (let ((text (chime--day-wide-notification-text event))) + (should (string-match-p "Blake's Birthday is due or scheduled today" text)))))) + +(ert-deftest test-chime-day-wide-notification-tomorrow () + "Test notification text for all-day event tomorrow with advance notice. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (tomorrow-timestamp (test-timestamp-string tomorrow t)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Blake's Birthday" tomorrow-timestamp nil))) + (with-test-time now + (let ((text (chime--day-wide-notification-text event))) + (should (string-match-p "Blake's Birthday is tomorrow" text)))))) + +(ert-deftest test-chime-day-wide-notification-in-2-days () + "Test notification text for all-day event in 2 days with advance notice. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (two-days (test-time-days-from-now 2)) + (timestamp (test-timestamp-string two-days t)) + (chime-day-wide-advance-notice 2) + (event (test-allday--create-event "Blake's Birthday" timestamp nil))) + (with-test-time now + (let ((text (chime--day-wide-notification-text event))) + (should (string-match-p "Blake's Birthday is in 2 days" text)))))) + +(ert-deftest test-chime-day-wide-notification-in-N-days () + "Test notification text for all-day event in N days with advance notice. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (five-days (test-time-days-from-now 5)) + (timestamp (test-timestamp-string five-days t)) + (chime-day-wide-advance-notice 5) + (event (test-allday--create-event "Conference" timestamp nil))) + (with-test-time now + (let ((text (chime--day-wide-notification-text event))) + (should (string-match-p "Conference is in [0-9]+ days" text)))))) + +;;; Tests: Display as day-wide event + +(ert-deftest test-chime-display-as-day-wide-event-today () + "Test that all-day events today are displayed as day-wide. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (today-timestamp (test-timestamp-string now t)) + (chime-day-wide-advance-notice nil) + (event (test-allday--create-event "Birthday Today" today-timestamp nil))) + (with-test-time now + (should (chime-display-as-day-wide-event event))))) + +(ert-deftest test-chime-display-as-day-wide-event-tomorrow-with-advance () + "Test that all-day events tomorrow are displayed when advance notice is enabled. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (timestamp (test-timestamp-string tomorrow t)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Birthday Tomorrow" timestamp nil))) + (with-test-time now + (should (chime-display-as-day-wide-event event))))) + +(ert-deftest test-chime-display-as-day-wide-event-tomorrow-without-advance () + "Test that all-day events tomorrow are NOT displayed without advance notice. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (timestamp (test-timestamp-string tomorrow t)) + (chime-day-wide-advance-notice nil) + (event (test-allday--create-event "Birthday Tomorrow" timestamp nil))) + (with-test-time now + (should-not (chime-display-as-day-wide-event event))))) + +;;; Tests: Tooltip configuration + +;; Note: These tests verify the logic exists, but full integration testing +;; requires the modeline update function which is async. See integration tests. + +(ert-deftest test-chime-tooltip-config-exists () + "Test that chime-tooltip-show-all-day-events customization exists." + (should (boundp 'chime-tooltip-show-all-day-events)) + (should (booleanp chime-tooltip-show-all-day-events))) + +(ert-deftest test-chime-day-wide-alert-times-default () + "Test that chime-day-wide-alert-times has correct default." + (should (boundp 'chime-day-wide-alert-times)) + (should (equal chime-day-wide-alert-times '("08:00")))) + +(ert-deftest test-chime-day-wide-advance-notice-default () + "Test that chime-day-wide-advance-notice has correct default." + (should (boundp 'chime-day-wide-advance-notice)) + (should (null chime-day-wide-advance-notice))) + +(provide 'test-chime-all-day-events) +;;; test-chime-all-day-events.el ends here diff --git a/tests/test-chime-apply-blacklist.el b/tests/test-chime-apply-blacklist.el new file mode 100644 index 0000000..6a82030 --- /dev/null +++ b/tests/test-chime-apply-blacklist.el @@ -0,0 +1,247 @@ +;;; test-chime-apply-blacklist.el --- Tests for chime--apply-blacklist -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--apply-blacklist function. +;; Tests use real org-mode buffers with real org syntax. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-apply-blacklist-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset blacklist settings + (setq chime-keyword-blacklist nil) + (setq chime-tags-blacklist nil) + (setq chime-predicate-blacklist nil)) + +(defun test-chime-apply-blacklist-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime-keyword-blacklist nil) + (setq chime-tags-blacklist nil) + (setq chime-predicate-blacklist nil)) + +;;; Normal Cases + +(ert-deftest test-chime-apply-blacklist-nil-blacklist-returns-all-markers () + "Test that nil blacklist returns all markers unchanged." + (test-chime-apply-blacklist-setup) + (unwind-protect + (let* ((chime-keyword-blacklist nil) + (chime-tags-blacklist nil) + (markers (list (make-marker) (make-marker) (make-marker))) + (result (chime--apply-blacklist markers))) + ;; Should return all markers when blacklist is nil + (should (equal (length result) 3))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-keyword-blacklist-filters-correctly () + "Test that keyword blacklist filters out markers correctly." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* TODO Task 3\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-blacklist '("DONE"))) + (let ((result (chime--apply-blacklist (list marker1 marker2 marker3)))) + ;; Should filter out DONE marker + (should (= (length result) 2)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should (member marker3 result))))))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-tags-blacklist-filters-correctly () + "Test that tags blacklist filters out markers correctly." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Task 1 :work:urgent:\n") + (insert "* Task 2 :personal:\n") + (insert "* Task 3 :work:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-tags-blacklist '("personal"))) + (let ((result (chime--apply-blacklist (list marker1 marker2 marker3)))) + ;; Should filter out marker with "personal" tag + (should (= (length result) 2)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should (member marker3 result))))))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-keyword-and-tags-blacklist-uses-or-logic () + "Test that both keyword and tags blacklists use OR logic." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* NEXT Task 3 :archive:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-blacklist '("DONE")) + (chime-tags-blacklist '("archive"))) + (let ((result (chime--apply-blacklist (list marker1 marker2 marker3)))) + ;; Should filter out marker2 (DONE) and marker3 (archive tag) + (should (= (length result) 1)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should-not (member marker3 result))))))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-multiple-keywords-filters-all () + "Test that multiple keywords in blacklist filters all matching." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* DONE Task 3\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-blacklist '("DONE"))) + (let ((result (chime--apply-blacklist (list marker1 marker2 marker3)))) + ;; Should only keep TODO marker, filter out both DONE markers + (should (= (length result) 1)) + (should (member marker1 result))))))) + (test-chime-apply-blacklist-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-apply-blacklist-empty-markers-list-returns-empty () + "Test that empty markers list returns empty." + (test-chime-apply-blacklist-setup) + (unwind-protect + (let ((chime-keyword-blacklist '("DONE")) + (result (chime--apply-blacklist '()))) + (should (equal result '()))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-single-item-blacklist-works () + "Test that single-item blacklist works correctly." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-keyword-blacklist '("DONE"))) + (let ((result (chime--apply-blacklist (list marker1 marker2)))) + (should (= (length result) 1)) + (should (member marker1 result)))))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-all-markers-blacklisted-returns-empty () + "Test that blacklisting all markers returns empty list." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* DONE Task 1\n") + (let ((marker1 (point-marker))) + (insert "* DONE Task 2\n") + (let ((marker2 (point-marker)) + (chime-keyword-blacklist '("DONE"))) + (let ((result (chime--apply-blacklist (list marker1 marker2)))) + (should (equal result '())))))) + (test-chime-apply-blacklist-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-apply-blacklist-handles-nil-keyword-gracefully () + "Test that nil keyword in marker is handled gracefully." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Entry without TODO keyword\n") + (let ((marker1 (point-marker)) + (chime-keyword-blacklist '("DONE"))) + (let ((result (chime--apply-blacklist (list marker1)))) + ;; Should keep marker with nil keyword (not in blacklist) + (should (= (length result) 1))))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-handles-nil-tags-gracefully () + "Test that nil tags in marker is handled gracefully." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Entry without tags\n") + (let ((marker1 (point-marker)) + (chime-tags-blacklist '("archive"))) + (let ((result (chime--apply-blacklist (list marker1)))) + ;; Should keep marker with nil tags (not in blacklist) + (should (= (length result) 1))))) + (test-chime-apply-blacklist-teardown))) + +(provide 'test-chime-apply-blacklist) +;;; test-chime-apply-blacklist.el ends here diff --git a/tests/test-chime-apply-whitelist.el b/tests/test-chime-apply-whitelist.el new file mode 100644 index 0000000..80e1a68 --- /dev/null +++ b/tests/test-chime-apply-whitelist.el @@ -0,0 +1,229 @@ +;;; test-chime-apply-whitelist.el --- Tests for chime--apply-whitelist -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--apply-whitelist function. +;; Tests use real org-mode buffers with real org syntax. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-apply-whitelist-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset whitelist settings + (setq chime-keyword-whitelist nil) + (setq chime-tags-whitelist nil) + (setq chime-predicate-whitelist nil)) + +(defun test-chime-apply-whitelist-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime-keyword-whitelist nil) + (setq chime-tags-whitelist nil) + (setq chime-predicate-whitelist nil)) + +;;; Normal Cases + +(ert-deftest test-chime-apply-whitelist-nil-whitelist-returns-all-markers () + "Test that nil whitelist returns all markers unchanged." + (test-chime-apply-whitelist-setup) + (unwind-protect + (let* ((chime-keyword-whitelist nil) + (chime-tags-whitelist nil) + (markers (list (make-marker) (make-marker) (make-marker))) + (result (chime--apply-whitelist markers))) + ;; Should return all markers when whitelist is nil + (should (equal (length result) 3))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-keyword-whitelist-filters-correctly () + "Test that keyword whitelist filters markers correctly." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* TODO Task 3\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-whitelist '("TODO"))) + (let ((result (chime--apply-whitelist (list marker1 marker2 marker3)))) + ;; Should only keep TODO markers + (should (= (length result) 2)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should (member marker3 result))))))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-tags-whitelist-filters-correctly () + "Test that tags whitelist filters markers correctly." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Task 1 :urgent:\n") + (insert "* Task 2 :normal:\n") + (insert "* Task 3 :urgent:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-tags-whitelist '("urgent"))) + (let ((result (chime--apply-whitelist (list marker1 marker2 marker3)))) + ;; Should only keep markers with "urgent" tag + (should (= (length result) 2)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should (member marker3 result))))))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-keyword-and-tags-whitelist-uses-or-logic () + "Test that both keyword and tags whitelists use OR logic." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* NEXT Task 3 :urgent:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-whitelist '("TODO")) + (chime-tags-whitelist '("urgent"))) + (let ((result (chime--apply-whitelist (list marker1 marker2 marker3)))) + ;; Should keep marker1 (TODO) and marker3 (urgent tag) + (should (= (length result) 2)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should (member marker3 result))))))) + (test-chime-apply-whitelist-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-apply-whitelist-empty-markers-list-returns-empty () + "Test that empty markers list returns empty." + (test-chime-apply-whitelist-setup) + (unwind-protect + (let ((chime-keyword-whitelist '("TODO")) + (result (chime--apply-whitelist '()))) + (should (equal result '()))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-single-item-whitelist-works () + "Test that single-item whitelist works correctly." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-keyword-whitelist '("TODO"))) + (let ((result (chime--apply-whitelist (list marker1 marker2)))) + (should (= (length result) 1)) + (should (member marker1 result)))))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-no-matching-markers-returns-empty () + "Test that whitelist with no matching markers returns empty list." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* DONE Task 1\n") + (insert "* DONE Task 2\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-keyword-whitelist '("TODO"))) + (let ((result (chime--apply-whitelist (list marker1 marker2)))) + (should (equal result '())))))) + (test-chime-apply-whitelist-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-apply-whitelist-handles-nil-keyword-gracefully () + "Test that nil keyword in marker is handled gracefully." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Entry without TODO keyword\n") + (goto-char (point-min)) + (let ((marker1 (point-marker)) + (chime-keyword-whitelist '("TODO"))) + (let ((result (chime--apply-whitelist (list marker1)))) + ;; Should filter out marker with nil keyword (not in whitelist) + (should (= (length result) 0))))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-handles-nil-tags-gracefully () + "Test that nil tags in marker is handled gracefully." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Entry without tags\n") + (goto-char (point-min)) + (let ((marker1 (point-marker)) + (chime-tags-whitelist '("urgent"))) + (let ((result (chime--apply-whitelist (list marker1)))) + ;; Should filter out marker with nil tags (not in whitelist) + (should (= (length result) 0))))) + (test-chime-apply-whitelist-teardown))) + +(provide 'test-chime-apply-whitelist) +;;; test-chime-apply-whitelist.el ends here diff --git a/tests/test-chime-calendar-url.el b/tests/test-chime-calendar-url.el new file mode 100644 index 0000000..34fd1ef --- /dev/null +++ b/tests/test-chime-calendar-url.el @@ -0,0 +1,64 @@ +;;; test-chime-calendar-url.el --- Tests for calendar URL feature -*- lexical-binding: t; -*- + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'alert) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;;; Tests for chime--open-calendar-url + +(ert-deftest test-chime-open-calendar-url-opens-when-set () + "Test that chime--open-calendar-url calls browse-url when URL is set." + (let ((chime-calendar-url "https://calendar.google.com") + (url-opened nil)) + (cl-letf (((symbol-function 'browse-url) + (lambda (url) (setq url-opened url)))) + (chime--open-calendar-url) + (should (equal url-opened "https://calendar.google.com"))))) + +(ert-deftest test-chime-open-calendar-url-does-nothing-when-nil () + "Test that chime--open-calendar-url does nothing when URL is nil." + (let ((chime-calendar-url nil) + (browse-url-called nil)) + (cl-letf (((symbol-function 'browse-url) + (lambda (_url) (setq browse-url-called t)))) + (chime--open-calendar-url) + (should-not browse-url-called)))) + +;;; Tests for chime--jump-to-first-event + +(ert-deftest test-chime-jump-to-first-event-jumps-to-event () + "Test that chime--jump-to-first-event jumps to first event in list." + (let* ((event1 '((title . "Event 1") + (marker-file . "/tmp/test.org") + (marker-pos . 100))) + (event2 '((title . "Event 2") + (marker-file . "/tmp/test.org") + (marker-pos . 200))) + (chime--upcoming-events `((,event1 ("time1" . time1) 10) + (,event2 ("time2" . time2) 20))) + (jumped-to-event nil)) + (cl-letf (((symbol-function 'chime--jump-to-event) + (lambda (event) (setq jumped-to-event event)))) + (chime--jump-to-first-event) + (should (equal jumped-to-event event1))))) + +(ert-deftest test-chime-jump-to-first-event-does-nothing-when-empty () + "Test that chime--jump-to-first-event does nothing when no events." + (let ((chime--upcoming-events nil) + (jump-called nil)) + (cl-letf (((symbol-function 'chime--jump-to-event) + (lambda (_event) (setq jump-called t)))) + (chime--jump-to-first-event) + (should-not jump-called)))) + +(provide 'test-chime-calendar-url) +;;; test-chime-calendar-url.el ends here diff --git a/tests/test-chime-check-event.el b/tests/test-chime-check-event.el new file mode 100644 index 0000000..a34e377 --- /dev/null +++ b/tests/test-chime-check-event.el @@ -0,0 +1,215 @@ +;;; test-chime-check-event.el --- Tests for chime--check-event -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--check-event function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) +(require 'testutil-events (expand-file-name "testutil-events.el")) + +;;; Normal Cases + +(ert-deftest test-chime-check-event-single-notification-returns-message () + "Test that single matching notification returns formatted message. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 (10 minutes from now) + (event-time (test-time-today-at 14 10)) + (event (test-make-simple-event "Team Meeting" event-time 10 'medium))) + (with-test-time now + (let ((result (chime--check-event event))) + ;; Should return list with one formatted message + (should (listp result)) + (should (= 1 (length result))) + (should (consp (car result))) + (should (stringp (caar result))) ; Message part + (should (symbolp (cdar result))) ; Severity part + ;; Message should contain title and time information + (should (string-match-p "Team Meeting" (caar result))) + (should (string-match-p "02:10 PM" (caar result))) + (should (string-match-p "in 10 minutes" (caar result)))))))) + +(ert-deftest test-chime-check-event-multiple-notifications-returns-multiple-messages () + "Test that multiple matching notifications return multiple formatted messages. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + ;; Two events: 14:10 and 14:05 + (event-time-1 (test-time-today-at 14 10)) + (event-time-2 (test-time-today-at 14 5)) + (timestamp-str-1 (test-timestamp-string event-time-1)) + (timestamp-str-2 (test-timestamp-string event-time-2)) + (event (test-make-event-data + "Important Call" + (list (cons timestamp-str-1 event-time-1) + (cons timestamp-str-2 event-time-2)) + '((10 . medium) (5 . medium))))) ; Both match + (with-test-time now + (let ((result (chime--check-event event))) + ;; Should return two formatted messages + (should (listp result)) + (should (= 2 (length result))) + (should (cl-every #'consp result)) ; All items are cons cells + ;; Both should mention the title + (should (string-match-p "Important Call" (caar result))) + (should (string-match-p "Important Call" (car (cadr result))))))))) + +(ert-deftest test-chime-check-event-zero-interval-returns-right-now-message () + "Test that zero interval produces 'right now' message. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + ;; Event at exactly now + (event-time (test-time-today-at 14 0)) + (event (test-make-simple-event "Daily Standup" event-time 0 'high))) + (with-test-time now + (let ((result (chime--check-event event))) + (should (listp result)) + (should (= 1 (length result))) + (should (string-match-p "Daily Standup" (caar result))) + (should (string-match-p "right now" (caar result)))))))) + +;;; Boundary Cases + +(ert-deftest test-chime-check-event-no-matching-notifications-returns-empty-list () + "Test that event with no matching times returns empty list. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:20 (doesn't match 10 minute interval) + (event-time (test-time-today-at 14 20)) + (event (test-make-simple-event "Future Event" event-time 10 'medium))) + (with-test-time now + (let ((result (chime--check-event event))) + (should (listp result)) + (should (= 0 (length result)))))))) + +(ert-deftest test-chime-check-event-day-wide-event-returns-empty-list () + "Test that day-wide event (no time) returns empty list. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 0)) + (timestamp-str (test-timestamp-string event-time t)) ; all-day format + (event (test-make-event-data "All Day Event" + (list (cons timestamp-str event-time)) + '((10 . medium))))) + (with-test-time now + (let ((result (chime--check-event event))) + (should (listp result)) + (should (= 0 (length result)))))))) + +;;; Error Cases + +(ert-deftest test-chime-check-event-empty-times-returns-empty-list () + "Test that event with no times returns empty list. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + (event (test-make-event-data "No Times Event" '() '((10 . medium))))) + (with-test-time now + (let ((result (chime--check-event event))) + (should (listp result)) + (should (= 0 (length result)))))))) + +(ert-deftest test-chime-check-event-empty-intervals-returns-empty-list () + "Test that event with no intervals returns empty list. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (event (test-make-simple-event "No Intervals Event" event-time nil nil))) + (setcdr (assoc 'intervals event) '()) ; Override with empty intervals + (with-test-time now + (let ((result (chime--check-event event))) + (should (listp result)) + (should (= 0 (length result)))))))) + +(ert-deftest test-chime-check-event-error-nil-event-handles-gracefully () + "Test that nil event parameter doesn't crash. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let ((now (test-time-today-at 14 0))) + (with-test-time now + ;; Should not error with nil event + (should-not (condition-case nil + (progn (chime--check-event nil) nil) + (error t))))))) + +(ert-deftest test-chime-check-event-error-invalid-event-structure-handles-gracefully () + "Test that invalid event structure doesn't crash. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let ((now (test-time-today-at 14 0)) + ;; Event missing required fields + (invalid-event '((invalid . "structure")))) + (with-test-time now + ;; Should not crash even with invalid event + (should-not (condition-case nil + (progn (chime--check-event invalid-event) nil) + (error t))))))) + +(ert-deftest test-chime-check-event-error-event-with-nil-times-handles-gracefully () + "Test that event with nil times field doesn't crash. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let ((now (test-time-today-at 14 0)) + (event '((times . nil) + (title . "Event with nil times") + (intervals . (10))))) + (with-test-time now + ;; Should not crash + (should-not (condition-case nil + (progn (chime--check-event event) nil) + (error t))))))) + +(provide 'test-chime-check-event) +;;; test-chime-check-event.el ends here diff --git a/tests/test-chime-check-interval.el b/tests/test-chime-check-interval.el new file mode 100644 index 0000000..0a51e2e --- /dev/null +++ b/tests/test-chime-check-interval.el @@ -0,0 +1,148 @@ +;;; test-chime-check-interval.el --- Tests for chime-check-interval -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime-check-interval customization variable. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-check-interval-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset to default + (setq chime-check-interval 60)) + +(defun test-chime-check-interval-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + ;; Reset to default + (setq chime-check-interval 60)) + +;;; Normal Cases + +(ert-deftest test-chime-check-interval-normal-default-is-60 () + "Test that default check interval is 60 seconds." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (should (equal chime-check-interval 60))) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-normal-can-set-30-seconds () + "Test that check interval can be set to 30 seconds." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (setq chime-check-interval 30) + (should (equal chime-check-interval 30))) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-normal-can-set-300-seconds () + "Test that check interval can be set to 300 seconds (5 minutes)." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (setq chime-check-interval 300) + (should (equal chime-check-interval 300))) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-normal-can-set-10-seconds () + "Test that check interval can be set to 10 seconds (minimum recommended)." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (setq chime-check-interval 10) + (should (equal chime-check-interval 10))) + (test-chime-check-interval-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-check-interval-boundary-one-second-triggers-warning () + "Test that 1 second interval triggers warning but is allowed." + (test-chime-check-interval-setup) + (unwind-protect + (progn + ;; Setting to 1 should trigger a warning but succeed + ;; We can't easily test the warning, but we can verify it's set + (setq chime-check-interval 1) + (should (equal chime-check-interval 1))) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-boundary-large-value-accepted () + "Test that large interval values are accepted (e.g., 1 hour)." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (setq chime-check-interval 3600) ; 1 hour + (should (equal chime-check-interval 3600))) + (test-chime-check-interval-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-check-interval-error-zero-rejected () + "Test that zero interval is rejected." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (should-error (customize-set-variable 'chime-check-interval 0) + :type 'user-error)) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-error-negative-rejected () + "Test that negative interval is rejected." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (should-error (customize-set-variable 'chime-check-interval -60) + :type 'user-error)) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-error-non-integer-rejected () + "Test that non-integer values are rejected." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (should-error (customize-set-variable 'chime-check-interval "60") + :type 'user-error)) + (test-chime-check-interval-teardown))) + +(provide 'test-chime-check-interval) +;;; test-chime-check-interval.el ends here diff --git a/tests/test-chime-debug-functions.el b/tests/test-chime-debug-functions.el new file mode 100644 index 0000000..28484b4 --- /dev/null +++ b/tests/test-chime-debug-functions.el @@ -0,0 +1,224 @@ +;;; test-chime-debug-functions.el --- Tests for chime debug functions -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for debug functions: chime--debug-dump-events, chime--debug-dump-tooltip, +;; and chime--debug-config. These tests verify that debug functions work correctly +;; and handle edge cases gracefully. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Enable debug mode and load chime +(setq chime-debug t) +(load (expand-file-name "../chime.el") nil t) +(require 'chime-debug (expand-file-name "../chime-debug.el")) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-debug-functions-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Clear upcoming events + (setq chime--upcoming-events nil)) + +(defun test-chime-debug-functions-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime--upcoming-events nil)) + +;;; Tests for chime--debug-dump-events + +(ert-deftest test-chime-debug-dump-events-normal-with-events () + "Test that chime--debug-dump-events dumps events to *Messages* buffer." + (test-chime-debug-functions-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (minutes 10)) + (with-test-time now + ;; Set up chime--upcoming-events as if chime--update-modeline populated it + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Meeting"))) + (event-item (list event (cons timestamp-str event-time) minutes))) + (setq chime--upcoming-events (list event-item)) + ;; Clear messages buffer to check output + (with-current-buffer (get-buffer-create "*Messages*") + (let ((inhibit-read-only t)) + (erase-buffer))) + ;; Call debug function + (chime--debug-dump-events) + ;; Verify output in *Messages* buffer + (with-current-buffer "*Messages*" + (let ((content (buffer-string))) + (should (string-match-p "=== Chime Debug: Upcoming Events" content)) + (should (string-match-p "Test Meeting" content)) + (should (string-match-p "=== End Chime Debug ===" content))))))) + (test-chime-debug-functions-teardown))) + +(ert-deftest test-chime-debug-dump-events-boundary-no-events () + "Test that chime--debug-dump-events handles no events gracefully." + (test-chime-debug-functions-setup) + (unwind-protect + (progn + ;; Ensure no events + (setq chime--upcoming-events nil) + ;; Should not error + (should-not (condition-case nil + (progn (chime--debug-dump-events) nil) + (error t)))) + (test-chime-debug-functions-teardown))) + +;;; Tests for chime--debug-dump-tooltip + +(ert-deftest test-chime-debug-dump-tooltip-normal-with-events () + "Test that chime--debug-dump-tooltip dumps tooltip to *Messages* buffer." + (test-chime-debug-functions-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (minutes 10)) + (with-test-time now + ;; Set up chime--upcoming-events + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Meeting"))) + (event-item (list event (cons timestamp-str event-time) minutes))) + (setq chime--upcoming-events (list event-item)) + ;; Clear messages buffer + (with-current-buffer (get-buffer-create "*Messages*") + (let ((inhibit-read-only t)) + (erase-buffer))) + ;; Call debug function + (chime--debug-dump-tooltip) + ;; Verify output in *Messages* buffer + (with-current-buffer "*Messages*" + (let ((content (buffer-string))) + (should (string-match-p "=== Chime Debug: Tooltip Content ===" content)) + (should (string-match-p "Test Meeting" content)) + (should (string-match-p "=== End Chime Debug ===" content))))))) + (test-chime-debug-functions-teardown))) + +(ert-deftest test-chime-debug-dump-tooltip-boundary-no-events () + "Test that chime--debug-dump-tooltip handles no events gracefully." + (test-chime-debug-functions-setup) + (unwind-protect + (progn + ;; Ensure no events + (setq chime--upcoming-events nil) + ;; Should not error + (should-not (condition-case nil + (progn (chime--debug-dump-tooltip) nil) + (error t)))) + (test-chime-debug-functions-teardown))) + +;;; Tests for chime--debug-config + +(ert-deftest test-chime-debug-config-normal-dumps-config () + "Test that chime--debug-config dumps configuration to *Messages* buffer." + (test-chime-debug-functions-setup) + (unwind-protect + (let ((chime-enable-modeline t) + (chime-modeline-lookahead-minutes 60) + (chime-alert-intervals '((10 . medium))) + (org-agenda-files '("/tmp/test.org"))) + ;; Clear messages buffer + (with-current-buffer (get-buffer-create "*Messages*") + (let ((inhibit-read-only t)) + (erase-buffer))) + ;; Call debug function + (chime--debug-config) + ;; Verify output in *Messages* buffer + (with-current-buffer "*Messages*" + (let ((content (buffer-string))) + (should (string-match-p "=== Chime Debug: Configuration ===" content)) + (should (string-match-p "Mode enabled:" content)) + (should (string-match-p "chime-enable-modeline:" content)) + (should (string-match-p "chime-modeline-lookahead-minutes:" content)) + (should (string-match-p "chime-alert-intervals:" content)) + (should (string-match-p "Org agenda files" content)) + (should (string-match-p "=== End Chime Debug ===" content))))) + (test-chime-debug-functions-teardown))) + +(ert-deftest test-chime-debug-config-boundary-no-agenda-files () + "Test that chime--debug-config handles empty org-agenda-files." + (test-chime-debug-functions-setup) + (unwind-protect + (let ((org-agenda-files '())) + ;; Clear messages buffer + (with-current-buffer (get-buffer-create "*Messages*") + (let ((inhibit-read-only t)) + (erase-buffer))) + ;; Should not error + (should-not (condition-case nil + (progn (chime--debug-config) nil) + (error t))) + ;; Verify output mentions 0 files + (with-current-buffer "*Messages*" + (let ((content (buffer-string))) + (should (string-match-p "Org agenda files (0)" content))))) + (test-chime-debug-functions-teardown))) + +;;; Integration tests + +(ert-deftest test-chime-debug-all-functions-work-together () + "Test that all three debug functions can be called sequentially." + (test-chime-debug-functions-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (minutes 10) + (chime-enable-modeline t) + (org-agenda-files '("/tmp/test.org"))) + (with-test-time now + ;; Set up events + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Integration Test Meeting"))) + (event-item (list event (cons timestamp-str event-time) minutes))) + (setq chime--upcoming-events (list event-item)) + ;; Call all three debug functions - should not error + (should-not (condition-case nil + (progn + (chime--debug-dump-events) + (chime--debug-dump-tooltip) + (chime--debug-config) + nil) + (error t)))))) + (test-chime-debug-functions-teardown))) + +(provide 'test-chime-debug-functions) +;;; test-chime-debug-functions.el ends here diff --git a/tests/test-chime-extract-time.el b/tests/test-chime-extract-time.el new file mode 100644 index 0000000..3c2a50f --- /dev/null +++ b/tests/test-chime-extract-time.el @@ -0,0 +1,331 @@ +;;; test-chime-extract-time.el --- Tests for chime--extract-time -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for chime--extract-time function with source-aware extraction: +;; - org-gcal events: extract ONLY from :org-gcal: drawer +;; - Regular events: prefer SCHEDULED/DEADLINE, fall back to plain timestamps +;; - Prevents duplicate entries when events are rescheduled + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-extract-time-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-extract-time-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Tests for org-gcal events + +(ert-deftest test-chime-extract-time-gcal-event-from-drawer () + "Test that org-gcal events extract timestamps ONLY from :org-gcal: drawer. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp-str (format-time-string "<%Y-%m-%d %a %H:%M-15:00>" time)) + (test-content (format "* Meeting +:PROPERTIES: +:entry-id: abc123@google.com +:END: +:org-gcal: +%s +:END: +" timestamp-str)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract the timestamp from :org-gcal: drawer + (should (= 1 (length times))) + (should (string-match-p "14:00" (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-gcal-event-ignores-body-timestamps () + "Test that org-gcal events ignore plain timestamps in body text. + +When an event is rescheduled, old timestamps might remain in the body. +The :org-gcal: drawer has the correct time, so we should ignore body text. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((new-time (test-time-tomorrow-at 14 0)) + (old-time (test-time-today-at 14 0)) + (new-timestamp (test-timestamp-string new-time)) + (old-timestamp (test-timestamp-string old-time)) + (test-content (format "* Meeting +:PROPERTIES: +:entry-id: abc123@google.com +:END: +:org-gcal: +%s +:END: +Old time was %s +" new-timestamp old-timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract ONLY from drawer (tomorrow), ignore body (today) + (should (= 1 (length times))) + (should (string-match-p "14:00" (car (car times)))) + ;; Verify it's the new timestamp, not the old one + (should (string-match-p (format-time-string "%Y-%m-%d" new-time) (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-gcal-event-ignores-scheduled () + "Test that org-gcal events ignore SCHEDULED/DEADLINE properties. + +For org-gcal events, the :org-gcal: drawer is the source of truth. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((drawer-time (test-time-tomorrow-at 14 0)) + (scheduled-time (test-time-days-from-now 2)) + (drawer-timestamp (test-timestamp-string drawer-time)) + (scheduled-timestamp (test-timestamp-string scheduled-time)) + (test-content (format "* Meeting +SCHEDULED: %s +:PROPERTIES: +:entry-id: abc123@google.com +:END: +:org-gcal: +%s +:END: +" scheduled-timestamp drawer-timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract ONLY from drawer (tomorrow), ignore SCHEDULED (day after) + (should (= 1 (length times))) + (should (string-match-p "14:00" (car (car times)))) + (should (string-match-p (format-time-string "%Y-%m-%d" drawer-time) (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-gcal-event-multiple-in-drawer () + "Test that org-gcal events extract all timestamps from :org-gcal: drawer. + +Some recurring events might have multiple timestamps in the drawer. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time1 (test-time-tomorrow-at 14 0)) + (time2 (test-time-days-from-now 2 14 0)) + (timestamp1 (test-timestamp-string time1)) + (timestamp2 (test-timestamp-string time2)) + (test-content (format "* Meeting +:PROPERTIES: +:entry-id: abc123@google.com +:END: +:org-gcal: +%s +%s +:END: +" timestamp1 timestamp2)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract both timestamps from drawer + (should (= 2 (length times))) + (should (string-match-p "14:00" (car (car times)))) + (should (string-match-p "14:00" (car (cadr times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +;;; Tests for regular org events + +(ert-deftest test-chime-extract-time-regular-event-scheduled () + "Test that regular events extract SCHEDULED timestamp. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-content (format "* Task +SCHEDULED: %s +" timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract SCHEDULED timestamp + (should (= 1 (length times))) + (should (string-match-p "14:00" (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-regular-event-deadline () + "Test that regular events extract DEADLINE timestamp. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 17 0)) + (timestamp (test-timestamp-string time)) + (test-content (format "* Task +DEADLINE: %s +" timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract DEADLINE timestamp + (should (= 1 (length times))) + (should (string-match-p "17:00" (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-regular-event-plain-timestamps () + "Test that regular events extract plain timestamps when no SCHEDULED/DEADLINE. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-content (format "* Meeting notes +Discussed: %s +" timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract plain timestamp + (should (= 1 (length times))) + (should (string-match-p "14:00" (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-regular-event-scheduled-and-plain () + "Test that regular events extract both SCHEDULED and plain timestamps. + +SCHEDULED/DEADLINE appear first, then plain timestamps. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((scheduled-time (test-time-tomorrow-at 14 0)) + (plain-time (test-time-days-from-now 2 15 0)) + (scheduled-timestamp (test-timestamp-string scheduled-time)) + (plain-timestamp (format-time-string "<%Y-%m-%d %a %H:%M>" plain-time)) + (test-content (format "* Task +SCHEDULED: %s +Note: also happens %s +" scheduled-timestamp plain-timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract both: SCHEDULED first, then plain + (should (= 2 (length times))) + ;; First should be SCHEDULED + (should (string-match-p "14:00" (car (car times)))) + ;; Second should be plain at 15:00 + (should (string-match-p "15:00" (car (cadr times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-regular-event-multiple-plain () + "Test that regular events extract all plain timestamps. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time1 (test-time-tomorrow-at 14 0)) + (time2 (test-time-days-from-now 2 15 0)) + (timestamp1 (test-timestamp-string time1)) + (timestamp2 (test-timestamp-string time2)) + (test-content (format "* Meeting notes +First discussion: %s +Second discussion: %s +" timestamp1 timestamp2)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract both plain timestamps + (should (= 2 (length times))) + (should (string-match-p "14:00" (car (car times)))) + (should (string-match-p "15:00" (car (cadr times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(provide 'test-chime-extract-time) +;;; test-chime-extract-time.el ends here diff --git a/tests/test-chime-format-event-for-tooltip.el b/tests/test-chime-format-event-for-tooltip.el new file mode 100644 index 0000000..a9a53a5 --- /dev/null +++ b/tests/test-chime-format-event-for-tooltip.el @@ -0,0 +1,260 @@ +;;; test-chime-format-event-for-tooltip.el --- Tests for chime--format-event-for-tooltip -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--format-event-for-tooltip function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-format-event-for-tooltip-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-format-event-for-tooltip-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-format-event-for-tooltip-normal-minutes () + "Test formatting event with minutes until event. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 10)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 10 + "Team Meeting"))) + (should (stringp result)) + (should (string-match-p "Team Meeting" result)) + (should (string-match-p "02:10 PM" result)) + (should (string-match-p "10 minutes" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-normal-hours () + "Test formatting event with hours until event. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 15 30)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 90 + "Afternoon Meeting"))) + (should (stringp result)) + (should (string-match-p "Afternoon Meeting" result)) + (should (string-match-p "03:30 PM" result)) + (should (string-match-p "1 hour" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-normal-multiple-hours () + "Test formatting event with multiple hours until event. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 17 0)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 300 + "End of Day Review"))) + (should (stringp result)) + (should (string-match-p "End of Day Review" result)) + (should (string-match-p "05:00 PM" result)) + (should (string-match-p "5 hours" result))) + (test-chime-format-event-for-tooltip-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-format-event-for-tooltip-boundary-exactly-one-day () + "Test formatting event exactly 1 day away (1440 minutes). + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-days-from-now 1 9 0)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 1440 + "Tomorrow Event"))) + (should (stringp result)) + (should (string-match-p "Tomorrow Event" result)) + (should (string-match-p "09:00 AM" result)) + (should (string-match-p "in 1 day" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-boundary-multiple-days () + "Test formatting event multiple days away. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-days-from-now 3 10 0)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 4320 ; 3 days + "Future Meeting"))) + (should (stringp result)) + (should (string-match-p "Future Meeting" result)) + (should (string-match-p "10:00 AM" result)) + (should (string-match-p "in 3 days" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-boundary-just-under-one-day () + "Test formatting event just under 1 day away (1439 minutes). + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 8 59)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 1439 + "Almost Tomorrow"))) + (should (stringp result)) + (should (string-match-p "Almost Tomorrow" result)) + (should (string-match-p "08:59 AM" result)) + ;; Should show hours/minutes, not days + (should (string-match-p "23 hours" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-boundary-zero-minutes () + "Test formatting event happening right now (0 minutes). + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-today-at 14 0)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 0 + "Current Event"))) + (should (stringp result)) + (should (string-match-p "Current Event" result)) + (should (string-match-p "02:00 PM" result)) + (should (string-match-p "right now" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-boundary-one-minute () + "Test formatting event 1 minute away. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-today-at 14 1)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 1 + "Imminent Event"))) + (should (stringp result)) + (should (string-match-p "Imminent Event" result)) + (should (string-match-p "02:01 PM" result)) + (should (string-match-p "1 minute" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-boundary-long-title () + "Test formatting event with very long title. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-today-at 14 10)) + (timestamp (test-timestamp-string time)) + (long-title (make-string 200 ?x)) + (result (chime--format-event-for-tooltip + timestamp + 10 + long-title))) + (should (stringp result)) + (should (string-match-p long-title result))) + (test-chime-format-event-for-tooltip-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-format-event-for-tooltip-error-nil-title () + "Test formatting with nil title doesn't crash. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-today-at 14 10)) + (timestamp (test-timestamp-string time))) + ;; Should not crash with nil title + (should-not (condition-case nil + (progn + (chime--format-event-for-tooltip + timestamp + 10 + nil) + nil) + (error t)))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-error-empty-title () + "Test formatting with empty title. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-today-at 14 10)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 10 + ""))) + (should (stringp result)) + (should (string-match-p "02:10 PM" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(provide 'test-chime-format-event-for-tooltip) +;;; test-chime-format-event-for-tooltip.el ends here diff --git a/tests/test-chime-format-refresh.el b/tests/test-chime-format-refresh.el new file mode 100644 index 0000000..4252c77 --- /dev/null +++ b/tests/test-chime-format-refresh.el @@ -0,0 +1,150 @@ +;;; test-chime-format-refresh.el --- Test format changes are picked up on refresh -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests to verify that changing configuration variables and calling +;; refresh functions picks up the new values. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-format-refresh-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset to defaults + (setq chime-modeline-string nil) + (setq chime-enable-modeline t) + (setq chime-modeline-lookahead-minutes 30) + (setq chime-modeline-format " ⏰ %s") + (setq chime-notification-text-format "%t at %T (%u)")) + +(defun test-chime-format-refresh-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime-modeline-string nil)) + +;;; Tests + +(ert-deftest test-chime-format-refresh-update-modeline-picks-up-format-change () + "Test that chime--update-modeline picks up changed chime-notification-text-format." + (test-chime-format-refresh-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Meeting"))) + (events (list event))) + ;; First update with format "%t at %T (%u)" + (chime--update-modeline events) + (should (string-match-p "Meeting at" chime-modeline-string)) + (should (string-match-p "(in" chime-modeline-string)) + + ;; Change format to "%t %u" (no time, no parentheses) + (setq chime-notification-text-format "%t %u") + + ;; Update again - should pick up new format + (chime--update-modeline events) + (should (string-match-p "Meeting in" chime-modeline-string)) + (should-not (string-match-p "Meeting at" chime-modeline-string)) + (should-not (string-match-p "(in" chime-modeline-string)))))) + (test-chime-format-refresh-teardown))) + +(ert-deftest test-chime-format-refresh-title-only-format () + "Test changing format to title-only." + (test-chime-format-refresh-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Important Meeting"))) + (events (list event))) + ;; Start with default format + (chime--update-modeline events) + (should (string-match-p "Important Meeting" chime-modeline-string)) + (should (string-match-p "at\\|in" chime-modeline-string)) + + ;; Change to title only + (setq chime-notification-text-format "%t") + + ;; Update - should show title only + (chime--update-modeline events) + ;; The modeline string will have the format applied, then wrapped with chime-modeline-format + ;; chime-modeline-format is " ⏰ %s", so the title will be in there + (should (string-match-p "Important Meeting" chime-modeline-string)) + ;; After format-spec, the raw text is just "Important Meeting" + ;; Wrapped with " ⏰ %s" it becomes " ⏰ Important Meeting" + ;; So we should NOT see time or countdown + (should-not (string-match-p "at [0-9]" chime-modeline-string)) + (should-not (string-match-p "in [0-9]" chime-modeline-string)))))) + (test-chime-format-refresh-teardown))) + +(ert-deftest test-chime-format-refresh-custom-separator () + "Test changing format with custom separator." + (test-chime-format-refresh-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Review PR"))) + (events (list event))) + ;; Start with default "at" + (chime--update-modeline events) + (should (string-match-p "at" chime-modeline-string)) + + ;; Change to custom separator " - " + (setq chime-notification-text-format "%t - %T") + + ;; Update - should show custom separator + (chime--update-modeline events) + (should (string-match-p "Review PR - " chime-modeline-string)) + (should-not (string-match-p " at " chime-modeline-string)))))) + (test-chime-format-refresh-teardown))) + +(provide 'test-chime-format-refresh) +;;; test-chime-format-refresh.el ends here diff --git a/tests/test-chime-gather-info.el b/tests/test-chime-gather-info.el new file mode 100644 index 0000000..1da1991 --- /dev/null +++ b/tests/test-chime-gather-info.el @@ -0,0 +1,475 @@ +;;; test-chime-gather-info.el --- Tests for chime--gather-info -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Integration tests for chime--gather-info function. +;; Tests ensure that event information is collected correctly +;; and that titles are properly sanitized to prevent Lisp read +;; syntax errors during async serialization. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) +(require 'testutil-events (expand-file-name "testutil-events.el")) + +;;; Setup and Teardown + +(defun test-chime-gather-info-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset to default alert intervals + (setq chime-alert-intervals '((10 . medium)))) + +(defun test-chime-gather-info-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-gather-info-extracts-all-components () + "Test that gather-info extracts times, title, intervals, and marker. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Team Meeting\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should have all required keys + (should (assoc 'times info)) + (should (assoc 'title info)) + (should (assoc 'intervals info)) + (should (assoc 'marker-file info)) + (should (assoc 'marker-pos info)) + ;; Title should be extracted + (should (string-equal "Team Meeting" (cdr (assoc 'title info)))) + ;; Intervals should include default alert interval as cons cell + (should (member '(10 . medium) (cdr (assoc 'intervals info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-with-balanced-parens-in-title () + "Test that balanced parentheses in title are preserved. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting (Team Sync)\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + (should (string-equal "Meeting (Team Sync)" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +;;; Sanitization Cases + +(ert-deftest test-chime-gather-info-sanitizes-unmatched-opening-paren () + "Test that unmatched opening parenthesis in title is closed. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting (Team Sync\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should add closing paren + (should (string-equal "Meeting (Team Sync)" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-sanitizes-unmatched-opening-bracket () + "Test that unmatched opening bracket in title is closed. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 15 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Review [PR #123\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should add closing bracket + (should (string-equal "Review [PR #123]" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-sanitizes-unmatched-opening-brace () + "Test that unmatched opening brace in title is closed. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 16 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Code Review {urgent\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should add closing brace + (should (string-equal "Code Review {urgent}" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-sanitizes-multiple-unmatched-delimiters () + "Test that multiple unmatched delimiters are all closed. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 17 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting [Team (Sync {Status\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should close all unmatched delimiters + (should (string-equal "Meeting [Team (Sync {Status})]" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-sanitizes-unmatched-closing-paren () + "Test that unmatched closing parenthesis is removed. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting Title)\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should remove extra closing paren + (should (string-equal "Meeting Title" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +;;; Real-World Bug Cases + +(ert-deftest test-chime-gather-info-bug-case-extended-leadership () + "Test the actual bug case from vineti.meetings.org. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 13 1)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO 1:01pm CTO/COO XLT (Extended Leadership\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should close the unmatched paren + (should (string-equal "1:01pm CTO/COO XLT (Extended Leadership)" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-bug-case-spice-cake () + "Test the actual bug case from journal/2023-11-22.org. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 18 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Spice Cake (\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should close the unmatched paren + (should (string-equal "Spice Cake ()" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +;;; Serialization Safety + +(ert-deftest test-chime-gather-info-output-serializable-with-unmatched-parens () + "Test that gather-info output with unmatched parens can be serialized. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting (Team\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker)) + ;; Extract just the title for serialization test + (title (cdr (assoc 'title info))) + ;; Simulate what happens in async serialization + (serialized (format "'((title . \"%s\"))" title))) + ;; Should not signal 'invalid-read-syntax error + (should (listp (read serialized))) + ;; Title should be sanitized + (should (string-equal "Meeting (Team)" title)))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-multiple-events-all-serializable () + "Test that multiple events with various delimiter issues are all serializable. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (problematic-titles '("Meeting (Team" + "Review [PR" + "Code {Status" + "Event ((" + "Task ))")) + (test-content (mapconcat + (lambda (title) + (format "* TODO %s\nSCHEDULED: %s\n" title timestamp)) + problematic-titles + "\n")) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file)) + (all-info '())) + (with-current-buffer test-buffer + (org-mode) + ;; Gather info for all events + (goto-char (point-min)) + (while (re-search-forward "^\\*\\s-+TODO" nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + (push info all-info) + (end-of-line))) + ;; Try to serialize all titles + (dolist (info all-info) + (let* ((title (cdr (assoc 'title info))) + (serialized (format "'((title . \"%s\"))" title))) + ;; Should not signal error + (should (listp (read serialized)))))) + (kill-buffer test-buffer)) + )) + +;;; Edge Cases + +(ert-deftest test-chime-gather-info-handles-empty-title () + "Test that gather-info handles entries with no title. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should return empty string for nil title + (should (string-equal "" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-handles-very-long-title-with-delimiters () + "Test that gather-info handles very long titles with unmatched delimiters. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (long-title "This is a very long meeting title that contains many words and might wrap in the notification display (Extended Info") + (test-file (chime-create-temp-test-file-with-content + (format "* TODO %s\nSCHEDULED: %s\n" long-title timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker)) + (title (cdr (assoc 'title info)))) + ;; Should close the unmatched paren + (should (string-suffix-p ")" title)) + ;; Should be able to serialize + (should (listp (read (format "'((title . \"%s\"))" title)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-serializable-without-marker-object () + "Test that gather-info returns serializable data without marker object. + +This tests the fix for the bug where marker objects from buffers with names +like 'todo.org<jr-estate>' could not be serialized because angle brackets in +the buffer name created invalid Lisp syntax: #<marker ... in todo.org<dir>> + +The fix returns marker-file and marker-pos instead of the marker object, +which can be properly serialized regardless of buffer name. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Test Task\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + + ;; Should have marker-file and marker-pos, NOT marker object + (should (assoc 'marker-file info)) + (should (assoc 'marker-pos info)) + (should-not (assoc 'marker info)) + + ;; The file path and position should be correct + (should (string-equal test-file (cdr (assoc 'marker-file info)))) + (should (numberp (cdr (assoc 'marker-pos info)))) + (should (> (cdr (assoc 'marker-pos info)) 0)) + + ;; The entire structure should be serializable via format %S and read + ;; This simulates what async.el does with the data + (let* ((serialized (format "%S" info)) + (deserialized (read serialized))) + ;; Should deserialize without error + (should (listp deserialized)) + ;; Should have the same data structure + (should (string-equal (cdr (assoc 'title deserialized)) + (cdr (assoc 'title info)))) + (should (string-equal (cdr (assoc 'marker-file deserialized)) + (cdr (assoc 'marker-file info)))) + (should (equal (cdr (assoc 'marker-pos deserialized)) + (cdr (assoc 'marker-pos info))))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-special-chars-in-title () + "Test that titles with Lisp special characters serialize correctly. + +Tests characters that could theoretically cause Lisp read syntax errors: +- Double quotes: string delimiters +- Backslashes: escape characters +- Semicolons: comment start +- Backticks/commas: quasiquote syntax +- Hash symbols: reader macros + +These should all be properly escaped by format %S. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (special-titles '(("Quote in \"middle\"" . "Quote in \"middle\"") + ("Backslash\\path\\here" . "Backslash\\path\\here") + ("Semicolon; not a comment" . "Semicolon; not a comment") + ("Backtick `and` comma, here" . "Backtick `and` comma, here") + ("Hash #tag and @mention" . "Hash #tag and @mention") + ("Mixed: \"foo\\bar;baz`qux#\"" . "Mixed: \"foo\\bar;baz`qux#\"")))) + (dolist (title-pair special-titles) + (let* ((title (car title-pair)) + (expected (cdr title-pair)) + (test-content (format "* TODO %s\nSCHEDULED: %s\n" title timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Title should be preserved exactly + (should (string-equal expected (cdr (assoc 'title info)))) + ;; Full structure should serialize/deserialize correctly + (let* ((serialized (format "%S" info)) + (deserialized (read serialized))) + (should (listp deserialized)) + (should (string-equal expected (cdr (assoc 'title deserialized))))))) + (kill-buffer test-buffer)))) + )) + +(provide 'test-chime-gather-info) +;;; test-chime-gather-info.el ends here diff --git a/tests/test-chime-group-events-by-day.el b/tests/test-chime-group-events-by-day.el new file mode 100644 index 0000000..c9e564a --- /dev/null +++ b/tests/test-chime-group-events-by-day.el @@ -0,0 +1,268 @@ +;;; test-chime-group-events-by-day.el --- Tests for chime--group-events-by-day -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--group-events-by-day function. +;; Tests cover normal cases, boundary cases, and error cases. +;; +;; Note: chime--group-events-by-day does not handle malformed events gracefully. +;; This is acceptable since events are generated internally by chime and should +;; always have the correct structure. If a malformed event is passed, it will error. +;; Removed test: test-chime-group-events-by-day-error-malformed-event + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-group-events-by-day-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-group-events-by-day-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Test Helpers + +(defun test-chime-make-event-item (minutes-until title) + "Create a mock event item for testing. +MINUTES-UNTIL is minutes until event, TITLE is event title." + (let* ((now (current-time)) + (event-time (time-add now (seconds-to-time (* minutes-until 60)))) + (timestamp-str (test-timestamp-string event-time)) + (event `((title . ,title) + (times . ()))) + (time-info (cons timestamp-str event-time))) + (list event time-info minutes-until))) + +;;; Normal Cases + +(ert-deftest test-chime-group-events-by-day-normal-single-day () + "Test grouping events all on same day. + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event1 (test-chime-make-event-item 10 "Event 1")) + (event2 (test-chime-make-event-item 30 "Event 2")) + (event3 (test-chime-make-event-item 60 "Event 3")) + (upcoming (list event1 event2 event3)) + (result (chime--group-events-by-day upcoming))) + ;; Should have 1 group (today) + (should (= 1 (length result))) + ;; Group should have 3 events + (should (= 3 (length (cdr (car result))))) + ;; Date string should say "Today" + (should (string-match-p "Today" (car (car result))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-normal-multiple-days () + "Test grouping events across multiple days. + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event1 (test-chime-make-event-item 10 "Today Event")) + (event2 (test-chime-make-event-item 1500 "Tomorrow Event")) ; > 1440 + (event3 (test-chime-make-event-item 3000 "Future Event")) ; > 2880 + (upcoming (list event1 event2 event3)) + (result (chime--group-events-by-day upcoming))) + ;; Should have 3 groups (today, tomorrow, future) + (should (= 3 (length result))) + ;; First group should say "Today" + (should (string-match-p "Today" (car (nth 0 result)))) + ;; Second group should say "Tomorrow" + (should (string-match-p "Tomorrow" (car (nth 1 result))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-normal-maintains-order () + "Test that events maintain order within groups. + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event1 (test-chime-make-event-item 10 "First")) + (event2 (test-chime-make-event-item 20 "Second")) + (event3 (test-chime-make-event-item 30 "Third")) + (upcoming (list event1 event2 event3)) + (result (chime--group-events-by-day upcoming)) + (today-events (cdr (car result)))) + ;; Should maintain order + (should (= 3 (length today-events))) + (should (string= "First" (cdr (assoc 'title (car (nth 0 today-events)))))) + (should (string= "Second" (cdr (assoc 'title (car (nth 1 today-events)))))) + (should (string= "Third" (cdr (assoc 'title (car (nth 2 today-events))))))) + (test-chime-group-events-by-day-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-group-events-by-day-boundary-empty-list () + "Test grouping empty events list. + +REFACTORED: No timestamps used" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let ((result (chime--group-events-by-day '()))) + ;; Should return empty list + (should (null result))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-boundary-single-event () + "Test grouping single event. + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event (test-chime-make-event-item 10 "Only Event")) + (upcoming (list event)) + (result (chime--group-events-by-day upcoming))) + ;; Should have 1 group + (should (= 1 (length result))) + ;; Group should have 1 event + (should (= 1 (length (cdr (car result)))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-boundary-exactly-1440-minutes () + "Test event at exactly 1440 minutes (1 day boundary). + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event (test-chime-make-event-item 1440 "Boundary Event")) + (upcoming (list event)) + (result (chime--group-events-by-day upcoming))) + ;; Should be grouped as "Tomorrow" + (should (= 1 (length result))) + (should (string-match-p "Tomorrow" (car (car result))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-boundary-just-under-1440 () + "Test event at 1439 minutes (23h 59m away). + +If current time is 10:00 AM, an event 1439 minutes away is at 9:59 AM +the next calendar day, so it should be grouped as 'Tomorrow', not 'Today'. + +REFACTORED: Uses dynamic timestamps and corrects expected behavior" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event (test-chime-make-event-item 1439 "Almost Tomorrow")) + (upcoming (list event)) + (result (chime--group-events-by-day upcoming))) + ;; Should be grouped as "Tomorrow" (next calendar day) + (should (= 1 (length result))) + (should (string-match-p "Tomorrow" (car (car result))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-boundary-exactly-2880-minutes () + "Test event at exactly 2880 minutes (2 day boundary). + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event (test-chime-make-event-item 2880 "Two Days Away")) + (upcoming (list event)) + (result (chime--group-events-by-day upcoming))) + ;; Should be grouped as a future day (not "Tomorrow") + (should (= 1 (length result))) + (should-not (string-match-p "Tomorrow" (car (car result))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-boundary-zero-minutes () + "Test event at 0 minutes (happening now). + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event (test-chime-make-event-item 0 "Right Now")) + (upcoming (list event)) + (result (chime--group-events-by-day upcoming))) + ;; Should be grouped as "Today" + (should (= 1 (length result))) + (should (string-match-p "Today" (car (car result))))) + (test-chime-group-events-by-day-teardown))) + +;;; Bug Reproduction Tests + +(ert-deftest test-chime-group-events-by-day-bug-tomorrow-morning-grouped-as-today () + "Test that tomorrow morning event is NOT grouped as 'Today'. +This reproduces the bug where an event at 10:00 AM tomorrow, +when it's 11:23 AM today (22h 37m = 1357 minutes away), +is incorrectly grouped as 'Today' instead of 'Tomorrow'. + +The bug: The function groups by 24-hour period (<1440 minutes) +instead of by calendar day." + (test-chime-group-events-by-day-setup) + (unwind-protect + (with-test-time (encode-time 0 23 11 2 11 2025) ; Nov 02, 2025 11:23 AM + (let* ((now (current-time)) + (tomorrow-morning (encode-time 0 0 10 3 11 2025)) ; Nov 03, 2025 10:00 AM + (minutes-until (/ (- (float-time tomorrow-morning) (float-time now)) 60)) + ;; Create event manually since test-chime-make-event-item uses relative time + (event `((title . "Transit to Meeting") + (times . ()))) + (time-info (cons (test-timestamp-string tomorrow-morning) tomorrow-morning)) + (event-item (list event time-info minutes-until)) + (upcoming (list event-item)) + (result (chime--group-events-by-day upcoming))) + ;; Verify it's less than 1440 minutes (this is why the bug happens) + (should (< minutes-until 1440)) + ;; Should have 1 group + (should (= 1 (length result))) + ;; BUG: Currently groups as "Today" but should be "Tomorrow" + ;; because the event is on Nov 03, not Nov 02 + (should (string-match-p "Tomorrow" (car (car result)))))) + (test-chime-group-events-by-day-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-group-events-by-day-error-nil-input () + "Test that nil input doesn't crash. + +REFACTORED: No timestamps used" + (test-chime-group-events-by-day-setup) + (unwind-protect + (progn + ;; Should not crash with nil + (should-not (condition-case nil + (progn (chime--group-events-by-day nil) nil) + (error t)))) + (test-chime-group-events-by-day-teardown))) + +(provide 'test-chime-group-events-by-day) +;;; test-chime-group-events-by-day.el ends here diff --git a/tests/test-chime-has-timestamp.el b/tests/test-chime-has-timestamp.el new file mode 100644 index 0000000..22eb8ad --- /dev/null +++ b/tests/test-chime-has-timestamp.el @@ -0,0 +1,277 @@ +;;; test-chime-has-timestamp.el --- Tests for chime--has-timestamp -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--has-timestamp function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-has-timestamp-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-has-timestamp-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-has-timestamp-standard-timestamp-with-time-returns-non-nil () + "Test that standard timestamp with time returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (timestamp (test-timestamp-string time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-timestamp-without-brackets-returns-non-nil () + "Test that timestamp without brackets but with time returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (timestamp (format-time-string "%Y-%m-%d %a %H:%M" time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-timestamp-with-time-range-returns-non-nil () + "Test that timestamp with time range returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M-15:30>" time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-scheduled-with-time-returns-non-nil () + "Test that SCHEDULED timestamp with time returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 9 0)) + (timestamp (concat "SCHEDULED: " (test-timestamp-string time))) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-deadline-with-time-returns-non-nil () + "Test that DEADLINE timestamp with time returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 17 0)) + (timestamp (concat "DEADLINE: " (test-timestamp-string time))) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-repeater-with-time-returns-non-nil () + "Test that timestamp with repeater and time returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M +1w>" time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-midnight-timestamp-returns-non-nil () + "Test that midnight timestamp (00:00) returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (timestamp (test-timestamp-string time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-has-timestamp-day-wide-timestamp-returns-nil () + "Test that day-wide timestamp without time returns nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (timestamp (test-timestamp-string time t)) + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-date-only-returns-nil () + "Test that date-only timestamp without day name returns nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (timestamp (format-time-string "<%Y-%m-%d>" time)) + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-single-digit-hour-returns-non-nil () + "Test that timestamp with single-digit hour returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 9 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %-H:%M>" time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-embedded-in-text-returns-non-nil () + "Test that timestamp embedded in text returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (concat "Meeting scheduled for " (test-timestamp-string time) " in conference room")) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-multiple-timestamps-returns-non-nil () + "Test that string with multiple timestamps returns non-nil for first match. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time1 (test-time-tomorrow-at 14 0)) + (time2 (test-time-days-from-now 2)) + (timestamp (concat (test-timestamp-string time1) " and " + (format-time-string "<%Y-%m-%d %a %H:%M>" time2))) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-has-timestamp-empty-string-returns-nil () + "Test that empty string returns nil." + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((timestamp "") + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-nil-input-returns-nil () + "Test that nil input returns nil." + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((timestamp nil) + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-no-timestamp-returns-nil () + "Test that string without timestamp returns nil." + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((timestamp "Just a regular string with no timestamp") + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-invalid-format-returns-nil () + "Test that invalid timestamp format returns nil. + +REFACTORED: Uses dynamic timestamps (keeps invalid format for testing)" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + ;; Intentionally wrong format (MM-DD-YYYY instead of YYYY-MM-DD) for testing + (timestamp (format-time-string "<%m-%d-%Y %a %H:%M>" time)) + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-partial-timestamp-returns-nil () + "Test that partial timestamp returns nil. + +REFACTORED: Uses dynamic timestamps (keeps partial format for testing)" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + ;; Intentionally incomplete timestamp for testing + (timestamp (format-time-string "<%Y-%m-%d" time)) + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +;;; org-gcal Integration Tests + +(ert-deftest test-chime-has-timestamp-org-gcal-time-range-returns-non-nil () + "Test that org-gcal time range format is detected. +org-gcal uses format like <2025-10-24 Fri 17:30-18:00> which should be detected. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 17 30)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M-18:00>" time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(provide 'test-chime-has-timestamp) +;;; test-chime-has-timestamp.el ends here diff --git a/tests/test-chime-modeline-no-events-text.el b/tests/test-chime-modeline-no-events-text.el new file mode 100644 index 0000000..d9d78fd --- /dev/null +++ b/tests/test-chime-modeline-no-events-text.el @@ -0,0 +1,290 @@ +;;; test-chime-modeline-no-events-text.el --- Tests for chime-modeline-no-events-text customization -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime-modeline-no-events-text defcustom. +;; Tests the modeline display when no events are within lookahead window. +;; +;; Tests three scenarios: +;; 1. Setting is nil → show nothing in modeline +;; 2. Setting is custom text → show that text +;; 3. Event within lookahead → show event (ignores setting) + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defvar test-chime-modeline-no-events-text--orig-lookahead nil) +(defvar test-chime-modeline-no-events-text--orig-tooltip-lookahead nil) +(defvar test-chime-modeline-no-events-text--orig-no-events-text nil) + +(defun test-chime-modeline-no-events-text-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Save original values + (setq test-chime-modeline-no-events-text--orig-lookahead chime-modeline-lookahead-minutes) + (setq test-chime-modeline-no-events-text--orig-tooltip-lookahead chime-tooltip-lookahead-hours) + (setq test-chime-modeline-no-events-text--orig-no-events-text chime-modeline-no-events-text) + ;; Set short lookahead for testing + (setq chime-modeline-lookahead-minutes 60) ; 1 hour + (setq chime-tooltip-lookahead-hours 24)) ; 24 hours + +(defun test-chime-modeline-no-events-text-teardown () + "Teardown function run after each test." + ;; Restore original values + (setq chime-modeline-lookahead-minutes test-chime-modeline-no-events-text--orig-lookahead) + (setq chime-tooltip-lookahead-hours test-chime-modeline-no-events-text--orig-tooltip-lookahead) + (setq chime-modeline-no-events-text test-chime-modeline-no-events-text--orig-no-events-text) + (chime-delete-test-base-dir)) + +;;; Helper Functions + +(defun test-chime-modeline-no-events-text--create-event (title time-offset-hours) + "Create org content for event with TITLE at TIME-OFFSET-HOURS from now." + (let* ((event-time (test-time-at 0 time-offset-hours 0)) + (timestamp (test-timestamp-string event-time))) + (format "* TODO %s\nSCHEDULED: %s\n" title timestamp))) + +(defun test-chime-modeline-no-events-text--gather-events (content) + "Process CONTENT like chime-check does and return events list." + (let* ((test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + (events nil)) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + (push info events)) + (forward-line 1))) + (kill-buffer test-buffer) + (nreverse events))) + +(defun test-chime-modeline-no-events-text--update-and-get-modeline (events-content) + "Create org file with EVENTS-CONTENT, update modeline, return chime-modeline-string." + (let ((events (test-chime-modeline-no-events-text--gather-events events-content))) + (chime--update-modeline events) + ;; Return the modeline string + chime-modeline-string)) + +;;; Normal Cases + +(ert-deftest test-chime-modeline-no-events-text-normal-nil-setting-no-events-in-lookahead-returns-nil () + "Test that nil setting shows nothing when events exist beyond lookahead. + +When chime-modeline-no-events-text is nil and events exist beyond +the lookahead window but not within it, the modeline should be nil +(show nothing)." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3))) + ;; Set to nil (default) + (setq chime-modeline-no-events-text nil) + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (null result))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-normal-custom-text-no-events-in-lookahead-returns-text () + "Test that custom text displays when events exist beyond lookahead. + +When chime-modeline-no-events-text is \" 🔕\" and events exist beyond +the lookahead window, the modeline should show \" 🔕\"." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3))) + ;; Set custom text + (setq chime-modeline-no-events-text " 🔕") + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (string-match-p "🔕" result))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-normal-event-within-lookahead-shows-event () + "Test that event within lookahead is shown, ignoring no-events-text. + +When an event is within the lookahead window, the modeline should show +the event regardless of chime-modeline-no-events-text setting." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 30 minutes from now (within 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Upcoming Event" 0.5))) + ;; Set custom text (should be ignored) + (setq chime-modeline-no-events-text " 🔕") + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (string-match-p "Upcoming Event" result)) + (should-not (string-match-p "🔕" result))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-normal-custom-text-has-tooltip () + "Test that custom text has tooltip when displayed. + +When chime-modeline-no-events-text is displayed, it should have a +help-echo property with the tooltip showing upcoming events." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3))) + ;; Set custom text + (setq chime-modeline-no-events-text " 🔕") + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + ;; Check for help-echo property (tooltip) + (should (get-text-property 0 'help-echo result))))) + (test-chime-modeline-no-events-text-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-modeline-no-events-text-boundary-empty-string-no-events-returns-empty () + "Test that empty string setting shows empty string. + +When chime-modeline-no-events-text is \"\" (empty string) and events +exist beyond lookahead, the modeline should show empty string." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3))) + ;; Set to empty string + (setq chime-modeline-no-events-text "") + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (equal "" result))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-boundary-no-events-at-all-shows-icon () + "Test that icon appears even when there are no events at all. + +When there are zero events (not just beyond lookahead, but none at all), +the modeline should still show the icon with a helpful tooltip explaining +that there are no events and suggesting to increase the lookahead window." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; No events - empty content + (content "")) + ;; Set custom text + (setq chime-modeline-no-events-text " 🔕") + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + ;; Should show icon (not nil) + (should result) + (should (stringp result)) + (should (equal " 🔕" (substring-no-properties result))) + ;; Should have tooltip explaining no events + (let ((tooltip (get-text-property 0 'help-echo result))) + (should tooltip) + (should (string-match-p "No calendar events" tooltip)) + (should (string-match-p "chime-tooltip-lookahead-hours" tooltip)))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-boundary-very-long-text-displays-correctly () + "Test that very long custom text displays correctly. + +When chime-modeline-no-events-text is a very long string (50+ chars), +the modeline should show the full string." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3)) + (long-text " No events within the next hour, but some later today")) + ;; Set very long text + (setq chime-modeline-no-events-text long-text) + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (equal long-text result))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-boundary-special-characters-emoji-displays () + "Test that special characters and emoji display correctly. + +When chime-modeline-no-events-text contains emoji and unicode characters, +they should display correctly in the modeline." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3)) + (emoji-text " 🔕🔔⏰📅")) + ;; Set emoji text + (setq chime-modeline-no-events-text emoji-text) + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (equal emoji-text result))))) + (test-chime-modeline-no-events-text-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-modeline-no-events-text-error-whitespace-only-displays-whitespace () + "Test that whitespace-only setting displays as-is. + +When chime-modeline-no-events-text is \" \" (whitespace only), +it should display the whitespace without trimming." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3)) + (whitespace-text " ")) + ;; Set whitespace text + (setq chime-modeline-no-events-text whitespace-text) + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (equal whitespace-text result))))) + (test-chime-modeline-no-events-text-teardown))) + +(provide 'test-chime-modeline-no-events-text) +;;; test-chime-modeline-no-events-text.el ends here diff --git a/tests/test-chime-modeline.el b/tests/test-chime-modeline.el new file mode 100644 index 0000000..534b017 --- /dev/null +++ b/tests/test-chime-modeline.el @@ -0,0 +1,1076 @@ +;;; test-chime-modeline.el --- Tests for chime modeline and tooltip -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Integration tests for chime modeline and tooltip behavior: +;; - Tests that rescheduled gcal events show correct times +;; - Tests that events don't appear multiple times in tooltip +;; - Tests that tooltip shows events in correct order +;; - Replicates real-world user scenarios + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-modeline-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Save original values + (setq test-chime-modeline--orig-lookahead chime-modeline-lookahead-minutes) + (setq test-chime-modeline--orig-tooltip-lookahead chime-tooltip-lookahead-hours) + ;; Set lookahead to 24 hours for testing (both modeline and tooltip) + (setq chime-modeline-lookahead-minutes 1440) + (setq chime-tooltip-lookahead-hours 24)) + +(defun test-chime-modeline-teardown () + "Teardown function run after each test." + ;; Restore original values + (setq chime-modeline-lookahead-minutes test-chime-modeline--orig-lookahead) + (setq chime-tooltip-lookahead-hours test-chime-modeline--orig-tooltip-lookahead) + (chime-delete-test-base-dir)) + +(defvar test-chime-modeline--orig-lookahead nil) +(defvar test-chime-modeline--orig-tooltip-lookahead nil) + +;;; Helper functions + +(defun test-chime-modeline--create-gcal-event (title time-str &optional old-time-str) + "Create test org content for a gcal event. +TITLE is the event title. +TIME-STR is the current time in the :org-gcal: drawer. +OLD-TIME-STR is an optional old time that might remain in the body." + (concat + (format "* %s\n" title) + ":PROPERTIES:\n" + ":entry-id: test123@google.com\n" + ":END:\n" + ":org-gcal:\n" + (format "%s\n" time-str) + ":END:\n" + (when old-time-str + (format "Old time was: %s\n" old-time-str)))) + +(defun test-chime-modeline--gather-events (content) + "Process CONTENT like chime-check does and return events list." + (let* ((test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + (events nil)) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + (push info events)) + (forward-line 1))) + (kill-buffer test-buffer) + (nreverse events))) + +(defun test-chime-modeline--count-in-string (regexp string) + "Count occurrences of REGEXP in STRING." + (let ((count 0) + (start 0)) + (while (string-match regexp string start) + (setq count (1+ count)) + (setq start (match-end 0))) + count)) + +;;; Tests for org-gcal event rescheduling + +(ert-deftest test-chime-modeline-gcal-event-after-reschedule () + "Test that rescheduled gcal event shows only NEW time, not old. + +Scenario: User moves event in Google Calendar, syncs with org-gcal. +The body might still mention the old time, but modeline should show new time. + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-modeline-setup) + (unwind-protect + (let* ((tomorrow (test-time-tomorrow-at 14 0)) + (today (test-time-today-at 14 0)) + (tomorrow-str (test-timestamp-string tomorrow)) + (today-str (test-timestamp-string today)) + (content (test-chime-modeline--create-gcal-event + "Team Meeting" + tomorrow-str + today-str)) + (events (test-chime-modeline--gather-events content))) + ;; Should have one event + (should (= 1 (length events))) + + ;; Event should have only ONE timestamp (from drawer, not body) + (let* ((event (car events)) + (times (cdr (assoc 'times event)))) + (should (= 1 (length times))) + ;; times is a list of (timestamp-string . parsed-time) cons cells + ;; Check the first timestamp string + (let ((time-str (caar times))) + (should (string-match-p ".*14:00" time-str)) + (should-not (string-match-p (format-time-string "%Y-%m-%d" today) time-str))))) + (test-chime-modeline-teardown))) + +;;; Tests for modeline deduplication + +(ert-deftest test-chime-modeline-no-duplicate-events () + "Test that modeline doesn't show the same event multiple times. + +Even if an event has multiple timestamps, it should appear only once +in the upcoming events list (with its soonest timestamp). + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (meeting-a-time (test-time-tomorrow-at 14 0)) + (meeting-b-time (test-time-tomorrow-at 15 0)) + (content (concat + (test-chime-modeline--create-gcal-event + "Meeting A" + (test-timestamp-string meeting-a-time)) + (test-chime-modeline--create-gcal-event + "Meeting B" + (test-timestamp-string meeting-b-time)))) + (events (test-chime-modeline--gather-events content))) + + ;; Should have two events + (should (= 2 (length events))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline with these events + (chime--update-modeline events) + + ;; Check that upcoming events list has no duplicates + (should (= 2 (length chime--upcoming-events))) + + ;; Each event should appear exactly once + (let ((titles (mapcar (lambda (item) + (cdr (assoc 'title (car item)))) + chime--upcoming-events))) + (should (member "Meeting A" titles)) + (should (member "Meeting B" titles)) + ;; No duplicate titles + (should (= 2 (length (delete-dups (copy-sequence titles)))))))) + (test-chime-modeline-teardown))) + +;;; Tests for tooltip generation + +(ert-deftest test-chime-modeline-tooltip-no-duplicates () + "Test that tooltip doesn't show the same event multiple times. +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (meeting-time (test-time-tomorrow-at 14 0)) + (content (test-chime-modeline--create-gcal-event + "Team Meeting" + (test-timestamp-string meeting-time))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (message "DEBUG: Tooltip content:\n%s" tooltip) + + ;; Tooltip should contain "Team Meeting" exactly once + (let ((count (test-chime-modeline--count-in-string "Team Meeting" tooltip))) + (should (= 1 count))) + + ;; "Upcoming Events" header should appear exactly once + (let ((header-count (test-chime-modeline--count-in-string "Upcoming Events" tooltip))) + (should (= 1 header-count)))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-tooltip-correct-order () + "Test that tooltip shows events in chronological order (soonest first). +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (meeting-a-time (test-time-tomorrow-at 14 0)) + (meeting-b-time (test-time-tomorrow-at 15 0)) + (content (concat + ;; Later event + (test-chime-modeline--create-gcal-event + "Meeting B" + (test-timestamp-string meeting-b-time)) + ;; Earlier event + (test-chime-modeline--create-gcal-event + "Meeting A" + (test-timestamp-string meeting-a-time)))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (message "DEBUG: Tooltip for order test:\n%s" tooltip) + + ;; "Meeting A" should appear before "Meeting B" in tooltip + (let ((pos-a (string-match "Meeting A" tooltip)) + (pos-b (string-match "Meeting B" tooltip))) + (should pos-a) + (should pos-b) + (should (< pos-a pos-b)))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-tooltip-structure () + "Test that tooltip has proper structure without duplicates. + +Tooltip should have: +- 'Upcoming Events as of...' header (once, at the beginning) +- Date sections (once per date) +- Event listings (once per event) +- No duplicate headers or sections + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (meeting-time (test-time-tomorrow-at 14 0)) + (content (test-chime-modeline--create-gcal-event + "Team Meeting" + (test-timestamp-string meeting-time))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (message "DEBUG: Tooltip structure:\n%s" tooltip) + + ;; Should have exactly one "Upcoming Events" header + (should (= 1 (test-chime-modeline--count-in-string "Upcoming Events" tooltip))) + + ;; Should start with "Upcoming Events as of" (new header format with timestamp) + (should (string-match-p "^Upcoming Events as of" tooltip)) + + ;; Event should appear exactly once + (should (= 1 (test-chime-modeline--count-in-string "Team Meeting" tooltip)))))) + (test-chime-modeline-teardown))) + +;;; Tests for tooltip max events limit + +(ert-deftest test-chime-modeline-tooltip-max-events () + "Test that tooltip respects chime-modeline-tooltip-max-events limit. + +When there are more events than the max, tooltip should show: +- Only the first N events +- '... and N more' message at the end + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (base-time (test-time-tomorrow-at 14 0)) + (content (concat + (test-chime-modeline--create-gcal-event + "Event 1" + (test-timestamp-string base-time)) + (test-chime-modeline--create-gcal-event + "Event 2" + (test-timestamp-string (test-time-tomorrow-at 15 0))) + (test-chime-modeline--create-gcal-event + "Event 3" + (test-timestamp-string (test-time-tomorrow-at 16 0))) + (test-chime-modeline--create-gcal-event + "Event 4" + (test-timestamp-string (test-time-tomorrow-at 17 0))) + (test-chime-modeline--create-gcal-event + "Event 5" + (test-timestamp-string (test-time-tomorrow-at 18 0))))) + (events (test-chime-modeline--gather-events content)) + (chime-modeline-tooltip-max-events 3)) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show first 3 events + (should (string-match-p "Event 1" tooltip)) + (should (string-match-p "Event 2" tooltip)) + (should (string-match-p "Event 3" tooltip)) + + ;; Should NOT show events 4 and 5 + (should-not (string-match-p "Event 4" tooltip)) + (should-not (string-match-p "Event 5" tooltip)) + + ;; Should have "... and 2 more events" message + (should (string-match-p "\\.\\.\\. and 2 more events" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-tooltip-max-events-nil () + "Test that tooltip shows all events when max-events is nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (base-time (test-time-tomorrow-at 14 0)) + (content (concat + (test-chime-modeline--create-gcal-event + "Event 1" + (test-timestamp-string base-time)) + (test-chime-modeline--create-gcal-event + "Event 2" + (test-timestamp-string (test-time-tomorrow-at 15 0))) + (test-chime-modeline--create-gcal-event + "Event 3" + (test-timestamp-string (test-time-tomorrow-at 16 0))))) + (events (test-chime-modeline--gather-events content)) + (chime-modeline-tooltip-max-events nil)) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show all 3 events + (should (string-match-p "Event 1" tooltip)) + (should (string-match-p "Event 2" tooltip)) + (should (string-match-p "Event 3" tooltip)) + + ;; Should NOT have "... and N more" message + (should-not (string-match-p "\\.\\.\\. and" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-tooltip-max-events-14-across-week () + "Test max-events with 14 events (2/day across 7 days). + +Comprehensive test of max-events interaction with multi-day grouping: +- 14 events total (2 per day for 7 days) +- max-events=20: should see all 14 events +- max-events=10: should see 10 events (2/day over 5 days) +- max-events=3: should see 2 for today, 1 for tomorrow + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (content "") + (events nil)) + + (with-test-time now + ;; Create 14 events: 2 per day for 7 days + (dotimes (day 7) + (dotimes (event-num 2) + (let* ((hours-offset (+ (* day 24) (* event-num 2) 2)) ; 2, 4, 26, 28, etc. + (event-time (time-add now (seconds-to-time (* hours-offset 3600)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (title (format "Day%d-Event%d" (1+ day) (1+ event-num)))) + (setq content (concat content + (test-chime-modeline--create-gcal-event + title + time-str)))))) + + (setq events (test-chime-modeline--gather-events content)) + + ;; Should have gathered 14 events + (should (= 14 (length events))) + + ;; Set lookahead to 10 days (enough to see all events, both modeline and tooltip) + (setq chime-modeline-lookahead-minutes 240) + (setq chime-tooltip-lookahead-hours 240) + + ;; Test 1: max-events=20 should show all 14 + (let ((chime-modeline-tooltip-max-events 20)) + (chime--update-modeline events) + (should (= 14 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Tooltip structure checks + (should (string-match-p "^Upcoming Events as of" tooltip)) + (should (= 1 (test-chime-modeline--count-in-string "Upcoming Events" tooltip))) + + ;; All 14 events should appear in tooltip + (dotimes (day 7) + (dotimes (event-num 2) + (let ((title (format "Day%d-Event%d" (1+ day) (1+ event-num)))) + (should (string-match-p title tooltip))))) + + ;; Verify chronological order: Day1-Event1 before Day7-Event2 + (let ((pos-first (string-match "Day1-Event1" tooltip)) + (pos-last (string-match "Day7-Event2" tooltip))) + (should pos-first) + (should pos-last) + (should (< pos-first pos-last))) + + ;; Should NOT have "... and N more" + (should-not (string-match-p "\\.\\.\\. and" tooltip)))) + + ;; Test 2: max-events=10 should show first 10 (5 days) + (let ((chime-modeline-tooltip-max-events 10)) + (chime--update-modeline events) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Tooltip structure checks + (should (string-match-p "^Upcoming Events as of" tooltip)) + (should (= 1 (test-chime-modeline--count-in-string "Upcoming Events" tooltip))) + + ;; First 10 events (days 1-5) should appear in tooltip + (dotimes (day 5) + (dotimes (event-num 2) + (let ((title (format "Day%d-Event%d" (1+ day) (1+ event-num)))) + (should (string-match-p title tooltip))))) + + ;; Events from days 6-7 should NOT appear in tooltip + (dotimes (day 2) + (dotimes (event-num 2) + (let ((title (format "Day%d-Event%d" (+ day 6) (1+ event-num)))) + (should-not (string-match-p title tooltip))))) + + ;; Verify chronological order in tooltip + (let ((pos-first (string-match "Day1-Event1" tooltip)) + (pos-last (string-match "Day5-Event2" tooltip))) + (should pos-first) + (should pos-last) + (should (< pos-first pos-last))) + + ;; Tooltip should have "... and 4 more events" + (should (string-match-p "\\.\\.\\. and 4 more events" tooltip)))) + + ;; Test 3: max-events=3 should show 2 today + 1 tomorrow + (let ((chime-modeline-tooltip-max-events 3)) + (chime--update-modeline events) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Tooltip structure checks + (should (string-match-p "^Upcoming Events as of" tooltip)) + (should (= 1 (test-chime-modeline--count-in-string "Upcoming Events" tooltip))) + + ;; First 2 events (today) should appear in tooltip + (should (string-match-p "Day1-Event1" tooltip)) + (should (string-match-p "Day1-Event2" tooltip)) + + ;; First event from day 2 (tomorrow) should appear in tooltip + (should (string-match-p "Day2-Event1" tooltip)) + + ;; Second event from day 2 should NOT appear in tooltip + (should-not (string-match-p "Day2-Event2" tooltip)) + + ;; Events from days 3+ should NOT appear in tooltip + (dotimes (day 5) + (dotimes (event-num 2) + (let ((title (format "Day%d-Event%d" (+ day 3) (1+ event-num)))) + (should-not (string-match-p title tooltip))))) + + ;; Verify chronological order in tooltip: Day1-Event1 before Day2-Event1 + (let ((pos-day1-e1 (string-match "Day1-Event1" tooltip)) + (pos-day1-e2 (string-match "Day1-Event2" tooltip)) + (pos-day2-e1 (string-match "Day2-Event1" tooltip))) + (should pos-day1-e1) + (should pos-day1-e2) + (should pos-day2-e1) + (should (< pos-day1-e1 pos-day1-e2)) + (should (< pos-day1-e2 pos-day2-e1))) + + ;; Should have "Today," and "Tomorrow," day labels in tooltip + (should (string-match-p "Today," tooltip)) + (should (string-match-p "Tomorrow," tooltip)) + + ;; Tooltip should have "... and 11 more events" + (should (string-match-p "\\.\\.\\. and 11 more events" tooltip)))))) + (test-chime-modeline-teardown))) + +;;; Tests for lookahead window boundaries + +(ert-deftest test-chime-modeline-lookahead-exact-limit () + "Test that event exactly at lookahead limit appears in modeline. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Create event exactly 60 minutes from now + (future-time (time-add now (seconds-to-time (* 60 60)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" future-time)) + (content (test-chime-modeline--create-gcal-event + "Event at limit" + time-str)) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to 60 minutes (both modeline and tooltip) + (setq chime-modeline-lookahead-minutes 60) + (setq chime-tooltip-lookahead-hours 1) + + ;; Update modeline + (chime--update-modeline events) + + ;; Event should appear in upcoming events + (should (= 1 (length chime--upcoming-events))) + (should (string-match-p "Event at limit" + (cdr (assoc 'title (car (car chime--upcoming-events)))))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-lookahead-beyond-limit () + "Test that event beyond lookahead limit does NOT appear. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Create event 90 minutes from now (beyond 60 min limit) + (future-time (time-add now (seconds-to-time (* 90 60)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" future-time)) + (content (test-chime-modeline--create-gcal-event + "Event beyond limit" + time-str)) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to 60 minutes (both modeline and tooltip) + (setq chime-modeline-lookahead-minutes 60) + (setq chime-tooltip-lookahead-hours 1) + + ;; Update modeline + (chime--update-modeline events) + + ;; Event should NOT appear in upcoming events + (should (= 0 (length chime--upcoming-events))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-past-event-excluded () + "Test that past events do NOT appear in modeline. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((past-time (test-time-yesterday-at 14 0)) + (content (test-chime-modeline--create-gcal-event + "Past Event" + (test-timestamp-string past-time))) + (events (test-chime-modeline--gather-events content))) + + ;; Update modeline + (chime--update-modeline events) + + ;; Past event should NOT appear + (should (= 0 (length chime--upcoming-events)))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-tooltip-lookahead-boundary-exactly-5-days () + "Test that event exactly 5 days away appears when tooltip lookahead is 5 days. + +This tests the inclusive boundary condition at a multi-day scale. +Event at exactly 120 hours with lookahead=120 hours should appear (at boundary). + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event exactly 5 days (120 hours) from now + (event-time (time-add now (seconds-to-time (* 120 3600)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (content (test-chime-modeline--create-gcal-event + "Event at 5 day boundary" + time-str)) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set tooltip lookahead to 120 hours (exactly 5 days) + (setq chime-modeline-lookahead-minutes 10080) ; 7 days (include in modeline) + (setq chime-tooltip-lookahead-hours 120) ; Exactly 5 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Event at 120 hours should appear (<= 120 hour boundary, inclusive) + (should (= 1 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (string-match-p "Event at 5 day boundary" tooltip)) + (should (string-match-p "in 5 days" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-tooltip-lookahead-boundary-crosses-into-window () + "Test that event appears when time progression brings it into lookahead window. + +Scenario: +- Event exactly 120 hours away +- Tooltip lookahead set to 119 hours (just under 5 days) +- Initially: event should NOT appear (120 > 119, beyond lookahead) +- Time progresses by 2 hours +- Now event is 118 hours away +- After time progression: event SHOULD appear (118 <= 119, within lookahead) + +This tests dynamic boundary crossing as time progresses. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event exactly 120 hours from now + (event-time (time-add now (seconds-to-time (* 120 3600)))) + (event-time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (content (test-chime-modeline--create-gcal-event + "Future Event" + event-time-str)) + (events (test-chime-modeline--gather-events content))) + + ;; PHASE 1: Initial state - event should NOT appear + (with-test-time now + (setq chime-modeline-lookahead-minutes 10080) ; 7 days (include in modeline) + (setq chime-tooltip-lookahead-hours 119) ; Just under 5 days + + (chime--update-modeline events) + + ;; Event at 120 hours should NOT appear (120 > 119, beyond lookahead) + (should (= 0 (length chime--upcoming-events)))) + + ;; PHASE 2: Time progresses by 2 hours + (let ((later (time-add now (seconds-to-time (* 2 3600))))) ; +2 hours + (with-test-time later + ;; Same lookahead settings + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 119) + + (chime--update-modeline events) + + ;; Now event is 118 hours away, should appear (118 <= 119) + (should (= 1 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (string-match-p "Future Event" tooltip)) + ;; Should show approximately 4 days 22 hours (118 hours) + (should (string-match-p "in 4 days 22 hours" tooltip)))))) + (test-chime-modeline-teardown))) + +;;; Tests for org-gcal with SCHEDULED edge case + +(ert-deftest test-chime-extract-time-gcal-ignores-scheduled () + "Test that org-gcal events ignore SCHEDULED and use drawer time. + +This is the CRITICAL test for the user's issue: when an event is +rescheduled in Google Calendar, org-gcal updates the :org-gcal: drawer +but might leave SCHEDULED property. We should ONLY use drawer time. + +REFACTORED: Uses dynamic timestamps" + (chime-create-test-base-dir) + (unwind-protect + (let* ((old-time (test-time-yesterday-at 14 0)) + (new-time (test-time-today-at 16 0)) + (old-timestamp (test-timestamp-string old-time)) + (new-timestamp (test-timestamp-string new-time)) + (content (format "* Team Meeting +SCHEDULED: %s +:PROPERTIES: +:entry-id: test123@google.com +:END: +:org-gcal: +%s +:END: +" old-timestamp new-timestamp)) + (test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + marker) + + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (setq marker (point-marker))) + + ;; Extract times + (let ((times (chime--extract-time marker))) + ;; Should have exactly one timestamp (from drawer, not SCHEDULED) + (should (= 1 (length times))) + + ;; Should be the drawer time (today 16:00), not SCHEDULED (yesterday 14:00) + (let ((time-str (caar times))) + (should (string-match-p "16:00" time-str)) + (should-not (string-match-p "14:00" time-str)))) + + (kill-buffer test-buffer)) + (chime-delete-test-base-dir))) + +;;; Tests for multiple timestamps deduplication + +(ert-deftest test-chime-modeline-multiple-timestamps-shows-soonest () + "Test that event with multiple timestamps appears once with soonest time. + +Regular org events (not org-gcal) can have multiple plain timestamps. +Modeline should show the event ONCE with its SOONEST upcoming timestamp. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (wed-time (test-time-tomorrow-at 14 0)) + (thu-time (test-time-days-from-now 2)) + (fri-time (test-time-days-from-now 3)) + (content (format "* Weekly Meeting +%s +%s +%s +" (test-timestamp-string wed-time) + (test-timestamp-string thu-time) + (test-timestamp-string fri-time))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Should have exactly ONE entry in upcoming events + (should (= 1 (length chime--upcoming-events))) + + ;; The entry should be for the soonest time (tomorrow) + (let* ((item (car chime--upcoming-events)) + (time-str (car (nth 1 item)))) + (should (string-match-p "14:00" time-str))) + + ;; Tooltip should show the event exactly once + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (= 1 (test-chime-modeline--count-in-string "Weekly Meeting" tooltip)))))) + (test-chime-modeline-teardown))) + +;;; Tests for day grouping + +(ert-deftest test-chime-modeline-day-grouping-today-tomorrow-future () + "Test tooltip groups events by day with correct labels. + +Events should be grouped as: +- 'Today, MMM DD' for events in next 24 hours +- 'Tomorrow, MMM DD' for events 24-48 hours away +- 'Weekday, MMM DD' for events beyond 48 hours + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event in 2 hours (today) + (today-time (time-add now (seconds-to-time (* 2 3600)))) + (today-str (format-time-string "<%Y-%m-%d %a %H:%M>" today-time)) + ;; Event in 26 hours (tomorrow) + (tomorrow-time (time-add now (seconds-to-time (* 26 3600)))) + (tomorrow-str (format-time-string "<%Y-%m-%d %a %H:%M>" tomorrow-time)) + ;; Event in 50 hours (future) + (future-time (time-add now (seconds-to-time (* 50 3600)))) + (future-str (format-time-string "<%Y-%m-%d %a %H:%M>" future-time)) + (content (concat + (test-chime-modeline--create-gcal-event + "Today Event" + today-str) + (test-chime-modeline--create-gcal-event + "Tomorrow Event" + tomorrow-str) + (test-chime-modeline--create-gcal-event + "Future Event" + future-str))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to 72 hours (both modeline and tooltip) + (setq chime-modeline-lookahead-minutes 4320) + (setq chime-tooltip-lookahead-hours 72) + + ;; Update modeline + (chime--update-modeline events) + + ;; Should have 3 events + (should (= 3 (length chime--upcoming-events))) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should have "Today," label + (should (string-match-p "Today," tooltip)) + + ;; Should have "Tomorrow," label + (should (string-match-p "Tomorrow," tooltip)) + + ;; Should have a weekday name for future event + (should (string-match-p (format-time-string "%A," future-time) tooltip)) + + ;; All three events should appear + (should (string-match-p "Today Event" tooltip)) + (should (string-match-p "Tomorrow Event" tooltip)) + (should (string-match-p "Future Event" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-day-grouping-multiple-same-day () + "Test that multiple events on same day are grouped together. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (morning-time (test-time-tomorrow-at 9 0)) + (afternoon-time (test-time-tomorrow-at 14 0)) + (evening-time (test-time-tomorrow-at 18 0)) + (content (concat + (test-chime-modeline--create-gcal-event + "Morning Event" + (test-timestamp-string morning-time)) + (test-chime-modeline--create-gcal-event + "Afternoon Event" + (test-timestamp-string afternoon-time)) + (test-chime-modeline--create-gcal-event + "Evening Event" + (test-timestamp-string evening-time)))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should have "Tomorrow, " exactly once (all events same day) + (should (= 1 (test-chime-modeline--count-in-string "Tomorrow," tooltip))) + + ;; All three events should appear + (should (string-match-p "Morning Event" tooltip)) + (should (string-match-p "Afternoon Event" tooltip)) + (should (string-match-p "Evening Event" tooltip)) + + ;; Events should appear in chronological order + (let ((pos-morning (string-match "Morning Event" tooltip)) + (pos-afternoon (string-match "Afternoon Event" tooltip)) + (pos-evening (string-match "Evening Event" tooltip))) + (should (< pos-morning pos-afternoon)) + (should (< pos-afternoon pos-evening)))))) + (test-chime-modeline-teardown))) + +;;; Tests for tooltip lookahead independence + +(ert-deftest test-chime-tooltip-lookahead-hours-independent () + "Test that tooltip can show events beyond modeline lookahead. + +Scenario: modeline-lookahead=60 (1 hour), tooltip-lookahead=180 (3 hours) +- Event at 30 min: appears in BOTH modeline and tooltip +- Event at 90 min: appears ONLY in tooltip (not modeline) +- Event at 240 min: appears in NEITHER + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event in 30 minutes (within modeline lookahead) + (event1-time (time-add now (seconds-to-time (* 30 60)))) + (event1-str (format-time-string "<%Y-%m-%d %a %H:%M>" event1-time)) + ;; Event in 90 minutes (beyond modeline, within tooltip) + (event2-time (time-add now (seconds-to-time (* 90 60)))) + (event2-str (format-time-string "<%Y-%m-%d %a %H:%M>" event2-time)) + ;; Event in 240 minutes (beyond both) + (event3-time (time-add now (seconds-to-time (* 240 60)))) + (event3-str (format-time-string "<%Y-%m-%d %a %H:%M>" event3-time)) + (content (concat + (test-chime-modeline--create-gcal-event + "Soon Event" + event1-str) + (test-chime-modeline--create-gcal-event + "Later Event" + event2-str) + (test-chime-modeline--create-gcal-event + "Far Event" + event3-str))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set different lookaheads for modeline vs tooltip + (setq chime-modeline-lookahead-minutes 60) ; 1 hour for modeline + (setq chime-tooltip-lookahead-hours 3) ; 3 hours for tooltip + + ;; Update modeline + (chime--update-modeline events) + + ;; Modeline should show only the 30-min event + (should (string-match-p "Soon Event" (or chime-modeline-string ""))) + (should-not (string-match-p "Later Event" (or chime-modeline-string ""))) + + ;; Tooltip should show both 30-min and 90-min events, but not 240-min + (should (= 2 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Tooltip should have event within modeline lookahead + (should (string-match-p "Soon Event" tooltip)) + + ;; Tooltip should have event beyond modeline but within tooltip lookahead + (should (string-match-p "Later Event" tooltip)) + + ;; Tooltip should NOT have event beyond tooltip lookahead + (should-not (string-match-p "Far Event" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-tooltip-lookahead-hours-default () + "Test that tooltip default lookahead (1 year) shows all future events. + +The default value effectively means 'show all future events' limited only +by max-events count. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event in 30 minutes + (event1-time (time-add now (seconds-to-time (* 30 60)))) + (event1-str (format-time-string "<%Y-%m-%d %a %H:%M>" event1-time)) + ;; Event in 90 minutes + (event2-time (time-add now (seconds-to-time (* 90 60)))) + (event2-str (format-time-string "<%Y-%m-%d %a %H:%M>" event2-time)) + ;; Event in 2 days + (event3-time (time-add now (seconds-to-time (* 48 3600)))) + (event3-str (format-time-string "<%Y-%m-%d %a %H:%M>" event3-time)) + (content (concat + (test-chime-modeline--create-gcal-event + "Soon Event" + event1-str) + (test-chime-modeline--create-gcal-event + "Later Event" + event2-str) + (test-chime-modeline--create-gcal-event + "Far Event" + event3-str))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set modeline lookahead only (tooltip uses default: 525600 = 1 year) + (setq chime-modeline-lookahead-minutes 60) + (setq chime-tooltip-lookahead-hours 8760) ; Default + + ;; Update modeline + (chime--update-modeline events) + + ;; Tooltip should see all 3 events (all within 1 year) + (should (= 3 (length chime--upcoming-events))) + + ;; Modeline should only show first event (within 60 min) + (should (string-match-p "Soon Event" (or chime-modeline-string ""))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; All events should appear in tooltip + (should (string-match-p "Soon Event" tooltip)) + (should (string-match-p "Later Event" tooltip)) + (should (string-match-p "Far Event" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-tooltip-lookahead-hours-larger-shows-more () + "Test that larger tooltip lookahead shows more events than modeline. + +Real-world scenario: Show next event in modeline if within 2 hours, +but show all events for today (24 hours) in tooltip. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (content "") + (events nil)) + + (with-test-time now + ;; Create 5 events spread across 12 hours + (dotimes (i 5) + (let* ((hours-offset (+ 1 (* i 2))) ; 1, 3, 5, 7, 9 hours + (event-time (time-add now (seconds-to-time (* hours-offset 3600)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (title (format "Event-%d-hours" hours-offset))) + (setq content (concat content + (test-chime-modeline--create-gcal-event + title + time-str))))) + + (setq events (test-chime-modeline--gather-events content)) + + ;; Set lookaheads: 2 hours for modeline, 12 hours for tooltip + (setq chime-modeline-lookahead-minutes 120) + (setq chime-tooltip-lookahead-hours 12) + + ;; Update modeline + (chime--update-modeline events) + + ;; Modeline should show only first event (within 2 hours) + (should (string-match-p "Event-1-hours" (or chime-modeline-string ""))) + (should-not (string-match-p "Event-5-hours" (or chime-modeline-string ""))) + + ;; Tooltip should show all 5 events (all within 12 hours) + (should (= 5 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; All events should appear in tooltip + (should (string-match-p "Event-1-hours" tooltip)) + (should (string-match-p "Event-3-hours" tooltip)) + (should (string-match-p "Event-5-hours" tooltip)) + (should (string-match-p "Event-7-hours" tooltip)) + (should (string-match-p "Event-9-hours" tooltip))))) + (test-chime-modeline-teardown))) + +(provide 'test-chime-modeline) +;;; test-chime-modeline.el ends here diff --git a/tests/test-chime-notification-text.el b/tests/test-chime-notification-text.el new file mode 100644 index 0000000..71a2969 --- /dev/null +++ b/tests/test-chime-notification-text.el @@ -0,0 +1,542 @@ +;;; test-chime-notification-text.el --- Tests for chime--notification-text -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--notification-text function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-notification-text-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset display format to default + (setq chime-display-time-format-string "%I:%M %p") + ;; Reset notification text format to default + (setq chime-notification-text-format "%t at %T (%u)") + ;; Reset time-left formats to defaults + (setq chime-time-left-format-at-event "right now") + (setq chime-time-left-format-short "in %M") + (setq chime-time-left-format-long "in %H %M") + ;; Reset title truncation to default (no truncation) + (setq chime-max-title-length nil)) + +(defun test-chime-notification-text-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-notification-text-standard-event-formats-correctly () + "Test that standard event formats correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (result (chime--notification-text str-interval event))) + ;; Should format: "Team Meeting at 02:30 PM (in X minutes)" + (should (stringp result)) + (should (string-match-p "Team Meeting" result)) + (should (string-match-p "02:30 PM" result)) + (should (string-match-p "in 10 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-morning-time-formats-with-am () + "Test that morning time uses AM. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 9 15)) + (str-interval (cons (test-timestamp-string time) '(5 . medium))) + (event '((title . "Standup"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Standup" result)) + (should (string-match-p "09:15 AM" result)) + (should (string-match-p "in 5 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-midnight-formats-correctly () + "Test that midnight time formats correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (str-interval (cons (test-timestamp-string time) '(30 . medium))) + (event '((title . "Midnight Event"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Midnight Event" result)) + (should (string-match-p "12:00 AM" result)) + (should (string-match-p "in 30 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-noon-formats-correctly () + "Test that noon time formats correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 12 0)) + (str-interval (cons (test-timestamp-string time) '(15 . medium))) + (event '((title . "Lunch"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Lunch" result)) + (should (string-match-p "12:00 PM" result)) + (should (string-match-p "in 15 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-zero-minutes-shows-right-now () + "Test that zero minutes shows 'right now'. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (str-interval (cons (test-timestamp-string time) '(0 . high))) + (event '((title . "Current Event"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Current Event" result)) + (should (string-match-p "02:00 PM" result)) + (should (string-match-p "right now" result))) + (test-chime-notification-text-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-notification-text-very-long-title-included () + "Test that very long titles are included in full. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 15 45)) + (str-interval (cons (test-timestamp-string time) '(20 . medium))) + (long-title "This is a very long event title that contains many words and might wrap in the notification display") + (event `((title . ,long-title))) + (result (chime--notification-text str-interval event))) + ;; Should include the full title + (should (string-match-p long-title result)) + (should (string-match-p "03:45 PM" result)) + (should (string-match-p "in 20 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-title-with-special-characters () + "Test that titles with special characters work correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 16 30)) + (str-interval (cons (test-timestamp-string time) '(5 . medium))) + (event '((title . "Review: Alice's PR #123 (urgent!)"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Review: Alice's PR #123 (urgent!)" result)) + (should (string-match-p "04:30 PM" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-time-format () + "Test that custom time format string is respected. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-display-time-format-string "%H:%M") ; 24-hour format + (result (chime--notification-text str-interval event))) + ;; Should use 24-hour format + (should (string-match-p "Meeting" result)) + (should (string-match-p "14:30" result)) + (should-not (string-match-p "PM" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-large-interval-shows-hours () + "Test that large intervals show hours. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 18 0)) + (str-interval (cons (test-timestamp-string time) '(120 . low))) ; 2 hours + (event '((title . "Evening Event"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Evening Event" result)) + (should (string-match-p "06:00 PM" result)) + ;; Should show hours format + (should (string-match-p "in 2 hours" result))) + (test-chime-notification-text-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-notification-text-empty-title-shows-empty () + "Test that empty title still generates output. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . ""))) + (result (chime--notification-text str-interval event))) + ;; Should still format, even with empty title + (should (stringp result)) + (should (string-match-p "02:30 PM" result)) + (should (string-match-p "in 10 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-missing-title-shows-nil () + "Test that missing title shows nil in output. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '()) ; No title + (result (chime--notification-text str-interval event))) + ;; Should still generate output with nil title + (should (stringp result)) + (should (string-match-p "02:30 PM" result)) + (should (string-match-p "in 10 minutes" result))) + (test-chime-notification-text-teardown))) + +;;; Custom Format Cases + +(ert-deftest test-chime-notification-text-custom-title-only () + "Test custom format showing title only. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (chime-notification-text-format "%t")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Team Meeting" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-title-and-time-no-countdown () + "Test custom format with title and time, no countdown. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (chime-notification-text-format "%t at %T")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Team Meeting at 02:30 PM" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-title-and-countdown-no-time () + "Test custom format with title and countdown, no time. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (chime-notification-text-format "%t (%u)")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Team Meeting (in 10 minutes)" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-separator () + "Test custom format with custom separator. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (chime-notification-text-format "%t - %T")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Team Meeting - 02:30 PM" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-order-time-first () + "Test custom format with time before title. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (chime-notification-text-format "%T: %t")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "02:30 PM: Team Meeting" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-compact-format () + "Test custom compact format. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-notification-text-format "%t@%T")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Meeting@02:30 PM" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-with-compact-time-left () + "Test custom format with compact time-left format. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-notification-text-format "%t (%u)") + (chime-time-left-format-short "in %mm")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Meeting (in 10m)" result)))) + (test-chime-notification-text-teardown))) + +;;; Time Format Cases + +(ert-deftest test-chime-notification-text-24-hour-time-format () + "Test 24-hour time format (14:30). + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-display-time-format-string "%H:%M")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "14:30" result)) + (should-not (string-match-p "PM" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-12-hour-no-space-before-ampm () + "Test 12-hour format without space before AM/PM (02:30PM). + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-display-time-format-string "%I:%M%p")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "02:30PM" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-lowercase-ampm () + "Test 12-hour format with lowercase am/pm (02:30 pm). + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-display-time-format-string "%I:%M %P")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "02:30 pm" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-24-hour-morning () + "Test 24-hour format for morning time (09:15). + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 9 15)) + (str-interval (cons (test-timestamp-string time) '(5 . medium))) + (event '((title . "Standup"))) + (chime-display-time-format-string "%H:%M")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "09:15" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-24-hour-midnight () + "Test 24-hour format for midnight (00:00). + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (str-interval (cons (test-timestamp-string time) '(30 . medium))) + (event '((title . "Midnight"))) + (chime-display-time-format-string "%H:%M")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "00:00" result)))) + (test-chime-notification-text-teardown))) + +;;; Title Truncation Cases + +(ert-deftest test-chime-notification-text-truncate-nil-no-truncation () + "Test that nil chime-max-title-length shows full title. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Very Long Meeting Title That Goes On And On"))) + (chime-max-title-length nil)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Very Long Meeting Title That Goes On And On" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-25-chars () + "Test truncation to 25 characters. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Very Long Meeting Title That Goes On"))) + (chime-max-title-length 25)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Very Long Meeting Titl\\.\\.\\." result)) + (should-not (string-match-p "That Goes On" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-15-chars () + "Test truncation to 15 characters. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Very Long Meeting Title"))) + (chime-max-title-length 15)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Very Long Me\\.\\.\\." result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-10-chars () + "Test truncation to 10 characters. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Very Long Title"))) + (chime-max-title-length 10)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Very Lo\\.\\.\\." result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-short-title-unchanged () + "Test that short titles are not truncated. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Short"))) + (chime-max-title-length 25)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Short" result)) + (should-not (string-match-p "\\.\\.\\." result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-exact-length-unchanged () + "Test that title exactly at max length is not truncated. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Exactly Twenty-Five C"))) ; 21 chars + (chime-max-title-length 21)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Exactly Twenty-Five C" result)) + (should-not (string-match-p "\\.\\.\\." result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-nil-title-handled () + "Test that nil title is handled gracefully with truncation enabled. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '()) ; No title + (chime-max-title-length 25)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "02:30 PM" result)))) + (test-chime-notification-text-teardown))) + +(provide 'test-chime-notification-text) +;;; test-chime-notification-text.el ends here diff --git a/tests/test-chime-notifications.el b/tests/test-chime-notifications.el new file mode 100644 index 0000000..d3d5e81 --- /dev/null +++ b/tests/test-chime-notifications.el @@ -0,0 +1,259 @@ +;;; test-chime-notifications.el --- Tests for chime--notifications -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--notifications function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-notifications-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-notifications-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-notifications-single-time-single-interval-returns-pair () + "Test that single time with single interval returns one notification pair. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 (10 minutes from now) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (intervals . ((10 . medium))))) + (result (chime--notifications event))) + ;; Should return list with one pair + (should (listp result)) + (should (= 1 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-single-time-multiple-intervals-returns-multiple-pairs () + "Test that single time with multiple intervals returns multiple notification pairs. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (intervals . ((10 . medium) (5 . medium))))) ; Two intervals, only 10 matches + (result (chime--notifications event))) + ;; Should return only matching interval + (should (listp result)) + (should (= 1 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-multiple-times-single-interval-returns-matching-pairs () + "Test that multiple times with single interval returns matching notifications. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Two events: one at 14:10, one at 14:05 + (event-time-1 (test-time-today-at 14 10)) + (event-time-2 (test-time-today-at 14 5)) + (timestamp-str-1 (test-timestamp-string event-time-1)) + (timestamp-str-2 (test-timestamp-string event-time-2))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str-1 . ,event-time-1) + (,timestamp-str-2 . ,event-time-2))) + (title . "Test Event") + (intervals . ((10 . medium))))) ; Only first time matches + (result (chime--notifications event))) + ;; Should return only matching time + (should (listp result)) + (should (= 1 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-multiple-times-multiple-intervals-returns-all-matches () + "Test that multiple times and intervals return all matching combinations. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 and 14:05 + (event-time-1 (test-time-today-at 14 10)) + (event-time-2 (test-time-today-at 14 5)) + (timestamp-str-1 (test-timestamp-string event-time-1)) + (timestamp-str-2 (test-timestamp-string event-time-2))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str-1 . ,event-time-1) + (,timestamp-str-2 . ,event-time-2))) + (title . "Test Event") + (intervals . ((10 . medium) (5 . medium))))) ; Both match (10 with first, 5 with second) + (result (chime--notifications event))) + ;; Should return both matching pairs + (should (listp result)) + (should (= 2 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-zero-interval-returns-current-time-match () + "Test that zero interval (notify now) works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at exactly current time + (event-time (test-time-today-at 14 0)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (intervals . ((0 . high))))) + (result (chime--notifications event))) + ;; Should return one matching pair + (should (listp result)) + (should (= 1 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-filters-day-wide-events () + "Test that day-wide events (without time) are filtered out. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Mix of day-wide and timed events + (event-time (test-time-today-at 14 10)) + (timestamp-str-day (test-timestamp-string event-time t)) ; Day-wide + (timestamp-str-timed (test-timestamp-string event-time))) ; Timed + (with-test-time now + (let* ((event `((times . ((,timestamp-str-day . ,event-time) ; Day-wide + (,timestamp-str-timed . ,event-time))) ; Timed + (title . "Test Event") + (intervals . ((10 . medium))))) + (result (chime--notifications event))) + ;; Should return only timed event + (should (listp result)) + (should (= 1 (length result)))))) + (test-chime-notifications-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-notifications-empty-times-returns-empty-list () + "Test that event with no times returns empty list. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (let* ((event `((times . (())) + (title . "Test Event") + (intervals . ((10 . medium))))) + (result (chime--notifications event))) + (should (listp result)) + (should (= 0 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-empty-intervals-returns-empty-list () + "Test that event with no intervals returns empty list. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (intervals . ()))) + (result (chime--notifications event))) + (should (listp result)) + (should (= 0 (length result)))))) + (test-chime-notifications-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-notifications-nil-times-returns-empty-list () + "Test that event with nil times returns empty list. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (let* ((event `((times . (nil)) + (title . "Test Event") + (intervals . ((10 . medium))))) + (result (chime--notifications event))) + (should (listp result)) + (should (= 0 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-nil-intervals-returns-empty-list () + "Test that event with nil intervals returns empty list. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (intervals . nil))) + (result (chime--notifications event))) + (should (listp result)) + (should (= 0 (length result)))))) + (test-chime-notifications-teardown))) + +(provide 'test-chime-notifications) +;;; test-chime-notifications.el ends here diff --git a/tests/test-chime-notify.el b/tests/test-chime-notify.el new file mode 100644 index 0000000..20727c1 --- /dev/null +++ b/tests/test-chime-notify.el @@ -0,0 +1,259 @@ +;;; test-chime-notify.el --- Tests for chime--notify -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--notify function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-notify-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset notification settings + (setq chime-notification-title "Agenda") + (setq chime-notification-icon nil) + (setq chime-extra-alert-plist nil) + (setq chime-play-sound t) + ;; Use a simple test path for sound file + (setq chime-sound-file "/tmp/test-chime.wav")) + +(defun test-chime-notify-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-notify-plays-sound-when-enabled-and-file-exists () + "Test that sound is played when enabled and file exists." + (test-chime-notify-setup) + (unwind-protect + (let ((sound-played nil) + (alert-called nil) + (alert-message nil)) + (cl-letf* ((chime-play-sound t) + (chime-sound-file (expand-file-name "test-sound.wav" chime-test-base-dir)) + ;; Mock file-exists-p to return t + ((symbol-function 'file-exists-p) (lambda (file) t)) + ;; Mock play-sound-file to track if called + ((symbol-function 'play-sound-file) + (lambda (file) + (setq sound-played t))) + ;; Mock alert to track if called + ((symbol-function 'alert) + (lambda (msg &rest args) + (setq alert-called t) + (setq alert-message msg)))) + (chime--notify "Team Meeting at 02:10 PM") + ;; Should play sound + (should sound-played) + ;; Should show alert + (should alert-called) + (should (equal alert-message "Team Meeting at 02:10 PM")))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-uses-beep-when-no-sound-file-specified () + "Test that no sound is played when chime-sound-file is nil." + (test-chime-notify-setup) + (unwind-protect + (let ((beep-called nil) + (alert-called nil)) + (cl-letf* ((chime-play-sound t) + (chime-sound-file nil) + ;; Mock beep to track if called + ((symbol-function 'beep) + (lambda () (setq beep-called t))) + ;; Mock alert + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + (chime--notify "Standup in 5 minutes") + ;; Should NOT call beep (no sound when chime-sound-file is nil) + (should-not beep-called) + ;; Should still show alert + (should alert-called))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-no-sound-when-disabled () + "Test that no sound is played when chime-play-sound is nil." + (test-chime-notify-setup) + (unwind-protect + (let ((sound-played nil) + (beep-called nil) + (alert-called nil)) + (cl-letf* ((chime-play-sound nil) + ((symbol-function 'play-sound-file) + (lambda (file) (setq sound-played t))) + ((symbol-function 'beep) + (lambda () (setq beep-called t))) + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + (chime--notify "Daily Standup") + ;; Should NOT play sound or beep + (should-not sound-played) + (should-not beep-called) + ;; Should still show alert + (should alert-called))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-passes-correct-parameters-to-alert () + "Test that alert is called with correct parameters." + (test-chime-notify-setup) + (unwind-protect + (let ((alert-params nil)) + (cl-letf* ((chime-play-sound nil) + (chime-notification-title "Custom Title") + (chime-notification-icon "/path/to/icon.png") + (chime-extra-alert-plist '(:persistent t)) + ;; Mock alert to capture parameters + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-params args)))) + ;; Pass cons cell (message . severity) to chime--notify + (chime--notify (cons "Test Event" 'high)) + ;; Verify alert was called with correct parameters + (should (equal (plist-get alert-params :title) "Custom Title")) + (should (equal (plist-get alert-params :icon) "/path/to/icon.png")) + (should (equal (plist-get alert-params :severity) 'high)) + (should (equal (plist-get alert-params :category) 'chime)) + ;; Extra plist should be merged in + (should (equal (plist-get alert-params :persistent) t)))) + (test-chime-notify-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-notify-empty-message-still-notifies () + "Test that empty message still triggers notification." + (test-chime-notify-setup) + (unwind-protect + (let ((alert-called nil) + (alert-message nil)) + (cl-letf* ((chime-play-sound nil) + ((symbol-function 'alert) + (lambda (msg &rest args) + (setq alert-called t) + (setq alert-message msg)))) + (chime--notify "") + ;; Should still call alert, even with empty message + (should alert-called) + (should (equal alert-message "")))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-no-sound-file-when-file-doesnt-exist () + "Test that no sound is played when file doesn't exist." + (test-chime-notify-setup) + (unwind-protect + (let ((sound-played nil) + (alert-called nil)) + (cl-letf* ((chime-play-sound t) + (chime-sound-file "/nonexistent/path/sound.wav") + ;; Mock file-exists-p to return nil + ((symbol-function 'file-exists-p) (lambda (file) nil)) + ((symbol-function 'play-sound-file) + (lambda (file) (setq sound-played t))) + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + (chime--notify "Test Event") + ;; Should NOT play sound + (should-not sound-played) + ;; Should still show alert + (should alert-called))) + (test-chime-notify-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-notify-handles-sound-playback-error-gracefully () + "Test that errors in sound playback don't prevent notification." + (test-chime-notify-setup) + (unwind-protect + (let ((alert-called nil)) + (cl-letf* ((chime-play-sound t) + (chime-sound-file "/some/file.wav") + ;; Mock file-exists-p to return t + ((symbol-function 'file-exists-p) (lambda (file) t)) + ;; Mock play-sound-file to throw error + ((symbol-function 'play-sound-file) + (lambda (file) (error "Sound playback failed"))) + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + ;; Should not throw error + (should-not (condition-case nil + (progn (chime--notify "Test Event") nil) + (error t))) + ;; Should still show alert despite sound error + (should alert-called))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-handles-beep-error-gracefully () + "Test that errors in beep don't prevent notification." + (test-chime-notify-setup) + (unwind-protect + (let ((alert-called nil)) + (cl-letf* ((chime-play-sound t) + (chime-sound-file nil) + ;; Mock beep to throw error + ((symbol-function 'beep) + (lambda () (error "Beep failed"))) + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + ;; Should not throw error + (should-not (condition-case nil + (progn (chime--notify "Test Event") nil) + (error t))) + ;; Should still show alert despite beep error + (should alert-called))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-error-nil-message-handles-gracefully () + "Test that nil message parameter doesn't crash." + (test-chime-notify-setup) + (unwind-protect + (let ((alert-called nil)) + (cl-letf* ((chime-play-sound nil) + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + ;; Should not error with nil message + (should-not (condition-case nil + (progn (chime--notify nil) nil) + (error t))) + ;; Alert should still be called + (should alert-called))) + (test-chime-notify-teardown))) + +(provide 'test-chime-notify) +;;; test-chime-notify.el ends here diff --git a/tests/test-chime-org-contacts.el b/tests/test-chime-org-contacts.el new file mode 100644 index 0000000..a9a01a1 --- /dev/null +++ b/tests/test-chime-org-contacts.el @@ -0,0 +1,317 @@ +;;; test-chime-org-contacts.el --- Tests for chime-org-contacts.el -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;;; Commentary: + +;; Unit and integration tests for chime-org-contacts.el + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'org) +(require 'org-capture) + +;; Load the module being tested +(let ((module-file (expand-file-name "../chime-org-contacts.el" + (file-name-directory (or load-file-name buffer-file-name))))) + (load module-file nil t)) + +;;; Unit Tests - chime-org-contacts--parse-birthday + +(ert-deftest test-chime-org-contacts-parse-birthday-full-format () + "Test parsing YYYY-MM-DD format." + (let ((result (chime-org-contacts--parse-birthday "2000-01-01"))) + (should (equal result '(2000 1 1)))) + + (let ((result (chime-org-contacts--parse-birthday "1985-03-15"))) + (should (equal result '(1985 3 15)))) + + (let ((result (chime-org-contacts--parse-birthday "2024-12-31"))) + (should (equal result '(2024 12 31))))) + +(ert-deftest test-chime-org-contacts-parse-birthday-partial-format () + "Test parsing MM-DD format uses current year." + (let ((current-year (nth 5 (decode-time)))) + (let ((result (chime-org-contacts--parse-birthday "03-15"))) + (should (equal result (list current-year 3 15)))) + + (let ((result (chime-org-contacts--parse-birthday "12-31"))) + (should (equal result (list current-year 12 31)))))) + +(ert-deftest test-chime-org-contacts-parse-birthday-leap-year () + "Test parsing leap year date." + (let ((result (chime-org-contacts--parse-birthday "2024-02-29"))) + (should (equal result '(2024 2 29))))) + +(ert-deftest test-chime-org-contacts-parse-birthday-invalid-format () + "Test that invalid formats return nil." + (should (null (chime-org-contacts--parse-birthday "2000/01/01"))) + (should (null (chime-org-contacts--parse-birthday "1-1-2000"))) + (should (null (chime-org-contacts--parse-birthday "Jan 1, 2000"))) + (should (null (chime-org-contacts--parse-birthday "not a date")))) + +(ert-deftest test-chime-org-contacts-parse-birthday-empty-input () + "Test that empty input returns nil." + (should (null (chime-org-contacts--parse-birthday "")))) + +(ert-deftest test-chime-org-contacts-parse-birthday-boundary-dates () + "Test boundary dates (start/end of year, end of months)." + (should (equal (chime-org-contacts--parse-birthday "2025-01-01") '(2025 1 1))) + (should (equal (chime-org-contacts--parse-birthday "2025-12-31") '(2025 12 31))) + (should (equal (chime-org-contacts--parse-birthday "2025-11-30") '(2025 11 30)))) + +;;; Unit Tests - chime-org-contacts--format-timestamp + +(ert-deftest test-chime-org-contacts-format-timestamp-basic () + "Test basic timestamp formatting." + (let ((timestamp (chime-org-contacts--format-timestamp 2025 1 1))) + (should (string-match-p "^<2025-01-01 [A-Za-z]\\{3\\} \\+1y>$" timestamp)))) + +(ert-deftest test-chime-org-contacts-format-timestamp-day-of-week () + "Test that day of week matches the date." + ;; 2025-01-01 is a Wednesday + (let ((timestamp (chime-org-contacts--format-timestamp 2025 1 1))) + (should (string-match-p "Wed" timestamp))) + + ;; 2024-02-29 is a Thursday (leap year) + (let ((timestamp (chime-org-contacts--format-timestamp 2024 2 29))) + (should (string-match-p "Thu" timestamp)))) + +(ert-deftest test-chime-org-contacts-format-timestamp-all-months () + "Test formatting for all months." + (dolist (month '(1 2 3 4 5 6 7 8 9 10 11 12)) + (let ((timestamp (chime-org-contacts--format-timestamp 2025 month 1))) + (should (string-match-p (format "^<2025-%02d-01 [A-Za-z]\\{3\\} \\+1y>$" month) timestamp))))) + +(ert-deftest test-chime-org-contacts-format-timestamp-repeater () + "Test that +1y repeater is always included." + (let ((timestamp (chime-org-contacts--format-timestamp 2025 3 15))) + (should (string-match-p "\\+1y>" timestamp)))) + +;;; Unit Tests - chime-org-contacts--insert-timestamp-after-drawer + +(ert-deftest test-chime-org-contacts-insert-timestamp-when-none-exists () + "Test inserting timestamp when none exists." + (with-temp-buffer + (org-mode) + (insert "* Contact\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (goto-char (point-min)) + + (chime-org-contacts--insert-timestamp-after-drawer "<2000-01-01 Wed +1y>") + + (let ((content (buffer-string))) + (should (string-match-p "<2000-01-01 Wed \\+1y>" content)) + (should (string-match-p ":END:\n<2000-01-01" content))))) + +(ert-deftest test-chime-org-contacts-insert-timestamp-skips-when-exists () + "Test that insertion is skipped when timestamp already exists." + (with-temp-buffer + (org-mode) + (insert "* Contact\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (insert "<2000-01-01 Wed +1y>\n") + (goto-char (point-min)) + + (chime-org-contacts--insert-timestamp-after-drawer "<2000-01-01 Wed +1y>") + + ;; Should have exactly one timestamp + (should (= 1 (how-many "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (point-min) (point-max)))))) + +(ert-deftest test-chime-org-contacts-insert-timestamp-handles-whitespace () + "Test handling of whitespace around :END:." + (with-temp-buffer + (org-mode) + (insert "* Contact\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert " :END: \n") ; Whitespace before and after + (goto-char (point-min)) + + (chime-org-contacts--insert-timestamp-after-drawer "<2000-01-01 Wed +1y>") + + (should (string-match-p "<2000-01-01 Wed \\+1y>" (buffer-string))))) + +(ert-deftest test-chime-org-contacts-insert-timestamp-preserves-content () + "Test that insertion doesn't modify other content." + (with-temp-buffer + (org-mode) + (insert "* Contact\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: test@example.com\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (insert "Some notes about the contact.\n") + (goto-char (point-min)) + + (let ((original-content (buffer-substring (point-min) (point-max)))) + (chime-org-contacts--insert-timestamp-after-drawer "<2000-01-01 Wed +1y>") + + (should (string-search ":EMAIL: test@example.com" (buffer-string))) + (should (string-search "Some notes about the contact" (buffer-string)))))) + +(ert-deftest test-chime-org-contacts-insert-timestamp-missing-end () + "Test handling when :END: is missing (malformed drawer)." + (with-temp-buffer + (org-mode) + (insert "* Contact\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + ;; No :END: + (goto-char (point-min)) + + (chime-org-contacts--insert-timestamp-after-drawer "<2000-01-01 Wed +1y>") + + ;; Should not insert when :END: is missing + (should-not (string-match-p "<2000-01-01" (buffer-string))))) + +;;; Integration Tests - chime-org-contacts--finalize-birthday-timestamp + +(ert-deftest test-chime-org-contacts-finalize-adds-timestamp-full-date () + "Test finalize adds timestamp for YYYY-MM-DD birthday." + (with-temp-buffer + (org-mode) + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: alice@example.com\n") + (insert ":BIRTHDAY: 1985-03-15\n") + (insert ":END:\n") + (goto-char (point-min)) + + (let ((org-capture-plist '(:key "C"))) + (chime-org-contacts--finalize-birthday-timestamp) + + (let ((content (buffer-string))) + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)))))) + +(ert-deftest test-chime-org-contacts-finalize-adds-timestamp-partial-date () + "Test finalize adds timestamp for MM-DD birthday." + (let ((current-year (nth 5 (decode-time)))) + (with-temp-buffer + (org-mode) + (insert "* Bob Baker\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 07-04\n") + (insert ":END:\n") + (goto-char (point-min)) + + (let ((org-capture-plist '(:key "C"))) + (chime-org-contacts--finalize-birthday-timestamp) + + (let ((content (buffer-string))) + (should (string-match-p (format "<%d-07-04 [A-Za-z]\\{3\\} \\+1y>" current-year) content))))))) + +(ert-deftest test-chime-org-contacts-finalize-skips-when-no-birthday () + "Test finalize does nothing when :BIRTHDAY: property missing." + (with-temp-buffer + (org-mode) + (insert "* Carol Chen\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: carol@example.com\n") + (insert ":END:\n") + (goto-char (point-min)) + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (chime-org-contacts--finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content))))) + +(ert-deftest test-chime-org-contacts-finalize-skips-empty-birthday () + "Test finalize skips empty birthday values." + (with-temp-buffer + (org-mode) + (insert "* David Davis\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: \n") + (insert ":END:\n") + (goto-char (point-min)) + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (chime-org-contacts--finalize-birthday-timestamp) + + (should (string= (buffer-string) original-content))))) + +(ert-deftest test-chime-org-contacts-finalize-only-runs-for-correct-key () + "Test finalize only runs for configured capture key." + (with-temp-buffer + (org-mode) + (insert "* Task\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (goto-char (point-min)) + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "t"))) ; Different key + (chime-org-contacts--finalize-birthday-timestamp) + + ;; Should not insert timestamp + (should (string= (buffer-string) original-content))))) + +;;; Integration Tests - chime-org-contacts--setup-capture-template + +(ert-deftest test-chime-org-contacts-setup-adds-template-when-file-set () + "Test that template is added when file is set." + (let ((chime-org-contacts-file "/tmp/test-contacts.org") + (org-capture-templates nil)) + + (chime-org-contacts--setup-capture-template) + + (should org-capture-templates) + (should (assoc "C" org-capture-templates)))) + +(ert-deftest test-chime-org-contacts-setup-skips-when-file-nil () + "Test that template is not added when file is nil." + (let ((chime-org-contacts-file nil) + (org-capture-templates nil)) + + (chime-org-contacts--setup-capture-template) + + (should-not org-capture-templates))) + +(ert-deftest test-chime-org-contacts-setup-template-structure () + "Test that added template has correct structure." + (let ((chime-org-contacts-file "/tmp/test-contacts.org") + (chime-org-contacts-capture-key "C") + (chime-org-contacts-heading "Contacts") + (org-capture-templates nil)) + + (chime-org-contacts--setup-capture-template) + + (let ((template (assoc "C" org-capture-templates))) + (should (string= (nth 1 template) "Contact (chime)")) + (should (eq (nth 2 template) 'entry)) + (should (equal (nth 3 template) '(file+headline chime-org-contacts-file "Contacts")))))) + +(ert-deftest test-chime-org-contacts-setup-uses-custom-key () + "Test that template uses custom capture key." + (let ((chime-org-contacts-file "/tmp/test-contacts.org") + (chime-org-contacts-capture-key "K") + (org-capture-templates nil)) + + (chime-org-contacts--setup-capture-template) + + (should (assoc "K" org-capture-templates)) + (should-not (assoc "C" org-capture-templates)))) + +(provide 'test-chime-org-contacts) +;;; test-chime-org-contacts.el ends here diff --git a/tests/test-chime-overdue-todos.el b/tests/test-chime-overdue-todos.el new file mode 100644 index 0000000..ad19d10 --- /dev/null +++ b/tests/test-chime-overdue-todos.el @@ -0,0 +1,403 @@ +;;; test-chime-overdue-todos.el --- Tests for overdue TODO functionality -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for overdue TODO functionality controlled by +;; `chime-show-any-overdue-with-day-wide-alerts'. +;; +;; When enabled (default t): Show overdue TODO items with day-wide alerts +;; When disabled (nil): Only show today's all-day events, not overdue items +;; +;; "Overdue" means events with timestamps in the past (before today). + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Test Helper Functions + +(defun test-overdue--create-event (title timestamp has-time) + "Create test event with TITLE and TIMESTAMP. +HAS-TIME determines if timestamp has time component." + (let* ((parsed-time (when has-time + (apply 'encode-time (org-parse-time-string timestamp)))) + (times (list (cons timestamp parsed-time)))) + `((title . ,title) + (times . ,times) + (intervals . (10))))) + +;;; Setup and Teardown + +(defun test-chime-overdue-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-overdue-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Tests for chime-event-has-any-passed-time + +(ert-deftest test-overdue-has-passed-time-yesterday-all-day () + "Test that all-day event from yesterday is recognized as passed. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: YESTERDAY (all-day) + +EXPECTED BEHAVIOR: + Should return t (yesterday is in the past) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (yesterday (test-time-yesterday-at 0 0)) + (yesterday-timestamp (test-timestamp-string yesterday t)) + (event (test-overdue--create-event + "Yesterday Event" + yesterday-timestamp + nil))) ; all-day event + (with-test-time now + (should (chime-event-has-any-passed-time event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-has-passed-time-today-all-day () + "Test that all-day event from today is recognized as passed. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: TODAY (all-day, no specific time) + +DAY-OF-WEEK REQUIREMENTS: + None - any day of week works + +SPECIAL PROPERTIES: + - All-day event: Yes (no time component) + - Timed event: No + - Repeating: No + - Range: No + +EXPECTED BEHAVIOR: + chime-event-has-any-passed-time should return t because the event + date (today) is not in the future. + +CURRENT IMPLEMENTATION (as of 2025-10-28): + Mock current-time: 2025-10-28 10:00 + Event timestamp: <2025-10-28 Tue> + +REFACTORING NOTES: + Simple case - just needs TODAY timestamp and TODAY current-time. + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (today-timestamp (test-timestamp-string now t)) + (event (test-overdue--create-event + "Today Event" + today-timestamp + nil))) ; all-day event + (with-test-time now + (should (chime-event-has-any-passed-time event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-has-passed-time-tomorrow-all-day () + "Test that all-day event from tomorrow is NOT recognized as passed. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: TOMORROW (all-day) + +EXPECTED BEHAVIOR: + Should return nil (tomorrow is in the future) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (tomorrow-timestamp (test-timestamp-string tomorrow t)) + (event (test-overdue--create-event + "Tomorrow Event" + tomorrow-timestamp + nil))) ; all-day event + (with-test-time now + (should-not (chime-event-has-any-passed-time event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-has-passed-time-timed-event-past () + "Test that timed event in the past is recognized as passed. + +TIME RELATIONSHIPS: + Current time: TODAY at 14:00 (2pm) + Event: TODAY at 09:00 (9am) - 5 hours ago + +EXPECTED BEHAVIOR: + Should return t (event time has passed) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (past-event (test-time-today-at 9 0)) + (past-timestamp (test-timestamp-string past-event)) + (event (test-overdue--create-event + "Past Meeting" + past-timestamp + t))) ; timed event + (with-test-time now + (should (chime-event-has-any-passed-time event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-has-passed-time-timed-event-future () + "Test that timed event in the future is NOT recognized as passed. + +TIME RELATIONSHIPS: + Current time: TODAY at 14:00 (2pm) + Event: TODAY at 16:00 (4pm) - 2 hours from now + +EXPECTED BEHAVIOR: + Should return nil (event time is in future) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (future-event (test-time-today-at 16 0)) + (future-timestamp (test-timestamp-string future-event)) + (event (test-overdue--create-event + "Future Meeting" + future-timestamp + t))) ; timed event + (with-test-time now + (should-not (chime-event-has-any-passed-time event)))) + (test-chime-overdue-teardown))) + +;;; Tests for chime-display-as-day-wide-event with overdue setting + +(ert-deftest test-overdue-display-yesterday-all-day-with-overdue-enabled () + "Test that yesterday's all-day event is displayed when overdue is enabled. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: YESTERDAY (all-day) + Setting: chime-show-any-overdue-with-day-wide-alerts = t + +EXPECTED BEHAVIOR: + Should display (overdue enabled shows past events) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (yesterday (test-time-yesterday-at 0 0)) + (yesterday-timestamp (test-timestamp-string yesterday t)) + (chime-show-any-overdue-with-day-wide-alerts t) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Yesterday Birthday" + yesterday-timestamp + nil))) + (with-test-time now + (should (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-yesterday-all-day-with-overdue-disabled () + "Test that yesterday's all-day event is NOT displayed when overdue is disabled. +This prevents showing old birthdays/holidays from the past. + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (yesterday (test-time-yesterday-at 0 0)) + (yesterday-timestamp (test-timestamp-string yesterday t)) + (chime-show-any-overdue-with-day-wide-alerts nil) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Yesterday Birthday" + yesterday-timestamp + nil))) + (with-test-time now + (should-not (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-yesterday-timed-with-overdue-enabled () + "Test that yesterday's timed event is displayed when overdue is enabled. + +TIME: TODAY 10am, Event: YESTERDAY 2pm, overdue=t +EXPECTED: Display (show past timed events when enabled) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (yesterday (test-time-yesterday-at 14 0)) + (yesterday-timestamp (test-timestamp-string yesterday)) + (chime-show-any-overdue-with-day-wide-alerts t) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Yesterday Meeting" + yesterday-timestamp + t))) + (with-test-time now + (should (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-yesterday-timed-with-overdue-disabled () + "Test that yesterday's timed event is NOT displayed when overdue is disabled. + +TIME: TODAY 10am, Event: YESTERDAY 2pm, overdue=nil +EXPECTED: Hide (don't show past timed events when disabled) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (yesterday (test-time-yesterday-at 14 0)) + (yesterday-timestamp (test-timestamp-string yesterday)) + (chime-show-any-overdue-with-day-wide-alerts nil) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Yesterday Meeting" + yesterday-timestamp + t))) + (with-test-time now + (should-not (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-today-all-day-always-shown () + "Test that today's all-day event is always displayed regardless of overdue setting. + +TIME: TODAY 10am, Event: TODAY (all-day), both overdue=t and =nil +EXPECTED: Always display (today's events shown regardless of setting) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (today-timestamp (test-timestamp-string now t)) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Today Birthday" + today-timestamp + nil))) + (with-test-time now + ;; Should show with overdue enabled + (let ((chime-show-any-overdue-with-day-wide-alerts t)) + (should (chime-display-as-day-wide-event event))) + ;; Should also show with overdue disabled (it's today, not overdue) + (let ((chime-show-any-overdue-with-day-wide-alerts nil)) + (should (chime-display-as-day-wide-event event))))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-week-old-all-day-with-overdue-enabled () + "Test that week-old all-day event is displayed when overdue is enabled. + +TIME: TODAY (Oct 28), Event: 7 DAYS AGO (Oct 21), overdue=t +EXPECTED: Display (show old events when enabled) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (week-ago (test-time-days-ago 7)) + (week-ago-timestamp (test-timestamp-string week-ago t)) + (chime-show-any-overdue-with-day-wide-alerts t) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Week Old Event" + week-ago-timestamp + nil))) + (with-test-time now + (should (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-week-old-all-day-with-overdue-disabled () + "Test that week-old all-day event is NOT displayed when overdue is disabled. +This prevents showing old birthdays/holidays from past weeks. + +TIME: TODAY (Oct 28), Event: 7 DAYS AGO (Oct 21), overdue=nil +EXPECTED: Hide (prevent old birthday spam) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (week-ago (test-time-days-ago 7)) + (week-ago-timestamp (test-timestamp-string week-ago t)) + (chime-show-any-overdue-with-day-wide-alerts nil) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Week Old Event" + week-ago-timestamp + nil))) + (with-test-time now + (should-not (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +;;; Tests verifying overdue doesn't affect future events + +(ert-deftest test-overdue-future-event-not-affected-by-overdue-setting () + "Test that future events are not affected by overdue setting. + +TIME: TODAY (Oct 28), Event: 2 DAYS FROM NOW (Oct 30), both overdue settings +EXPECTED: Never display (future events not shown without advance notice) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (future (test-time-days-from-now 2)) + (future-timestamp (test-timestamp-string future t)) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Future Event" + future-timestamp + nil))) + (with-test-time now + ;; Should NOT show with overdue enabled (it's future, not today) + (let ((chime-show-any-overdue-with-day-wide-alerts t)) + (should-not (chime-display-as-day-wide-event event))) + ;; Should NOT show with overdue disabled (it's future, not today) + (let ((chime-show-any-overdue-with-day-wide-alerts nil)) + (should-not (chime-display-as-day-wide-event event))))) + (test-chime-overdue-teardown))) + +(provide 'test-chime-overdue-todos) +;;; test-chime-overdue-todos.el ends here diff --git a/tests/test-chime-process-notifications.el b/tests/test-chime-process-notifications.el new file mode 100644 index 0000000..8f0a829 --- /dev/null +++ b/tests/test-chime-process-notifications.el @@ -0,0 +1,344 @@ +;;; test-chime-process-notifications.el --- Tests for chime--process-notifications -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--process-notifications function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-process-notifications-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-process-notifications-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-process-notifications-normal-single-event-calls-notify () + "Test that single event with notification calls chime--notify. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 (10 minutes from now) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (notify-called nil) + (notify-messages '())) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) + (setq notify-called t) + (push msg notify-messages))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting") + (intervals . ((10 . medium))))) + (events (list event))) + (chime--process-notifications events) + ;; Should call notify + (should notify-called) + (should (= 1 (length notify-messages))) + (should (string-match-p "Team Meeting" (caar notify-messages))))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-normal-multiple-events-calls-notify-multiple-times () + "Test that multiple events with notifications call chime--notify multiple times. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event 1 at 14:10 + (event-time-1 (test-time-today-at 14 10)) + (timestamp-str-1 (test-timestamp-string event-time-1)) + ;; Event 2 at 14:05 + (event-time-2 (test-time-today-at 14 5)) + (timestamp-str-2 (test-timestamp-string event-time-2)) + (notify-count 0)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-count (1+ notify-count)))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((event1 `((times . ((,timestamp-str-1 . ,event-time-1))) + (title . "Meeting 1") + (intervals . ((10 . medium))))) + (event2 `((times . ((,timestamp-str-2 . ,event-time-2))) + (title . "Meeting 2") + (intervals . ((5 . medium))))) + (events (list event1 event2))) + (chime--process-notifications events) + ;; Should call notify twice (once per event) + (should (= 2 notify-count)))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-normal-deduplication-removes-duplicates () + "Test that duplicate notification messages are deduplicated. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Two events with same title and time - should dedupe + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (notify-messages '())) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (push msg notify-messages))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((event1 `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting") + (intervals . ((10 . medium))))) + (event2 `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting") + (intervals . ((10 . medium))))) + (events (list event1 event2))) + (chime--process-notifications events) + ;; Should only call notify once due to deduplication + (should (= 1 (length notify-messages))))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-normal-day-wide-notifications-called-at-right-time () + "Test that day-wide notifications are sent when current time matches. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 9 0)) + ;; Day-wide event + (event-time (test-time-today-at 0 0)) + (timestamp-str (test-timestamp-string event-time t)) ; Day-wide + (notify-count 0)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-count (1+ notify-count)))) + ;; Mock day-wide time to return true + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () t)) + ((symbol-function 'chime-day-wide-notifications) + (lambda (events) (list "Day-wide alert")))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "All Day Event") + (intervals . ()))) + (events (list event))) + (chime--process-notifications events) + ;; Should call notify at least once for day-wide + (should (>= notify-count 1)))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-normal-no-day-wide-when-wrong-time () + "Test that day-wide notifications are not sent when time doesn't match. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 0 0)) + (timestamp-str (test-timestamp-string event-time t)) ; Day-wide + (day-wide-called nil)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) (lambda (msg) nil)) + ;; Mock day-wide time to return false + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil)) + ((symbol-function 'chime-day-wide-notifications) + (lambda (events) + (setq day-wide-called t) + '()))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "All Day Event") + (intervals . ()))) + (events (list event))) + (chime--process-notifications events) + ;; Day-wide function should not be called + (should-not day-wide-called))))) + (test-chime-process-notifications-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-process-notifications-boundary-empty-events-no-notifications () + "Test that empty events list produces no notifications. + +REFACTORED: No timestamps used" + (test-chime-process-notifications-setup) + (unwind-protect + (let ((notify-called nil)) + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-called t))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let ((events '())) + (chime--process-notifications events) + ;; Should not call notify + (should-not notify-called)))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-boundary-events-with-no-matches-no-notifications () + "Test that events with no matching notifications don't call notify. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 15:00 (60 minutes away, doesn't match 10 min interval) + (event-time (test-time-today-at 15 0)) + (timestamp-str (test-timestamp-string event-time)) + (notify-called nil)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-called t))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Future Event") + (intervals . ((10 . medium))))) + (events (list event))) + (chime--process-notifications events) + ;; Should not call notify + (should-not notify-called))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-boundary-single-event-edge-case () + "Test processing single event works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (notify-count 0)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-count (1+ notify-count)))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Single Event") + (intervals . ((10 . medium))))) + (events (list event))) + (chime--process-notifications events) + ;; Should call notify exactly once + (should (= 1 notify-count)))))) + (test-chime-process-notifications-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-process-notifications-error-nil-events-handles-gracefully () + "Test that nil events parameter doesn't crash. + +REFACTORED: No timestamps used" + (test-chime-process-notifications-setup) + (unwind-protect + (let ((notify-called nil)) + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-called t))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + ;; Should not error with nil events + (should-not (condition-case nil + (progn (chime--process-notifications nil) nil) + (error t))) + ;; Should not call notify + (should-not notify-called))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-error-invalid-event-structure-handles-gracefully () + "Test that invalid event structure doesn't crash. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (notify-called nil)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-called t))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* (;; Invalid event: missing required fields + (events (list '((invalid . "structure"))))) + ;; Should not crash even with invalid events + (should-not (condition-case nil + (progn (chime--process-notifications events) nil) + (error t))))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-error-mixed-valid-invalid-events-processes-valid () + "Test that mix of valid and invalid events processes valid ones. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Valid event + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (notify-count 0)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-count (1+ notify-count)))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((valid-event `((times . ((,timestamp-str . ,event-time))) + (title . "Valid Event") + (intervals . ((10 . medium))))) + ;; Invalid event + (invalid-event '((invalid . "data"))) + (events (list valid-event invalid-event))) + ;; Should not crash + (should-not (condition-case nil + (progn (chime--process-notifications events) nil) + (error t))) + ;; Should process at least the valid event + (should (>= notify-count 1)))))) + (test-chime-process-notifications-teardown))) + +(provide 'test-chime-process-notifications) +;;; test-chime-process-notifications.el ends here diff --git a/tests/test-chime-sanitize-title.el b/tests/test-chime-sanitize-title.el new file mode 100644 index 0000000..8329abd --- /dev/null +++ b/tests/test-chime-sanitize-title.el @@ -0,0 +1,402 @@ +;;; test-chime-sanitize-title.el --- Tests for chime--sanitize-title -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--sanitize-title function. +;; Tests cover: +;; - Unmatched opening delimiters (parentheses, brackets, braces) +;; - Unmatched closing delimiters +;; - Mixed unmatched delimiters +;; - Already balanced delimiters (no-op) +;; - Nil and empty strings +;; - Real-world bug cases that triggered the issue + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-sanitize-title-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-sanitize-title-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases - Already Balanced + +(ert-deftest test-chime-sanitize-title-balanced-parens-unchanged () + "Test that balanced parentheses are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting (Team Sync)") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-balanced-brackets-unchanged () + "Test that balanced brackets are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Review [PR #123]") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-balanced-braces-unchanged () + "Test that balanced braces are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Code Review {urgent}") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-mixed-balanced-unchanged () + "Test that mixed balanced delimiters are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting [Team] (Sync) {Urgent}") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-nested-balanced-unchanged () + "Test that nested balanced delimiters are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Review (PR [#123] {urgent})") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-no-delimiters-unchanged () + "Test that titles without delimiters are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Simple Meeting Title") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +;;; Unmatched Opening Delimiters + +(ert-deftest test-chime-sanitize-title-unmatched-opening-paren () + "Test that unmatched opening parenthesis is closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "CTO/COO XLT (Extended Leadership") + (result (chime--sanitize-title title))) + (should (string-equal "CTO/COO XLT (Extended Leadership)" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-unmatched-opening-paren-at-end () + "Test that unmatched opening parenthesis at end is closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Spice Cake (") + (result (chime--sanitize-title title))) + (should (string-equal "Spice Cake ()" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-multiple-unmatched-opening-parens () + "Test that multiple unmatched opening parentheses are closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting (Team (Sync") + (result (chime--sanitize-title title))) + (should (string-equal "Meeting (Team (Sync))" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-unmatched-opening-bracket () + "Test that unmatched opening bracket is closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Review [PR #123") + (result (chime--sanitize-title title))) + (should (string-equal "Review [PR #123]" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-unmatched-opening-brace () + "Test that unmatched opening brace is closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Code Review {urgent") + (result (chime--sanitize-title title))) + (should (string-equal "Code Review {urgent}" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-mixed-unmatched-opening-delimiters () + "Test that mixed unmatched opening delimiters are all closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting [Team (Sync {Urgent") + (result (chime--sanitize-title title))) + (should (string-equal "Meeting [Team (Sync {Urgent})]" result))) + (test-chime-sanitize-title-teardown))) + +;;; Unmatched Closing Delimiters + +(ert-deftest test-chime-sanitize-title-unmatched-closing-paren () + "Test that unmatched closing parenthesis is removed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting Title)") + (result (chime--sanitize-title title))) + (should (string-equal "Meeting Title" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-multiple-unmatched-closing-parens () + "Test that multiple unmatched closing parentheses are removed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting Title))") + (result (chime--sanitize-title title))) + (should (string-equal "Meeting Title" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-unmatched-closing-bracket () + "Test that unmatched closing bracket is removed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Review PR]") + (result (chime--sanitize-title title))) + (should (string-equal "Review PR" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-unmatched-closing-brace () + "Test that unmatched closing brace is removed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Code Review}") + (result (chime--sanitize-title title))) + (should (string-equal "Code Review" result))) + (test-chime-sanitize-title-teardown))) + +;;; Complex Mixed Cases + +(ert-deftest test-chime-sanitize-title-opening-and-closing-mixed () + "Test title with both unmatched opening and closing delimiters." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting (Team) Extra)") + (result (chime--sanitize-title title))) + ;; Should remove the extra closing paren + (should (string-equal "Meeting (Team) Extra" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-complex-nesting-with-unmatched () + "Test complex nested delimiters with some unmatched." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting [Team (Sync] Extra") + (result (chime--sanitize-title title))) + ;; The ']' doesn't match the '[' (because '(' is in between) + ;; So it's removed, and we close the '(' and '[' properly: ')' and ']' + (should (string-equal "Meeting [Team (Sync Extra)]" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-all-types-unmatched () + "Test with all three delimiter types unmatched." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting (Team [Project {Status") + (result (chime--sanitize-title title))) + (should (string-equal "Meeting (Team [Project {Status}])" result))) + (test-chime-sanitize-title-teardown))) + +;;; Edge Cases + +(ert-deftest test-chime-sanitize-title-nil-returns-empty-string () + "Test that nil title returns empty string." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((result (chime--sanitize-title nil))) + (should (string-equal "" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-empty-string-unchanged () + "Test that empty string is unchanged." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "") + (result (chime--sanitize-title title))) + (should (string-equal "" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-only-opening-delimiters () + "Test title with only opening delimiters." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "([{") + (result (chime--sanitize-title title))) + (should (string-equal "([{}])" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-only-closing-delimiters () + "Test title with only closing delimiters." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title ")]}") + (result (chime--sanitize-title title))) + (should (string-equal "" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-very-long-title-with-unmatched () + "Test very long title with unmatched delimiter." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "This is a very long meeting title that contains many words and might wrap in the notification display (Extended Info") + (result (chime--sanitize-title title))) + (should (string-equal "This is a very long meeting title that contains many words and might wrap in the notification display (Extended Info)" result))) + (test-chime-sanitize-title-teardown))) + +;;; Real-World Bug Cases + +(ert-deftest test-chime-sanitize-title-bug-case-extended-leadership () + "Test the actual bug case from vineti.meetings.org." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "1:01pm CTO/COO XLT (Extended Leadership") + (result (chime--sanitize-title title))) + (should (string-equal "1:01pm CTO/COO XLT (Extended Leadership)" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-bug-case-spice-cake () + "Test the actual bug case from journal/2023-11-22.org." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Spice Cake (") + (result (chime--sanitize-title title))) + (should (string-equal "Spice Cake ()" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-lisp-serialization-safety () + "Test that sanitized title can be safely read by Lisp reader." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting (Team Sync") + (sanitized (chime--sanitize-title title)) + ;; Simulate what happens in async serialization + (serialized (format "'((title . \"%s\"))" sanitized))) + ;; This should not signal an error + (should (listp (read serialized))) + (should (string-equal "Meeting (Team Sync)" sanitized))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-async-serialization-with-unmatched-parens () + "Test that titles with unmatched parens won't break async serialization." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((problematic-titles '("Meeting (Team" + "Review [PR" + "Code {Status" + "Event ((" + "Task ))"))) + (dolist (title problematic-titles) + (let* ((sanitized (chime--sanitize-title title)) + (serialized (format "'((title . \"%s\"))" sanitized))) + ;; Should not signal 'invalid-read-syntax error + (should (listp (read serialized)))))) + (test-chime-sanitize-title-teardown))) + +;;; Integration with chime--extract-title + +(ert-deftest test-chime-extract-title-sanitizes-output () + "Test that chime--extract-title applies sanitization. + +REFACTORED: Uses dynamic timestamps" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting (Team Sync\n%s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) ; Enable org-mode + (goto-char (point-min)) + ;; Search for the heading + (re-search-forward "^\\* TODO" nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (title (chime--extract-title marker))) + ;; Should be sanitized with closing paren added + (should (string-equal "Meeting (Team Sync)" title)))) + (kill-buffer test-buffer)) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-extract-title-handles-nil () + "Test that chime--extract-title handles nil gracefully. + +REFACTORED: Uses dynamic timestamps" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO\n%s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) ; Enable org-mode + (goto-char (point-min)) + ;; Search for the heading + (re-search-forward "^\\* TODO" nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (title (chime--extract-title marker))) + ;; Should return empty string for nil title + (should (string-equal "" title)))) + (kill-buffer test-buffer)) + (test-chime-sanitize-title-teardown))) + +(provide 'test-chime-sanitize-title) +;;; test-chime-sanitize-title.el ends here diff --git a/tests/test-chime-time-left.el b/tests/test-chime-time-left.el new file mode 100644 index 0000000..565ec4b --- /dev/null +++ b/tests/test-chime-time-left.el @@ -0,0 +1,305 @@ +;;; test-chime-time-left.el --- Tests for chime--time-left -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--time-left function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-time-left-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset format strings to defaults + (setq chime-time-left-format-at-event "right now") + (setq chime-time-left-format-short "in %M") + (setq chime-time-left-format-long "in %H %M")) + +(defun test-chime-time-left-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-time-left-one-minute-formats-correctly () + "Test that 1 minute formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 60))) + (should (stringp result)) + (should (string-match-p "in 1 minute" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-five-minutes-formats-correctly () + "Test that 5 minutes formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 300))) + (should (stringp result)) + (should (string-match-p "in 5 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-ten-minutes-formats-correctly () + "Test that 10 minutes formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 600))) + (should (stringp result)) + (should (string-match-p "in 10 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-thirty-minutes-formats-correctly () + "Test that 30 minutes formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 1800))) + (should (stringp result)) + (should (string-match-p "in 30 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-one-hour-formats-correctly () + "Test that 1 hour formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 3600))) + (should (stringp result)) + ;; At exactly 1 hour (3600s), still shows minutes format + (should (string-match-p "in 60 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-two-hours-formats-correctly () + "Test that 2 hours formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 7200))) + (should (stringp result)) + (should (string-match-p "in 2 hours" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-one-hour-thirty-minutes-formats-correctly () + "Test that 1 hour 30 minutes formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 5400))) + (should (stringp result)) + ;; Should show both hours and minutes + (should (string-match-p "in 1 hour 30 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-three-hours-fifteen-minutes-formats-correctly () + "Test that 3 hours 15 minutes formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 11700))) + (should (stringp result)) + (should (string-match-p "in 3 hours 15 minutes" result))) + (test-chime-time-left-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-time-left-zero-seconds-returns-right-now () + "Test that 0 seconds returns 'right now'." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 0))) + (should (stringp result)) + (should (string-equal "right now" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-one-second-shows-right-now () + "Test that 1 second shows 'right now'." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 1))) + (should (stringp result)) + ;; Less than a minute, but format-seconds might show "in 0 minutes" + ;; or the implementation might handle this specially + (should result)) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-fifty-nine-seconds-shows-minutes () + "Test that 59 seconds shows in minutes format." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 59))) + (should (stringp result)) + ;; Should use minutes format (< 1 hour) + (should (string-match-p "in" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-exactly-one-hour-shows-minutes-format () + "Test that exactly 1 hour shows minutes format." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 3600))) + (should (stringp result)) + ;; At exactly 3600s, still uses minutes format (boundary case) + (should (string-match-p "in 60 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-fifty-nine-minutes-shows-minutes-only () + "Test that 59 minutes shows minutes format only." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 3540))) ; 59 minutes + (should (stringp result)) + (should (string-match-p "in 59 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-twenty-four-hours-formats-correctly () + "Test that 24 hours formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 86400))) + (should (stringp result)) + (should (string-match-p "in 24 hours" result))) + (test-chime-time-left-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-time-left-negative-value-returns-right-now () + "Test that negative value returns 'right now'." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left -60))) + (should (stringp result)) + (should (string-equal "right now" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-large-negative-returns-right-now () + "Test that large negative value returns 'right now'." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left -3600))) + (should (stringp result)) + (should (string-equal "right now" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-very-large-value-formats-correctly () + "Test that very large value (1 week) formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 604800))) ; 1 week + (should (stringp result)) + ;; Should format with days/hours + (should (string-match-p "in" result))) + (test-chime-time-left-teardown))) + +;;; Custom Format Cases + +(ert-deftest test-chime-time-left-custom-compact-format-short () + "Test custom compact format for short duration (in 5m)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-short "in %mm") + (let ((result (chime--time-left 300))) ; 5 minutes + (should (stringp result)) + (should (string-equal "in 5m" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-compact-format-long () + "Test custom compact format for long duration (in 1h 37m)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-long "in %hh %mm") + (let ((result (chime--time-left 5820))) ; 1 hour 37 minutes + (should (stringp result)) + (should (string-equal "in 1h 37m" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-parentheses-format () + "Test custom format with parentheses ((1 hr 37 min))." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-long "(%h hr %m min)") + (let ((result (chime--time-left 5820))) ; 1 hour 37 minutes + (should (stringp result)) + (should (string-equal "(1 hr 37 min)" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-no-prefix-format () + "Test custom format without 'in' prefix (1h37m)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-long "%hh%mm") + (let ((result (chime--time-left 5820))) ; 1 hour 37 minutes + (should (stringp result)) + (should (string-equal "1h37m" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-at-event-message () + "Test custom at-event message (NOW!)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-at-event "NOW!") + (let ((result (chime--time-left 0))) + (should (stringp result)) + (should (string-equal "NOW!" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-short-with-unit-text () + "Test custom short format with custom unit text (5 min)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-short "%m min") + (let ((result (chime--time-left 300))) ; 5 minutes + (should (stringp result)) + (should (string-equal "5 min" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-emoji-format () + "Test custom format with emoji (🕐 1h37m)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-long "🕐 %hh%mm") + (let ((result (chime--time-left 5820))) ; 1 hour 37 minutes + (should (stringp result)) + (should (string-equal "🕐 1h37m" result)))) + (test-chime-time-left-teardown))) + +(provide 'test-chime-time-left) +;;; test-chime-time-left.el ends here diff --git a/tests/test-chime-timestamp-parse.el b/tests/test-chime-timestamp-parse.el new file mode 100644 index 0000000..32cdace --- /dev/null +++ b/tests/test-chime-timestamp-parse.el @@ -0,0 +1,413 @@ +;;; test-chime-timestamp-parse.el --- Tests for chime--timestamp-parse -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--timestamp-parse function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-timestamp-parse-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-timestamp-parse-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-timestamp-parse-standard-timestamp-returns-time-list () + "Test that a standard timestamp with time component returns a time list. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + ;; Should return a time list (list of integers) + (should (listp result)) + (should (= (length result) 2)) + (should (integerp (car result))) + (should (integerp (cadr result))) + ;; Result should not be nil + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-scheduled-timestamp-returns-time-list () + "Test that a SCHEDULED timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 9 0)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-deadline-timestamp-returns-time-list () + "Test that a DEADLINE timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 17 0)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-timestamp-with-weekly-repeater-returns-time-list () + "Test that a timestamp with +1w repeater parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M +1w>" time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-timestamp-with-completion-repeater-returns-time-list () + "Test that a timestamp with .+1d repeater parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 8 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M .+1d>" time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-timestamp-with-catchup-repeater-returns-time-list () + "Test that a timestamp with ++1w repeater parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 10 30)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M ++1w>" time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-timestamp-with-time-range-returns-start-time () + "Test that a timestamp with time range returns the start time. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M-15:30>" time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-timestamp-with-date-range-returns-start-date () + "Test that a timestamp with date range returns start date time. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time1 (test-time-tomorrow-at 10 0)) + (time2 (test-time-days-from-now 2)) + (timestamp (concat (test-timestamp-string time1) "--" + (format-time-string "<%Y-%m-%d %a %H:%M>" time2))) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-timestamp-parse-midnight-timestamp-returns-time-list () + "Test that midnight (00:00) timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-last-minute-of-day-returns-time-list () + "Test that last minute of day (23:59) timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 23 59)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-year-boundary-new-years-eve-returns-time-list () + "Test that New Year's Eve timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((now (test-time-now)) + (decoded (decode-time now)) + (year (nth 5 decoded)) + ;; Create Dec 31 at 23:30 for current test year + (time (encode-time 0 30 23 31 12 year)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-year-boundary-new-years-day-returns-time-list () + "Test that New Year's Day timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((now (test-time-now)) + (decoded (decode-time now)) + (year (1+ (nth 5 decoded))) ; Next year + ;; Create Jan 1 at 00:30 for next test year + (time (encode-time 0 30 0 1 1 year)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-single-digit-time-returns-time-list () + "Test that single-digit hours and minutes parse correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 1 5)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-leap-year-feb-29-returns-time-list () + "Test that Feb 29 in leap year parses correctly. + +REFACTORED: Uses dynamic timestamps (2024 leap year)" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* (;; Use 2024 as a known leap year + (time (encode-time 0 0 14 29 2 2024)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-month-boundary-end-of-month-returns-time-list () + "Test that end of month timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps (Oct 31)" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((now (test-time-now)) + (decoded (decode-time now)) + (year (nth 5 decoded)) + ;; Create Oct 31 at 14:00 for current test year + (time (encode-time 0 0 14 31 10 year)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +;;; Bug Reproduction Tests + +(ert-deftest test-chime-timestamp-parse-tomorrow-timestamp-returns-correct-date () + "Test that a tomorrow timestamp is parsed as tomorrow, not today. +This reproduces the bug where timestamps like <2025-11-03 Mon 10:00-10:30> +on Nov 02 are incorrectly grouped as 'Today' instead of 'Tomorrow'." + (test-chime-timestamp-parse-setup) + (unwind-protect + (with-test-time (encode-time 0 23 11 2 11 2025) ; Nov 02, 2025 11:23:00 AM + (let* ((tomorrow-timestamp "<2025-11-03 Mon 10:00-10:30>") + (parsed (chime--timestamp-parse tomorrow-timestamp)) + (now (current-time))) + ;; Should parse successfully + (should parsed) + ;; Convert parsed time (HIGH LOW) to full time by appending (0 0) + (let* ((parsed-time (append parsed '(0 0))) + (parsed-decoded (decode-time parsed-time)) + (time-diff-seconds (- (time-to-seconds parsed-time) + (time-to-seconds now)))) + ;; Verify the parsed date is Nov 03, 2025 (not Nov 02!) + (should (= 3 (decoded-time-day parsed-decoded))) + (should (= 11 (decoded-time-month parsed-decoded))) + (should (= 2025 (decoded-time-year parsed-decoded))) + ;; Verify the parsed time is 10:00 + (should (= 10 (decoded-time-hour parsed-decoded))) + (should (= 0 (decoded-time-minute parsed-decoded))) + ;; Time difference should be ~22h 37m (81420 seconds) + (should (> time-diff-seconds 81360)) ; At least 22h 36m + (should (< time-diff-seconds 81480))))) ; At most 22h 38m + (test-chime-timestamp-parse-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-timestamp-parse-empty-string-returns-nil () + "Test that empty string returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-nil-input-returns-nil () + "Test that nil input returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp nil) + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-missing-opening-bracket-returns-nil () + "Test that timestamp missing opening bracket returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "2025-10-24 Fri 14:00>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-missing-closing-bracket-returns-nil () + "Test that timestamp missing closing bracket returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-10-24 Fri 14:00") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-invalid-date-format-returns-nil () + "Test that invalid date format returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<10-24-2025 Fri 14:00>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-invalid-month-returns-nil () + "Test that invalid month value returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-13-24 Fri 14:00>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-invalid-day-returns-nil () + "Test that invalid day value returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-10-32 Fri 14:00>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-invalid-time-hour-returns-nil () + "Test that invalid hour value returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-10-24 Fri 25:00>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-invalid-time-minute-returns-nil () + "Test that invalid minute value returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-10-24 Fri 14:60>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-date-only-no-time-returns-nil () + "Test that day-wide timestamp without time returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-10-24 Fri>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(provide 'test-chime-timestamp-parse) +;;; test-chime-timestamp-parse.el ends here diff --git a/tests/test-chime-timestamp-within-interval-p.el b/tests/test-chime-timestamp-within-interval-p.el new file mode 100644 index 0000000..bf67dc2 --- /dev/null +++ b/tests/test-chime-timestamp-within-interval-p.el @@ -0,0 +1,325 @@ +;;; test-chime-timestamp-within-interval-p.el --- Tests for chime--timestamp-within-interval-p -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--timestamp-within-interval-p function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-timestamp-within-interval-p-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-timestamp-within-interval-p-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-timestamp-within-interval-p-exactly-at-interval-returns-t () + "Test that timestamp exactly at interval returns t. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:10 (10 minutes from 14:00) + (timestamp (test-time-today-at 14 10)) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-zero-interval-returns-t () + "Test that zero interval (notify now) returns t for current time. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 30)) + ;; Timestamp at exactly current time (14:30) + (timestamp (test-time-today-at 14 30)) + (interval 0)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-five-minutes-returns-t () + "Test that 5-minute interval works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 25)) + ;; Timestamp at 14:30 (5 minutes from 14:25) + (timestamp (test-time-today-at 14 30)) + (interval 5)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-sixty-minutes-returns-t () + "Test that 60-minute (1 hour) interval works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 15:00 (60 minutes from 14:00) + (timestamp (test-time-today-at 15 0)) + (interval 60)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-large-interval-returns-t () + "Test that large interval (1 day = 1440 minutes) works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:00 next day (1440 minutes from now) + ;; Add 86400 seconds (1440 minutes = 1 day) to now + ;; Convert to list format for compatibility + (timestamp (apply #'encode-time (decode-time (time-add now (seconds-to-time 86400))))) + (interval 1440)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-thirty-minutes-returns-t () + "Test that 30-minute interval works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 15)) + ;; Timestamp at 14:45 (30 minutes from 14:15) + (timestamp (test-time-today-at 14 45)) + (interval 30)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-timestamp-within-interval-p-one-minute-before-returns-nil () + "Test that timestamp 1 minute before interval returns nil. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:09 (9 minutes from 14:00, not 10) + (timestamp (test-time-today-at 14 9)) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should-not result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-one-minute-after-returns-nil () + "Test that timestamp 1 minute after interval returns nil. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:11 (11 minutes from 14:00, not 10) + (timestamp (test-time-today-at 14 11)) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should-not result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-crossing-midnight-returns-t () + "Test that interval crossing midnight works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 23 50)) + ;; Timestamp at 00:00 next day (10 minutes from 23:50) + ;; Add 600 seconds (10 minutes) to 23:50 to get 00:00 next day + ;; Convert to list format for compatibility + (timestamp (apply #'encode-time (decode-time (time-add now (seconds-to-time 600))))) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-crossing-day-boundary-returns-t () + "Test that interval crossing to next day works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 23 30)) + ;; Timestamp at 00:30 next day (60 minutes from 23:30) + ;; Add 3600 seconds (60 minutes) to 23:30 to get 00:30 next day + ;; Convert to list format for compatibility + (timestamp (apply #'encode-time (decode-time (time-add now (seconds-to-time 3600))))) + (interval 60)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-week-interval-returns-t () + "Test that very large interval (1 week = 10080 minutes) works. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:00 one week later (10080 minutes = 7 days from now) + ;; Add 604800 seconds (10080 minutes = 7 days) to now + ;; Convert to list format for compatibility + (timestamp (apply #'encode-time (decode-time (time-add now (seconds-to-time 604800))))) + (interval 10080)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-at-midnight-returns-t () + "Test that timestamp at exact midnight works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 23 50)) + ;; Timestamp at midnight (10 minutes from 23:50) + ;; Add 600 seconds (10 minutes) to 23:50 to get 00:00 next day + ;; Convert to list format for compatibility + (timestamp (apply #'encode-time (decode-time (time-add now (seconds-to-time 600))))) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-timestamp-within-interval-p-nil-timestamp-returns-nil () + "Test that nil timestamp returns nil. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (timestamp nil) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should-not result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-nil-interval-returns-nil () + "Test that nil interval returns nil. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (timestamp (test-time-today-at 14 10)) + (interval nil)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should-not result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-negative-interval-returns-nil () + "Test that negative interval returns nil (past timestamps). + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp 10 minutes in the past (13:50) + (timestamp (test-time-today-at 13 50)) + (interval -10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-invalid-timestamp-returns-nil () + "Test that invalid timestamp format returns nil. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (timestamp "not-a-timestamp") + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should-not result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-float-interval-works () + "Test that float interval gets converted properly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:10 (10 minutes from 14:00) + (timestamp (test-time-today-at 14 10)) + (interval 10.5)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(provide 'test-chime-timestamp-within-interval-p) +;;; test-chime-timestamp-within-interval-p.el ends here diff --git a/tests/test-chime-tooltip-bugs.el b/tests/test-chime-tooltip-bugs.el new file mode 100644 index 0000000..dce2fa5 --- /dev/null +++ b/tests/test-chime-tooltip-bugs.el @@ -0,0 +1,392 @@ +;;; test-chime-tooltip-bugs.el --- Tests for tooltip bugs -*- lexical-binding: t; -*- + +;; Tests for reported issues: +;; 1. Duplicate "in in" in countdown text +;; 2. Duplicate events in tooltip +;; 3. Missing future events + +;;; Code: + +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) +(load (expand-file-name "../chime.el") nil t) +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-tooltip-bugs-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + (setq test-tooltip-bugs--orig-lookahead chime-modeline-lookahead-minutes) + (setq test-tooltip-bugs--orig-tooltip-lookahead chime-tooltip-lookahead-hours) + (setq chime-modeline-lookahead-minutes 1440) + (setq chime-tooltip-lookahead-hours 8760)) ; 1 year + +(defun test-tooltip-bugs-teardown () + "Teardown function run after each test." + (setq chime-modeline-lookahead-minutes test-tooltip-bugs--orig-lookahead) + (setq chime-tooltip-lookahead-hours test-tooltip-bugs--orig-tooltip-lookahead) + (chime-delete-test-base-dir)) + +(defvar test-tooltip-bugs--orig-lookahead nil) +(defvar test-tooltip-bugs--orig-tooltip-lookahead nil) + +;;; Helper functions + +(defun test-tooltip-bugs--create-gcal-event (title time-str) + "Create test org content for a gcal event." + (concat + (format "* %s\n" title) + ":PROPERTIES:\n" + ":entry-id: test@google.com\n" + ":END:\n" + ":org-gcal:\n" + (format "%s\n" time-str) + ":END:\n")) + +(defun test-tooltip-bugs--gather-events (content) + "Process CONTENT and return events list." + (let* ((test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + (events nil)) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + (push info events)) + (forward-line 1))) + (kill-buffer test-buffer) + (nreverse events))) + +(defun test-tooltip-bugs--count-in-string (regexp string) + "Count occurrences of REGEXP in STRING." + (let ((count 0) + (start 0)) + (while (string-match regexp string start) + (setq count (1+ count)) + (setq start (match-end 0))) + count)) + +;;; Test 1: No duplicate "in" in countdown text + +(ert-deftest test-tooltip-no-duplicate-in () + "Test that tooltip doesn't have duplicate 'in in' in countdown. + +Issue: Tooltip showed '(in in 1h 4m)' instead of '(in 1h 4m)'." + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (test-time-now)) + (event-time (time-add now (seconds-to-time (* 90 60)))) ; 90 min from now + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (content (test-tooltip-bugs--create-gcal-event "Test Event" time-str)) + (events (test-tooltip-bugs--gather-events content))) + + (with-test-time now + (chime--update-modeline events)) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should NOT have "in in" + (should-not (string-match-p "in in" tooltip)) + + ;; Should have single "in" (from format string) + (should (string-match-p "(in " tooltip)))) + (test-tooltip-bugs-teardown))) + +;;; Test 2: No duplicate events in tooltip + +(ert-deftest test-tooltip-no-duplicate-events () + "Test that same event doesn't appear multiple times in tooltip. + +Issue: Events appeared multiple times in tooltip." + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (test-time-now)) + (event1-time (time-add now (seconds-to-time (* 60 60)))) ; 1 hour + (event1-str (format-time-string "<%Y-%m-%d %a %H:%M>" event1-time)) + (event2-time (time-add now (seconds-to-time (* 120 60)))) ; 2 hours + (event2-str (format-time-string "<%Y-%m-%d %a %H:%M>" event2-time)) + (content (concat + (test-tooltip-bugs--create-gcal-event "Task 1" event1-str) + (test-tooltip-bugs--create-gcal-event "Task 2" event2-str))) + (events (test-tooltip-bugs--gather-events content))) + + (with-test-time now + (chime--update-modeline events)) + + ;; Should have exactly 2 events + (should (= 2 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Each event should appear exactly once + (should (= 1 (test-tooltip-bugs--count-in-string "Task 1" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Task 2" tooltip))))) + (test-tooltip-bugs-teardown))) + +;;; Test 3: All future events included (not just first few in file) + +(ert-deftest test-tooltip-includes-all-future-events () + "Test that tooltip includes all future events, not just first N in file. + +Issue: Events later in file were being ignored. +This tests that chime doesn't assume events are in chronological order in file." + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Create events in NON-chronological order in file + ;; Far event first, then near events + (event-far-time (time-add now (seconds-to-time (* 48 3600)))) ; 2 days + (event-far-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-far-time)) + (event1-time (time-add now (seconds-to-time (* 60 60)))) ; 1 hour + (event1-str (format-time-string "<%Y-%m-%d %a %H:%M>" event1-time)) + (event2-time (time-add now (seconds-to-time (* 90 60)))) ; 1.5 hours + (event2-str (format-time-string "<%Y-%m-%d %a %H:%M>" event2-time)) + (content (concat + ;; Far event appears FIRST in file + (test-tooltip-bugs--create-gcal-event "Task Far" event-far-str) + ;; Near events appear AFTER in file + (test-tooltip-bugs--create-gcal-event "Task 1" event1-str) + (test-tooltip-bugs--create-gcal-event "Task 2" event2-str))) + (events (test-tooltip-bugs--gather-events content))) + + ;; Mock time to prevent timing-related flakiness + (with-test-time now + (chime--update-modeline events)) + + ;; Should have all 3 events + (should (= 3 (length chime--upcoming-events))) + + ;; Events should be in chronological order (not file order) + (let* ((item1 (nth 0 chime--upcoming-events)) + (item2 (nth 1 chime--upcoming-events)) + (item3 (nth 2 chime--upcoming-events)) + (title1 (cdr (assoc 'title (car item1)))) + (title2 (cdr (assoc 'title (car item2)))) + (title3 (cdr (assoc 'title (car item3))))) + ;; Should be sorted chronologically + (should (string= title1 "Task 1")) + (should (string= title2 "Task 2")) + (should (string= title3 "Task Far"))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; All events should appear in tooltip + (should (string-match-p "Task 1" tooltip)) + (should (string-match-p "Task 2" tooltip)) + (should (string-match-p "Task Far" tooltip)) + + ;; Chronological order: Task 1 before Task 2 before Task Far + (let ((pos1 (string-match "Task 1" tooltip)) + (pos2 (string-match "Task 2" tooltip)) + (pos-far (string-match "Task Far" tooltip))) + (should (< pos1 pos2)) + (should (< pos2 pos-far))))) + (test-tooltip-bugs-teardown))) + +;;; Test 4: Exact replication of reported duplicate event issue + +(ert-deftest test-tooltip-exact-duplicate-bug () + "Replicate exact bug: Task 2 appearing twice, Task 3 appearing twice. + +From user report: +Task 1 at 9:00 PM (in 1h 4m) +Task 2 at 10:00 PM (in 2h 4m) <-- appears +Task 3 at 10:00 PM (in 2h 4m) <-- appears +Task 2 at 10:00 PM (in 2h 4m) <-- DUPLICATE +Task 4 at 01:00 PM (in 17h 4m)" + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (current-time)) + ;; Create exactly the scenario from the report + (task1-time (time-add now (seconds-to-time (* 64 60)))) ; ~1h 4m + (task1-str (format-time-string "<%Y-%m-%d %a %H:%M>" task1-time)) + (task2-time (time-add now (seconds-to-time (* 124 60)))) ; ~2h 4m + (task2-str (format-time-string "<%Y-%m-%d %a %H:%M>" task2-time)) + (task3-time (time-add now (seconds-to-time (* 124 60)))) ; same time as task2 + (task3-str (format-time-string "<%Y-%m-%d %a %H:%M>" task3-time)) + (task4-time (time-add now (seconds-to-time (* 1024 60)))) ; ~17h 4m + (task4-str (format-time-string "<%Y-%m-%d %a %H:%M>" task4-time)) + (content (concat + (test-tooltip-bugs--create-gcal-event "Task 1" task1-str) + (test-tooltip-bugs--create-gcal-event "Task 2" task2-str) + (test-tooltip-bugs--create-gcal-event "Task 3" task3-str) + (test-tooltip-bugs--create-gcal-event "Task 4" task4-str))) + (events (test-tooltip-bugs--gather-events content))) + + (chime--update-modeline events) + + ;; Should have exactly 4 events + (should (= 4 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Each event should appear exactly once (no duplicates) + (should (= 1 (test-tooltip-bugs--count-in-string "Task 1" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Task 2" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Task 3" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Task 4" tooltip))) + + ;; Verify chronological order + (let ((pos1 (string-match "Task 1" tooltip)) + (pos2 (string-match "Task 2" tooltip)) + (pos3 (string-match "Task 3" tooltip)) + (pos4 (string-match "Task 4" tooltip))) + (should (< pos1 pos2)) + (should (< pos2 pos4))))) + (test-tooltip-bugs-teardown))) + +;;; Test 5: Missing events that appear later in file + +(ert-deftest test-tooltip-missing-later-events () + "Replicate exact bug: Tasks 5, 6, 7 missing even though they're in future. + +From user report: 'Earlier in the same org-gcal file, I have Tasks 5, 6, 7 +and many more in the future.' + +This tests the concern: 'I worry chime is somehow assuming events and dates +are always listed in chronological order.'" + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (current-time)) + ;; Create scenario where early events appear AFTER later events in file + ;; This mimics how org-gcal might organize events + (task5-time (time-add now (seconds-to-time (* 30 3600)))) ; 30 hours (tomorrow) + (task5-str (format-time-string "<%Y-%m-%d %a %H:%M>" task5-time)) + (task6-time (time-add now (seconds-to-time (* 48 3600)))) ; 48 hours (2 days) + (task6-str (format-time-string "<%Y-%m-%d %a %H:%M>" task6-time)) + (task7-time (time-add now (seconds-to-time (* 72 3600)))) ; 72 hours (3 days) + (task7-str (format-time-string "<%Y-%m-%d %a %H:%M>" task7-time)) + (task1-time (time-add now (seconds-to-time (* 2 3600)))) ; 2 hours (soon!) + (task1-str (format-time-string "<%Y-%m-%d %a %H:%M>" task1-time)) + ;; Put far events FIRST in file, near event LAST + (content (concat + (test-tooltip-bugs--create-gcal-event "Task 5" task5-str) + (test-tooltip-bugs--create-gcal-event "Task 6" task6-str) + (test-tooltip-bugs--create-gcal-event "Task 7" task7-str) + (test-tooltip-bugs--create-gcal-event "Task 1" task1-str))) + (events (test-tooltip-bugs--gather-events content))) + + (chime--update-modeline events) + + ;; Should have all 4 events + (should (= 4 (length chime--upcoming-events))) + + ;; Events should be sorted chronologically (not file order) + (let* ((item1 (nth 0 chime--upcoming-events)) + (item2 (nth 1 chime--upcoming-events)) + (item3 (nth 2 chime--upcoming-events)) + (item4 (nth 3 chime--upcoming-events)) + (title1 (cdr (assoc 'title (car item1)))) + (title2 (cdr (assoc 'title (car item2)))) + (title3 (cdr (assoc 'title (car item3)))) + (title4 (cdr (assoc 'title (car item4))))) + (should (string= title1 "Task 1")) ; soonest + (should (string= title2 "Task 5")) + (should (string= title3 "Task 6")) + (should (string= title4 "Task 7"))) ; furthest + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; ALL events should be present + (should (string-match-p "Task 1" tooltip)) + (should (string-match-p "Task 5" tooltip)) + (should (string-match-p "Task 6" tooltip)) + (should (string-match-p "Task 7" tooltip)) + + ;; Verify chronological order in tooltip + (let ((pos1 (string-match "Task 1" tooltip)) + (pos5 (string-match "Task 5" tooltip)) + (pos6 (string-match "Task 6" tooltip)) + (pos7 (string-match "Task 7" tooltip))) + (should (< pos1 pos5)) + (should (< pos5 pos6)) + (should (< pos6 pos7))))) + (test-tooltip-bugs-teardown))) + +;;; Test 6: Only 5 events shown when many more exist + +(ert-deftest test-tooltip-only-first-5-shown () + "Replicate bug: Only 5 events shown in tooltip despite having many more. + +From user report: Tooltip showed 5 events, then '...and that's all' even +though there were many more future events in the file. + +This might be due to default max-events=5." + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (test-time-now)) + (content "") + (events nil)) + + ;; Create 10 events + (dotimes (i 10) + (let* ((hours-offset (+ 1 i)) ; 1, 2, 3... 10 hours + (event-time (time-add now (seconds-to-time (* hours-offset 3600)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (title (format "Task %d" (1+ i)))) + (setq content (concat content + (test-tooltip-bugs--create-gcal-event title time-str))))) + + (setq events (test-tooltip-bugs--gather-events content)) + + ;; Set to default: max-events=5 + (let ((chime-modeline-tooltip-max-events 5)) + (with-test-time now + (chime--update-modeline events)) + + ;; All 10 events should be in upcoming-events + (should (= 10 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Tooltip should show first 5 events + (should (string-match-p "Task 1" tooltip)) + (should (string-match-p "Task 2" tooltip)) + (should (string-match-p "Task 3" tooltip)) + (should (string-match-p "Task 4" tooltip)) + (should (string-match-p "Task 5" tooltip)) + + ;; Should NOT show tasks 6-10 + (should-not (string-match-p "Task 6" tooltip)) + (should-not (string-match-p "Task 10" tooltip)) + + ;; Should show "... and 5 more events" + (should (string-match-p "\\.\\.\\..*and 5 more events" tooltip))))) + (test-tooltip-bugs-teardown))) + +;;; Test 7: Events at exactly same time + +(ert-deftest test-tooltip-events-same-time () + "Test events scheduled at exactly the same time. + +From user report: Task 2 and Task 3 both at 10:00 PM. +Should both appear, should not duplicate." + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (current-time)) + (same-time (time-add now (seconds-to-time (* 120 60)))) ; 2 hours + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" same-time)) + (content (concat + (test-tooltip-bugs--create-gcal-event "Meeting A" time-str) + (test-tooltip-bugs--create-gcal-event "Meeting B" time-str) + (test-tooltip-bugs--create-gcal-event "Meeting C" time-str))) + (events (test-tooltip-bugs--gather-events content))) + + (chime--update-modeline events) + + ;; Should have all 3 events + (should (= 3 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; All 3 events should appear exactly once + (should (= 1 (test-tooltip-bugs--count-in-string "Meeting A" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Meeting B" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Meeting C" tooltip))))) + (test-tooltip-bugs-teardown))) + +(provide 'test-chime-tooltip-bugs) +;;; test-chime-tooltip-bugs.el ends here diff --git a/tests/test-chime-tooltip-day-calculation.el b/tests/test-chime-tooltip-day-calculation.el new file mode 100644 index 0000000..5d0901d --- /dev/null +++ b/tests/test-chime-tooltip-day-calculation.el @@ -0,0 +1,326 @@ +;;; test-chime-tooltip-day-calculation.el --- Tests for tooltip day/hour calculation -*- lexical-binding: t; -*- + +;;; Commentary: +;; Comprehensive tests for tooltip time-until formatting, especially day/hour calculations. +;; +;; Tests cover: +;; - Boundary cases (23h59m, 24h, 25h) +;; - Midnight boundaries +;; - Multiple days with fractional hours +;; - Exact day boundaries (48h, 72h) +;; - Edge cases that could trigger truncation bugs + +;;; Code: + +(require 'ert) +(require 'package) +(setq package-user-dir (expand-file-name "~/.emacs.d/elpa")) +(package-initialize) +(load (expand-file-name "../chime.el") nil t) +(require 'testutil-time (expand-file-name "testutil-time.el")) +(require 'testutil-general (expand-file-name "testutil-general.el")) + +(ert-deftest test-chime-tooltip-day-calculation-fractional-days () + "Test that fractional days show both days and hours correctly. + +User scenario: Viewing tooltip on Sunday 9pm, sees: +- Tuesday 9pm event: 48 hours = exactly 2 days → 'in 2 days' +- Wednesday 2pm event: 65 hours = 2.7 days → 'in 2 days 17 hours' + +This test prevents regression of the integer division truncation bug." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 21 0)) ; Sunday 9pm + ;; Create events at specific future times + (tuesday-9pm (time-add now (seconds-to-time (* 48 3600)))) ; +48 hours + (wednesday-2pm (time-add now (seconds-to-time (* 65 3600)))) ; +65 hours + (content (format "* Tuesday Event\n<%s>\n* Wednesday Event\n<%s>\n" + (format-time-string "<%Y-%m-%d %a %H:%M>" tuesday-9pm) + (format-time-string "<%Y-%m-%d %a %H:%M>" wednesday-2pm))) + (test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + (events nil)) + + ;; Gather events + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (push (chime--gather-info (point-marker)) events) + (forward-line 1))) + (kill-buffer test-buffer) + (setq events (nreverse events)) + + ;; Set lookahead to cover events (7 days) + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + ;; Update modeline and get tooltip + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + + ;; Verify tooltip contains both events + (should (string-match-p "Tuesday Event" tooltip)) + (should (string-match-p "Wednesday Event" tooltip)) + + ;; Print tooltip for manual inspection + (message "TOOLTIP CONTENT:\n%s" tooltip) + + ;; AFTER FIX: Tuesday shows "in 2 days", Wednesday shows "in 2 days 17 hours" + ;; Verify Tuesday shows exactly 2 days (no "hours" in countdown) + (should (string-match-p "Tuesday Event.*(in 2 days)" tooltip)) + ;; Make sure Tuesday doesn't have hours + (should-not (string-match-p "Tuesday Event.*hours" tooltip)) + + ;; Verify Wednesday shows 2 days AND 17 hours + (should (string-match-p "Wednesday Event.*(in 2 days 17 hours)" tooltip)) + + ;; Verify they show DIFFERENT countdowns + (let ((tuesday-line (progn + (string-match "Tuesday Event[^\n]*" tooltip) + (match-string 0 tooltip))) + (wednesday-line (progn + (string-match "Wednesday Event[^\n]*" tooltip) + (match-string 0 tooltip)))) + (should-not (string= tuesday-line wednesday-line)))))) + + (chime-delete-test-base-dir))) + +;;; Helper function for creating test events + +(defun test-chime-tooltip-day-calculation--create-event-at-hours (base-time title hours-from-now) + "Create event with TITLE at HOURS-FROM-NOW hours from BASE-TIME. +Returns formatted org content string." + (let* ((event-time (time-add base-time (seconds-to-time (* hours-from-now 3600))))) + (format "* %s\n<%s>\n" + title + (format-time-string "%Y-%m-%d %a %H:%M" event-time)))) + +(defun test-chime-tooltip-day-calculation--get-formatted-line (tooltip event-name) + "Extract the formatted countdown line for EVENT-NAME from TOOLTIP." + (when (string-match (format "%s[^\n]*" event-name) tooltip) + (match-string 0 tooltip))) + +;;; Boundary Cases - Critical thresholds + +(ert-deftest test-chime-tooltip-day-calculation-boundary-exactly-24-hours () + "Test event exactly 24 hours away shows 'in 1 day' not hours." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 12 0)) + (content (test-chime-tooltip-day-calculation--create-event-at-hours now "Tomorrow Same Time" 24)) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show "in 1 day" not hours + (should (string-match-p "(in 1 day)" tooltip)) + (should-not (string-match-p "hours" tooltip))))) + (chime-delete-test-base-dir))) + +(ert-deftest test-chime-tooltip-day-calculation-boundary-23-hours-59-minutes () + "Test event 23h59m away shows hours, not days (just under 24h threshold)." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 12 0)) + ;; 23 hours 59 minutes = 1439 minutes = just under 1440 + (event-time (time-add now (seconds-to-time (* 1439 60)))) + (content (format "* Almost Tomorrow\n<%s>\n" + (format-time-string "%Y-%m-%d %a %H:%M" event-time))) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show hours format (< 24 hours) + (should (string-match-p "hours" tooltip)) + (should-not (string-match-p "days?" tooltip))))) + (chime-delete-test-base-dir))) + +(ert-deftest test-chime-tooltip-day-calculation-boundary-25-hours () + "Test event 25 hours away shows 'in 1 day 1 hour'." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 12 0)) + (content (test-chime-tooltip-day-calculation--create-event-at-hours now "Day Plus One" 25)) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show "in 1 day 1 hour" + (should (string-match-p "(in 1 day 1 hour)" tooltip))))) + (chime-delete-test-base-dir))) + +(ert-deftest test-chime-tooltip-day-calculation-boundary-exactly-48-hours () + "Test event exactly 48 hours away shows 'in 2 days' without hours." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 12 0)) + (content (test-chime-tooltip-day-calculation--create-event-at-hours now "Two Days Exact" 48)) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events)) + (line (test-chime-tooltip-day-calculation--get-formatted-line + (chime--make-tooltip chime--upcoming-events) "Two Days Exact"))) + ;; Should show exactly "in 2 days" with NO hours + (should (string-match-p "(in 2 days)" tooltip)) + ;; Verify the line doesn't contain "hour" (would be "2 days 0 hours") + (should-not (string-match-p "hour" line))))) + (chime-delete-test-base-dir))) + +;;; Midnight Boundaries + +(ert-deftest test-chime-tooltip-day-calculation-midnight-crossing-shows-correct-days () + "Test event crossing midnight boundary calculates days correctly. + +Scenario: 11pm now, event at 2am (3 hours later, next calendar day) +Should show hours, not '1 day' since it's only 3 hours away." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 23 0)) ; 11pm + ;; 3 hours later = 2am next day + (content (test-chime-tooltip-day-calculation--create-event-at-hours now "Early Morning" 3)) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show "in 3 hours" not "in 1 day" + (should (string-match-p "3 hours" tooltip)) + (should-not (string-match-p "days?" tooltip))))) + (chime-delete-test-base-dir))) + +(ert-deftest test-chime-tooltip-day-calculation-midnight-plus-one-day () + "Test event at midnight tomorrow (24h exactly) shows '1 day'." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 0 0)) ; Midnight today + (content (test-chime-tooltip-day-calculation--create-event-at-hours now "Midnight Tomorrow" 24)) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (string-match-p "(in 1 day)" tooltip)) + (should-not (string-match-p "hour" tooltip))))) + (chime-delete-test-base-dir))) + +;;; Multiple Events - Verify distinct formatting + +(ert-deftest test-chime-tooltip-day-calculation-multiple-events-distinct () + "Test multiple events at different fractional-day offsets show distinct times." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 12 0)) + (content (concat + (test-chime-tooltip-day-calculation--create-event-at-hours now "Event 1 Day" 24) + (test-chime-tooltip-day-calculation--create-event-at-hours now "Event 1.5 Days" 36) + (test-chime-tooltip-day-calculation--create-event-at-hours now "Event 2 Days" 48) + (test-chime-tooltip-day-calculation--create-event-at-hours now "Event 2.75 Days" 66))) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Verify each event shows correctly + (should (string-match-p "Event 1 Day.*(in 1 day)" tooltip)) + (should (string-match-p "Event 1.5 Days.*(in 1 day 12 hours)" tooltip)) + (should (string-match-p "Event 2 Days.*(in 2 days)" tooltip)) + (should (string-match-p "Event 2.75 Days.*(in 2 days 18 hours)" tooltip)) + + ;; Verify they're all different + (let ((lines (split-string tooltip "\n"))) + (let ((countdowns (cl-remove-if-not + (lambda (line) (string-match-p "Event.*day" line)) + lines))) + ;; Should have 4 distinct countdown lines + (should (= 4 (length countdowns))) + ;; All should be unique + (should (= 4 (length (delete-dups (copy-sequence countdowns)))))))))) + (chime-delete-test-base-dir))) + +(provide 'test-chime-tooltip-day-calculation) +;;; test-chime-tooltip-day-calculation.el ends here diff --git a/tests/test-chime-update-modeline-helpers.el b/tests/test-chime-update-modeline-helpers.el new file mode 100644 index 0000000..106a7e2 --- /dev/null +++ b/tests/test-chime-update-modeline-helpers.el @@ -0,0 +1,166 @@ +;;; test-chime-update-modeline-helpers.el --- Tests for modeline helper functions -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for the refactored modeline helper functions: +;; - chime--find-soonest-time-in-window +;; - chime--build-upcoming-events-list +;; - chime--find-soonest-modeline-event + +;;; Code: + +(require 'ert) +(require 'package) +(setq package-user-dir (expand-file-name "~/.emacs.d/elpa")) +(package-initialize) +(load (expand-file-name "../chime.el") nil t) +(require 'testutil-time (expand-file-name "testutil-time.el")) +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-events (expand-file-name "testutil-events.el")) + +;;;; Tests for chime--find-soonest-time-in-window + +(ert-deftest test-chime-find-soonest-time-empty-list () + "Test that empty times list returns nil." + (let ((now (test-time-now)) + (times '())) + (should (null (chime--find-soonest-time-in-window times now 60))))) + +(ert-deftest test-chime-find-soonest-time-single-within-window () + "Test single time within window returns that time." + (let* ((now (test-time-now)) + (event-time (time-add now (seconds-to-time 1800))) ; 30 minutes + (times (list (cons "<2025-01-01 Wed 12:30>" event-time)))) + (let ((result (chime--find-soonest-time-in-window times now 60))) + (should result) + (should (equal (nth 0 result) "<2025-01-01 Wed 12:30>")) + (should (time-equal-p (nth 1 result) event-time)) + (should (< (abs (- (nth 2 result) 30)) 1))))) ; ~30 minutes + +(ert-deftest test-chime-find-soonest-time-multiple-returns-soonest () + "Test multiple times returns the soonest one." + (let* ((now (test-time-now)) + (time1 (time-add now (seconds-to-time 3600))) ; 60 min + (time2 (time-add now (seconds-to-time 1800))) ; 30 min (soonest) + (time3 (time-add now (seconds-to-time 5400))) ; 90 min + (times (list (cons "<2025-01-01 Wed 13:00>" time1) + (cons "<2025-01-01 Wed 12:30>" time2) + (cons "<2025-01-01 Wed 13:30>" time3)))) + (let ((result (chime--find-soonest-time-in-window times now 120))) + (should result) + (should (equal (nth 0 result) "<2025-01-01 Wed 12:30>")) + (should (< (abs (- (nth 2 result) 30)) 1))))) ; ~30 minutes + +(ert-deftest test-chime-find-soonest-time-outside-window () + "Test times outside window returns nil." + (let* ((now (test-time-now)) + (event-time (time-add now (seconds-to-time 7200))) ; 120 minutes + (times (list (cons "<2025-01-01 Wed 14:00>" event-time)))) + (should (null (chime--find-soonest-time-in-window times now 60))))) + +(ert-deftest test-chime-find-soonest-time-mix-inside-outside () + "Test mix of times inside/outside window returns soonest inside." + (let* ((now (test-time-now)) + (time-outside (time-add now (seconds-to-time 7200))) ; 120 min (outside) + (time-inside (time-add now (seconds-to-time 1800))) ; 30 min (inside, soonest) + (times (list (cons "<2025-01-01 Wed 14:00>" time-outside) + (cons "<2025-01-01 Wed 12:30>" time-inside)))) + (let ((result (chime--find-soonest-time-in-window times now 60))) + (should result) + (should (equal (nth 0 result) "<2025-01-01 Wed 12:30>"))))) + +(ert-deftest test-chime-find-soonest-time-past-event () + "Test past events are excluded." + (let* ((now (test-time-now)) + (past-time (time-subtract now (seconds-to-time 1800))) ; -30 minutes + (times (list (cons "<2025-01-01 Wed 11:30>" past-time)))) + (should (null (chime--find-soonest-time-in-window times now 60))))) + +;;;; Tests for chime--build-upcoming-events-list + +(ert-deftest test-chime-build-upcoming-empty-events () + "Test empty events list returns empty." + (let ((now (test-time-now)) + (events '())) + (should (null (chime--build-upcoming-events-list events now 1440 t))))) + +(ert-deftest test-chime-build-upcoming-single-event () + "Test single event within window is included." + (with-test-setup + (let* ((now (test-time-now)) + (event-time (time-add now (seconds-to-time 1800))) + (content (test-create-org-event "Meeting" event-time)) + (events (test-gather-events-from-content content)) + (result (chime--build-upcoming-events-list events now 1440 t))) + (should (= (length result) 1)) + (should (string= (cdr (assoc 'title (car (car result)))) "Meeting"))))) + +(ert-deftest test-chime-build-upcoming-sorted-by-time () + "Test multiple events are sorted by time (soonest first)." + (with-test-setup + (let* ((now (test-time-now)) + (time1 (time-add now (seconds-to-time 5400))) ; 90 min + (time2 (time-add now (seconds-to-time 1800))) ; 30 min (soonest) + (time3 (time-add now (seconds-to-time 3600))) ; 60 min + (content (test-create-org-events + `(("Meeting 1" ,time1) + ("Meeting 2" ,time2) + ("Meeting 3" ,time3)))) + (events (test-gather-events-from-content content)) + (result (chime--build-upcoming-events-list events now 1440 t))) + (should (= (length result) 3)) + ;; First should be Meeting 2 (soonest at 30 min) + (should (string= (cdr (assoc 'title (car (nth 0 result)))) "Meeting 2")) + ;; Second should be Meeting 3 (60 min) + (should (string= (cdr (assoc 'title (car (nth 1 result)))) "Meeting 3")) + ;; Third should be Meeting 1 (90 min) + (should (string= (cdr (assoc 'title (car (nth 2 result)))) "Meeting 1"))))) + +(ert-deftest test-chime-build-upcoming-excludes-outside-window () + "Test events outside lookahead window are excluded." + (with-test-setup + (let* ((now (test-time-now)) + (near-time (time-add now (seconds-to-time 1800))) ; 30 min (included) + (far-time (time-add now (seconds-to-time 10800))) ; 180 min (excluded) + (content (test-create-org-events + `(("Near Meeting" ,near-time) + ("Far Meeting" ,far-time)))) + (events (test-gather-events-from-content content)) + (result (chime--build-upcoming-events-list events now 60 t))) ; 60 min window + (should (= (length result) 1)) + (should (string= (cdr (assoc 'title (car (car result)))) "Near Meeting"))))) + +;;;; Tests for chime--find-soonest-modeline-event + +(ert-deftest test-chime-find-soonest-modeline-empty-events () + "Test empty events list returns nil." + (let ((now (test-time-now)) + (events '())) + (should (null (chime--find-soonest-modeline-event events now 60))))) + +(ert-deftest test-chime-find-soonest-modeline-single-timed-event () + "Test single timed event within window is returned." + (with-test-setup + (let* ((now (test-time-now)) + (event-time (time-add now (seconds-to-time 1800))) + (content (test-create-org-event "Meeting" event-time)) + (events (test-gather-events-from-content content)) + (result (chime--find-soonest-modeline-event events now 60))) + (should result) + (should (string= (cdr (assoc 'title (nth 0 result))) "Meeting"))))) + +(ert-deftest test-chime-find-soonest-modeline-excludes-all-day () + "Test all-day events are excluded from modeline." + (with-test-setup + (let* ((now (test-time-today-at 10 0)) + (all-day-time (test-time-today-at 0 0)) + (timed-time (time-add now (seconds-to-time 1800))) + (content (test-create-org-events + `(("All Day Event" ,all-day-time nil t) + ("Timed Event" ,timed-time)))) + (events (test-gather-events-from-content content)) + (result (chime--find-soonest-modeline-event events now 60))) + (should result) + (should (string= (cdr (assoc 'title (nth 0 result))) "Timed Event"))))) + +(provide 'test-chime-update-modeline-helpers) +;;; test-chime-update-modeline-helpers.el ends here diff --git a/tests/test-chime-update-modeline.el b/tests/test-chime-update-modeline.el new file mode 100644 index 0000000..764334c --- /dev/null +++ b/tests/test-chime-update-modeline.el @@ -0,0 +1,474 @@ +;;; test-chime-update-modeline.el --- Tests for chime--update-modeline -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--update-modeline function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-update-modeline-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset modeline settings + (setq chime-modeline-string nil) + (setq chime-enable-modeline t) + (setq chime-modeline-lookahead-minutes 30) + (setq chime-modeline-format " ⏰ %s") + ;; Disable no-events indicator for tests that expect nil modeline + (setq chime-modeline-no-events-text nil) + (setq chime-tooltip-lookahead-hours nil)) ; Use modeline lookahead + +(defun test-chime-update-modeline-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime-modeline-string nil)) + +;;; Normal Cases + +(ert-deftest test-chime-update-modeline-single-event-within-window-updates-modeline () + "Test that single event within lookahead window updates modeline. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 (10 minutes from now) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting"))) + (events (list event))) + (chime--update-modeline events) + ;; Should set modeline string + (should chime-modeline-string) + (should (stringp chime-modeline-string)) + (should (string-match-p "Team Meeting" chime-modeline-string)) + (should (string-match-p "10 minutes" chime-modeline-string)))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-multiple-events-picks-soonest () + "Test that with multiple events, soonest one is shown. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event 1 at 14:05 (5 minutes - soonest) + (event-time-1 (test-time-today-at 14 5)) + (timestamp-str-1 (test-timestamp-string event-time-1)) + ;; Event 2 at 14:25 (25 minutes) + (event-time-2 (test-time-today-at 14 25)) + (timestamp-str-2 (test-timestamp-string event-time-2))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event1 `((times . ((,timestamp-str-1 . ,event-time-1))) + (title . "Standup"))) + (event2 `((times . ((,timestamp-str-2 . ,event-time-2))) + (title . "Code Review"))) + (events (list event1 event2))) + (chime--update-modeline events) + ;; Should show the soonest event + (should chime-modeline-string) + (should (string-match-p "Standup" chime-modeline-string)) + (should-not (string-match-p "Code Review" chime-modeline-string)))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-event-outside-window-no-update () + "Test that event outside lookahead window doesn't update modeline. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 15:10 (70 minutes from now, outside 30 minute window) + (event-time (test-time-today-at 15 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Far Future Event"))) + (events (list event))) + (chime--update-modeline events) + ;; Should NOT set modeline string + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-zero-lookahead-clears-modeline () + "Test that zero lookahead clears modeline. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting"))) + (events (list event))) + (setq chime-modeline-lookahead-minutes 0) + (chime--update-modeline events) + ;; Should clear modeline + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-normal-disabled-clears-modeline () + "Test that chime-enable-modeline nil clears modeline even with valid event. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting"))) + (events (list event))) + (setq chime-enable-modeline nil) + (chime--update-modeline events) + ;; Should NOT set modeline string when disabled + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-normal-enabled-updates-modeline () + "Test that chime-enable-modeline t allows normal modeline updates. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting"))) + (events (list event))) + (setq chime-enable-modeline t) + (chime--update-modeline events) + ;; Should set modeline string when enabled + (should chime-modeline-string) + (should (string-match-p "Team Meeting" chime-modeline-string)))))) + (test-chime-update-modeline-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-update-modeline-no-events-clears-modeline () + "Test that no events clears modeline. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let ((events '())) + (chime--update-modeline events) + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-day-wide-events-filtered-out () + "Test that day-wide events are filtered out. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 0 0)) + (timestamp-str (test-timestamp-string event-time t))) ; Day-wide + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "All Day Event"))) + (events (list event))) + (chime--update-modeline events) + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-event-at-exact-boundary-included () + "Test that event at exact lookahead boundary is included. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:30 (exactly 30 minutes, at boundary) + (event-time (test-time-today-at 14 30)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Boundary Event"))) + (events (list event))) + (chime--update-modeline events) + (should chime-modeline-string) + (should (string-match-p "Boundary Event" chime-modeline-string)))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-boundary-disabled-overrides-lookahead () + "Test that chime-enable-modeline nil overrides positive lookahead. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting"))) + (events (list event))) + ;; Even with positive lookahead, disabled should prevent updates + (setq chime-enable-modeline nil) + (setq chime-modeline-lookahead-minutes 30) + (chime--update-modeline events) + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-update-modeline-past-events-not-shown () + "Test that past events are not shown in modeline. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 13:50 (10 minutes ago) + (event-time (test-time-today-at 13 50)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Past Event"))) + (events (list event))) + (chime--update-modeline events) + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-error-nil-events-handles-gracefully () + "Test that nil events parameter doesn't crash. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + ;; Should not error with nil events + (should-not (condition-case nil + (progn (chime--update-modeline nil) nil) + (error t))) + ;; Modeline should remain unset or cleared + (should-not chime-modeline-string)))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-error-invalid-event-structure-handles-gracefully () + "Test that invalid event structure doesn't crash. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* (;; Event missing required fields + (invalid-event '((invalid . "structure"))) + (events (list invalid-event))) + ;; Should not crash even with invalid events + (should-not (condition-case nil + (progn (chime--update-modeline events) nil) + (error t))))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-error-event-with-nil-times-handles-gracefully () + "Test that event with nil times field doesn't crash. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event '((times . nil) + (title . "Event with nil times"))) + (events (list event))) + ;; Should not crash + (should-not (condition-case nil + (progn (chime--update-modeline events) nil) + (error t))) + ;; Modeline should not be set + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +;;; Upcoming Events State Tests + +(ert-deftest test-chime-update-modeline-upcoming-events-populated () + "Test that chime--upcoming-events is populated with all events in window. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Three events within 30 minute window + (event1-time (test-time-today-at 14 5)) + (timestamp-str-1 (test-timestamp-string event1-time)) + (event2-time (test-time-today-at 14 10)) + (timestamp-str-2 (test-timestamp-string event2-time)) + (event3-time (test-time-today-at 14 25)) + (timestamp-str-3 (test-timestamp-string event3-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event1 `((times . ((,timestamp-str-1 . ,event1-time))) + (title . "Event 1") + (marker . nil))) + (event2 `((times . ((,timestamp-str-2 . ,event2-time))) + (title . "Event 2") + (marker . nil))) + (event3 `((times . ((,timestamp-str-3 . ,event3-time))) + (title . "Event 3") + (marker . nil))) + (events (list event1 event2 event3))) + (chime--update-modeline events) + ;; Should populate chime--upcoming-events + (should chime--upcoming-events) + ;; Should have all 3 events + (should (= 3 (length chime--upcoming-events))))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-upcoming-events-sorted () + "Test that chime--upcoming-events are sorted by time (soonest first). + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Add events in reverse order + (event1-time (test-time-today-at 14 25)) + (timestamp-str-1 (test-timestamp-string event1-time)) + (event2-time (test-time-today-at 14 10)) + (timestamp-str-2 (test-timestamp-string event2-time)) + (event3-time (test-time-today-at 14 5)) + (timestamp-str-3 (test-timestamp-string event3-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event1 `((times . ((,timestamp-str-1 . ,event1-time))) + (title . "Latest Event") + (marker . nil))) + (event2 `((times . ((,timestamp-str-2 . ,event2-time))) + (title . "Middle Event") + (marker . nil))) + (event3 `((times . ((,timestamp-str-3 . ,event3-time))) + (title . "Soonest Event") + (marker . nil))) + (events (list event1 event2 event3))) + (chime--update-modeline events) + ;; First event should be soonest + (let* ((first-event (car chime--upcoming-events)) + (first-event-obj (car first-event)) + (first-title (cdr (assoc 'title first-event-obj)))) + (should (string= "Soonest Event" first-title))))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-upcoming-events-cleared-when-disabled () + "Test that chime--upcoming-events is cleared when modeline disabled. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (marker . nil))) + (events (list event))) + ;; First populate with modeline enabled + (setq chime-enable-modeline t) + (chime--update-modeline events) + (should chime--upcoming-events) + ;; Now disable modeline + (setq chime-enable-modeline nil) + (chime--update-modeline events) + ;; Should clear chime--upcoming-events + (should-not chime--upcoming-events))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-upcoming-events-only-within-window () + "Test that only events within lookahead window are stored. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event within window (10 minutes) + (event1-time (test-time-today-at 14 10)) + (timestamp-str-1 (test-timestamp-string event1-time)) + ;; Event outside window (60 minutes, window is 30) + (event2-time (test-time-today-at 15 0)) + (timestamp-str-2 (test-timestamp-string event2-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event1 `((times . ((,timestamp-str-1 . ,event1-time))) + (title . "Within Window") + (marker . nil))) + (event2 `((times . ((,timestamp-str-2 . ,event2-time))) + (title . "Outside Window") + (marker . nil))) + (events (list event1 event2))) + (setq chime-modeline-lookahead-minutes 30) + (setq chime-tooltip-lookahead-hours 0.5) ; Also set tooltip lookahead + (chime--update-modeline events) + ;; Should only have 1 event (within window) + (should (= 1 (length chime--upcoming-events))))))) + (test-chime-update-modeline-teardown))) + +(provide 'test-chime-update-modeline) +;;; test-chime-update-modeline.el ends here diff --git a/tests/test-chime-validate-configuration.el b/tests/test-chime-validate-configuration.el new file mode 100644 index 0000000..fe0555f --- /dev/null +++ b/tests/test-chime-validate-configuration.el @@ -0,0 +1,279 @@ +;;; test-chime-validate-configuration.el --- Tests for chime-validate-configuration -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;;; Commentary: + +;; Unit tests for chime-validate-configuration function. +;; Tests validation of chime's runtime environment and configuration. +;; +;; Test categories: +;; - Normal Cases: Valid configurations that should pass +;; - Boundary Cases: Edge conditions (single file, empty strings, etc.) +;; - Error Cases: Invalid configurations that should fail +;; +;; External dependencies mocked: +;; - file-exists-p (file I/O) +;; - require (package loading) +;; - display-warning (UI side effect) +;; +;; NOT mocked: +;; - Validation logic itself +;; - org-agenda-files variable (use let to set test values) + +;;; Code: + +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'org-agenda) +(load (expand-file-name "../chime.el") nil t) +(require 'cl-lib) + +;;; Setup and Teardown + +(defun test-chime-validate-configuration-setup () + "Set up test environment before each test." + ;; No persistent state to set up - each test uses let-bindings + nil) + +(defun test-chime-validate-configuration-teardown () + "Clean up test environment after each test." + ;; No cleanup needed - let-bindings automatically unwind + nil) + +;;; Test Helper Functions + +(defun test-chime-validate-configuration--has-error-p (issues) + "Return t if ISSUES contains at least one :error severity item." + (cl-some (lambda (issue) (eq (car issue) :error)) issues)) + +(defun test-chime-validate-configuration--has-warning-p (issues) + "Return t if ISSUES contains at least one :warning severity item." + (cl-some (lambda (issue) (eq (car issue) :warning)) issues)) + +(defun test-chime-validate-configuration--count-issues (issues severity) + "Count number of ISSUES with given SEVERITY (:error, :warning, or :info)." + (length (cl-remove-if-not (lambda (i) (eq (car i) severity)) issues))) + +;;; Normal Cases - Valid Configurations + +(ert-deftest test-chime-validate-configuration-normal-valid-config-returns-nil () + "Test validation passes with valid org-agenda-files and all dependencies loaded." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/inbox.org" "/tmp/work.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (should (null (chime-validate-configuration))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-normal-multiple-files-returns-nil () + "Test validation passes with multiple org-agenda files that all exist." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/one.org" "/tmp/two.org" "/tmp/three.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (should (null (chime-validate-configuration))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-normal-modeline-disabled-skips-check () + "Test validation skips global-mode-string check when modeline is disabled." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/inbox.org")) + (chime-enable-modeline nil)) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil)) + ((symbol-function 'boundp) + (lambda (sym) (not (eq sym 'global-mode-string))))) ; Only global-mode-string unbound + (should (null (chime-validate-configuration))))) + (test-chime-validate-configuration-teardown)) + +;;; Boundary Cases - Edge Conditions + +(ert-deftest test-chime-validate-configuration-boundary-single-file-returns-nil () + "Test validation passes with exactly one org-agenda file." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/single.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (should (null (chime-validate-configuration))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-boundary-some-files-missing-returns-warning () + "Test validation warns when some but not all files are missing." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/exists.org" "/missing.org" "/also-missing.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) + (lambda (f) (string= f "/exists.org"))) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should (= 1 (length issues))) + (should (eq :warning (caar issues))) + (should (string-match-p "2 org-agenda-files don't exist" (cadar issues))) + (should (string-match-p "/missing.org" (cadar issues))) + (should (string-match-p "/also-missing.org" (cadar issues)))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-boundary-all-files-missing-returns-warning () + "Test validation warns when all org-agenda files are missing." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/missing1.org" "/missing2.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) nil)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should (= 1 (length issues))) + (should (eq :warning (caar issues))) + (should (string-match-p "2 org-agenda-files don't exist" (cadar issues)))))) + (test-chime-validate-configuration-teardown)) + +;;; Error Cases - Invalid Configurations + +(ert-deftest test-chime-validate-configuration-error-nil-org-agenda-files-returns-error () + "Test validation returns error when org-agenda-files is nil." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files nil) + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should issues) + (should (test-chime-validate-configuration--has-error-p issues)) + (should (string-match-p "not set or empty" (cadar issues)))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-error-empty-list-returns-error () + "Test validation returns error when org-agenda-files is empty list." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '()) + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should issues) + (should (eq :error (caar issues))) + (should (string-match-p "not set or empty" (cadar issues)))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-error-unbound-org-agenda-files-returns-error () + "Test validation returns error when org-agenda-files variable is not bound." + (test-chime-validate-configuration-setup) + (cl-letf (((symbol-function 'boundp) + (lambda (sym) (not (eq sym 'org-agenda-files)))) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should issues) + (should (test-chime-validate-configuration--has-error-p issues)) + (should (string-match-p "not set or empty" (cadar issues))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-error-non-list-org-agenda-files-returns-error () + "Test validation returns error when org-agenda-files is not a list." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files "/tmp/inbox.org") ; string instead of list + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should issues) + (should (test-chime-validate-configuration--has-error-p issues))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-error-org-agenda-not-loadable-returns-error () + "Test validation returns error when org-agenda cannot be loaded." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/test.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) + (lambda (feature &optional _ _) + (if (eq feature 'org-agenda) nil t))) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should issues) + (should (cl-some (lambda (i) + (and (eq (car i) :error) + (string-match-p "org-agenda" (cadr i)))) + issues))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-error-multiple-errors-returns-all () + "Test validation returns all errors when multiple issues exist." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files nil) ; Error 1: nil org-agenda-files + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'require) + (lambda (feature &optional _ _) + (if (eq feature 'org-agenda) nil t))) ; Error 2: can't load org-agenda + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should (>= (test-chime-validate-configuration--count-issues issues :error) 2))))) + (test-chime-validate-configuration-teardown)) + +;;; Warning Cases - Non-Critical Issues + +(ert-deftest test-chime-validate-configuration-warning-missing-global-mode-string-returns-warning () + "Test validation warns when global-mode-string is not available but modeline is enabled." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/inbox.org")) + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'boundp) + (lambda (sym) (not (eq sym 'global-mode-string)))) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should (test-chime-validate-configuration--has-warning-p issues)) + (should (string-match-p "global-mode-string not available" (cadar issues)))))) + (test-chime-validate-configuration-teardown)) + +;;; Interactive Behavior Tests + +(ert-deftest test-chime-validate-configuration-interactive-calls-display-warning () + "Test validation displays warnings when called interactively." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files nil) + (warning-called nil) + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'display-warning) + (lambda (&rest _) (setq warning-called t))) + ((symbol-function 'called-interactively-p) (lambda (_) t))) + (chime-validate-configuration) + (should warning-called))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-interactive-success-shows-message () + "Test validation shows success message when called interactively with valid config." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/inbox.org")) + (message-shown nil) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'message) + (lambda (fmt &rest _) + (when (string-match-p "validation checks passed" fmt) + (setq message-shown t)))) + ((symbol-function 'called-interactively-p) (lambda (_) t))) + (chime-validate-configuration) + (should message-shown))) + (test-chime-validate-configuration-teardown)) + +(provide 'test-chime-validate-configuration) +;;; test-chime-validate-configuration.el ends here diff --git a/tests/test-chime-validation-retry.el b/tests/test-chime-validation-retry.el new file mode 100644 index 0000000..b35fd29 --- /dev/null +++ b/tests/test-chime-validation-retry.el @@ -0,0 +1,435 @@ +;;; test-chime-validation-retry.el --- Tests for chime validation retry logic -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for chime's configuration validation retry mechanism. +;; +;; Tests cover the graceful retry behavior when org-agenda-files is not +;; immediately available (e.g., loaded asynchronously via idle timer). +;; +;; The retry mechanism allows chime to wait for org-agenda-files to be +;; populated before showing configuration errors, providing a better UX +;; for users with async initialization code. +;; +;; Components tested: +;; - chime--validation-retry-count tracking +;; - chime-validation-max-retries configuration +;; - chime-check validation retry logic +;; - chime--stop retry counter reset +;; - Message display behavior (waiting vs error) + +;;; Code: + +(require 'ert) + +;; Initialize package system to make installed packages available in batch mode +(require 'package) +(setq package-user-dir (expand-file-name "~/.emacs.d/elpa")) +(package-initialize) + +;; Load chime from parent directory (which will load its dependencies) +(load (expand-file-name "../chime.el") nil t) + +;;; Setup and Teardown + +(defvar test-chime-validation-retry--original-max-retries nil + "Original value of chime-validation-max-retries for restoration.") + +(defvar test-chime-validation-retry--original-agenda-files nil + "Original value of org-agenda-files for restoration.") + +(defun test-chime-validation-retry-setup () + "Set up test environment before each test." + ;; Save original values + (setq test-chime-validation-retry--original-max-retries chime-validation-max-retries) + (setq test-chime-validation-retry--original-agenda-files org-agenda-files) + + ;; Reset validation state + (setq chime--validation-done nil) + (setq chime--validation-retry-count 0) + + ;; Set predictable defaults + (setq chime-validation-max-retries 3)) + +(defun test-chime-validation-retry-teardown () + "Clean up test environment after each test." + ;; Restore original values + (setq chime-validation-max-retries test-chime-validation-retry--original-max-retries) + (setq org-agenda-files test-chime-validation-retry--original-agenda-files) + + ;; Reset validation state + (setq chime--validation-done nil) + (setq chime--validation-retry-count 0)) + +;;; Normal Cases - Retry Behavior + +(ert-deftest test-chime-validation-retry-normal-first-failure-shows-waiting () + "Test first validation failure shows waiting message, not error. + +When org-agenda-files is empty on the first check, chime should show +a friendly waiting message instead of immediately displaying the full +error. This accommodates async org-agenda-files initialization." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Empty org-agenda-files to trigger validation failure + (setq org-agenda-files nil) + + ;; Capture message output + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages))) + ;; Mock fetch to prevent actual agenda processing + ((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Call chime-check + (chime-check) + + ;; Should show waiting message + (should (= chime--validation-retry-count 1)) + (should-not chime--validation-done) + (should (cl-some (lambda (msg) + (string-match-p "Waiting for org-agenda-files" msg)) + messages)) + ;; Should NOT show error message + (should-not (cl-some (lambda (msg) + (string-match-p "Configuration errors detected" msg)) + messages))))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-normal-success-resets-counter () + "Test successful validation after retry resets counter to zero. + +When validation succeeds on a retry attempt, the retry counter should +be reset to 0, allowing fresh retry attempts if validation fails again +later (e.g., after mode restart)." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Simulate one failed attempt + (setq chime--validation-retry-count 1) + + ;; Set valid org-agenda-files + (setq org-agenda-files '("/tmp/test.org")) + + ;; Mock fetch to prevent actual agenda processing + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Call chime-check - should succeed + (chime-check) + + ;; Counter should be reset + (should (= chime--validation-retry-count 0)) + ;; Validation marked as done + (should chime--validation-done))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-normal-multiple-retries-increment () + "Test multiple validation failures increment counter correctly. + +Each validation failure should increment the retry counter by 1, +allowing the system to track how many retries have been attempted." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Empty org-agenda-files + (setq org-agenda-files nil) + + ;; Mock fetch + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil)) + ((symbol-function 'message) + (lambda (&rest args) nil))) + + ;; First attempt + (chime-check) + (should (= chime--validation-retry-count 1)) + + ;; Second attempt + (chime-check) + (should (= chime--validation-retry-count 2)) + + ;; Third attempt + (chime-check) + (should (= chime--validation-retry-count 3)))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-normal-successful-validation-proceeds () + "Test successful validation proceeds with event checking. + +When validation passes, chime-check should proceed to fetch and +process events normally." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Valid org-agenda-files + (setq org-agenda-files '("/tmp/test.org")) + + ;; Track if fetch was called + (let ((fetch-called nil)) + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) + (setq fetch-called t)))) + + ;; Call chime-check + (chime-check) + + ;; Should proceed to fetch + (should fetch-called) + (should chime--validation-done) + (should (= chime--validation-retry-count 0))))) + (test-chime-validation-retry-teardown))) + +;;; Boundary Cases - Edge Conditions + +(ert-deftest test-chime-validation-retry-boundary-max-retries-zero () + "Test max-retries=0 shows error immediately without retrying. + +When chime-validation-max-retries is set to 0, validation failures +should immediately show the full error message without any retry +attempts." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Set max retries to 0 + (setq chime-validation-max-retries 0) + + ;; Empty org-agenda-files + (setq org-agenda-files nil) + + ;; Capture message output + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages))) + ((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Call chime-check + (chime-check) + + ;; Counter incremented + (should (= chime--validation-retry-count 1)) + ;; Should show error, not waiting message + (should (cl-some (lambda (msg) + (string-match-p "Configuration errors detected" msg)) + messages)) + (should-not (cl-some (lambda (msg) + (string-match-p "Waiting for" msg)) + messages))))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-boundary-max-retries-one () + "Test max-retries=1 allows one retry before showing error. + +First attempt should show waiting message, second attempt should +show full error." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Set max retries to 1 + (setq chime-validation-max-retries 1) + + ;; Empty org-agenda-files + (setq org-agenda-files nil) + + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; First attempt - should show waiting + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check) + (should (= chime--validation-retry-count 1)) + (should (cl-some (lambda (msg) + (string-match-p "Waiting for" msg)) + messages)))) + + ;; Second attempt - should show error + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check) + (should (= chime--validation-retry-count 2)) + (should (cl-some (lambda (msg) + (string-match-p "Configuration errors detected" msg)) + messages)))))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-boundary-exactly-at-threshold () + "Test behavior exactly at max-retries threshold. + +The (retry_count + 1)th attempt should show the error message." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Default max retries = 3 + (setq chime-validation-max-retries 3) + (setq org-agenda-files nil) + + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Attempts 1-3: waiting messages + (dotimes (_ 3) + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check) + (should (cl-some (lambda (msg) + (string-match-p "Waiting for" msg)) + messages))))) + + ;; Attempt 4: should show error + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check) + (should (= chime--validation-retry-count 4)) + (should (cl-some (lambda (msg) + (string-match-p "Configuration errors detected" msg)) + messages)))))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-boundary-stop-resets-counter () + "Test chime--stop resets retry counter to zero. + +When chime-mode is stopped, the retry counter should be reset to +allow fresh retry attempts on next start." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Simulate some failed attempts + (setq chime--validation-retry-count 5) + (setq chime--validation-done nil) + + ;; Call stop + (chime--stop) + + ;; Counter should be reset + (should (= chime--validation-retry-count 0)) + (should-not chime--validation-done)) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-boundary-empty-agenda-files () + "Test empty org-agenda-files list triggers retry. + +An empty list should be treated the same as nil - both should +trigger validation failure and retry." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Empty list (not nil) + (setq org-agenda-files '()) + + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages))) + ((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Should trigger retry + (chime-check) + (should (= chime--validation-retry-count 1)) + (should (cl-some (lambda (msg) + (string-match-p "Waiting for" msg)) + messages))))) + (test-chime-validation-retry-teardown))) + +;;; Error Cases - Failure Scenarios + +(ert-deftest test-chime-validation-retry-error-exceeding-max-shows-full-error () + "Test exceeding max retries shows full error with details. + +After max retries exceeded, the full validation error should be +displayed with all error details in the *Messages* buffer." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + (setq chime-validation-max-retries 2) + (setq org-agenda-files nil) + + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Exhaust retries + (dotimes (_ 3) + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check)))) + + ;; Verify error message on next attempt + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check) + ;; Should show error message (detailed error with retry count goes to *Messages* buffer via chime--log-silently) + (should (cl-some (lambda (msg) + (string-match-p "Configuration errors detected" msg)) + messages)))))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-error-persistent-failure () + "Test validation failure persisting through all retries. + +If org-agenda-files remains empty through all retry attempts, +validation should never be marked as done." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + (setq chime-validation-max-retries 3) + (setq org-agenda-files nil) + + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil)) + ((symbol-function 'message) + (lambda (&rest args) nil))) + + ;; Multiple attempts, all failing + (dotimes (_ 10) + (chime-check) + ;; Should never mark as done + (should-not chime--validation-done)) + + ;; Counter keeps incrementing + (should (= chime--validation-retry-count 10)))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-error-counter-large-value () + "Test retry counter handles large values without overflow. + +The retry counter should continue incrementing correctly even with +many retry attempts, ensuring no integer overflow issues." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + (setq chime-validation-max-retries 1000) + (setq org-agenda-files nil) + + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil)) + ((symbol-function 'message) + (lambda (&rest args) nil))) + + ;; Many attempts + (dotimes (i 100) + (chime-check) + (should (= chime--validation-retry-count (1+ i)))) + + ;; Should still be counting correctly + (should (= chime--validation-retry-count 100)))) + (test-chime-validation-retry-teardown))) + +(provide 'test-chime-validation-retry) +;;; test-chime-validation-retry.el ends here diff --git a/tests/test-chime-whitelist-blacklist-conflicts.el b/tests/test-chime-whitelist-blacklist-conflicts.el new file mode 100644 index 0000000..ba7d00a --- /dev/null +++ b/tests/test-chime-whitelist-blacklist-conflicts.el @@ -0,0 +1,253 @@ +;;; test-chime-whitelist-blacklist-conflicts.el --- Tests for whitelist/blacklist conflicts -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for conflicts when the same keyword or tag appears in both +;; whitelist and blacklist. These tests verify which takes precedence. +;; +;; Current implementation: whitelist is applied first, then blacklist. +;; Therefore, blacklist wins in conflicts - items matching both lists +;; are filtered out. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-conflicts-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset all whitelist/blacklist settings + (setq chime-keyword-whitelist nil) + (setq chime-tags-whitelist nil) + (setq chime-predicate-whitelist nil) + (setq chime-keyword-blacklist nil) + (setq chime-tags-blacklist nil) + (setq chime-predicate-blacklist nil)) + +(defun test-chime-conflicts-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime-keyword-whitelist nil) + (setq chime-tags-whitelist nil) + (setq chime-predicate-whitelist nil) + (setq chime-keyword-blacklist nil) + (setq chime-tags-blacklist nil) + (setq chime-predicate-blacklist nil)) + +;;; Keyword Conflict Tests + +(ert-deftest test-chime-conflict-same-keyword-in-both-lists () + "Test behavior when same keyword is in both whitelist and blacklist. +Current behavior: blacklist wins (item is filtered out)." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* TODO Task 3\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-whitelist '("TODO" "DONE")) + (chime-keyword-blacklist '("DONE"))) ; DONE in both lists + ;; Apply both filters (simulating what happens in chime--gather-timestamps) + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2 marker3))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep all three (all are TODO or DONE) + (should (= (length after-whitelist) 3)) + (should (member marker1 after-whitelist)) + (should (member marker2 after-whitelist)) + (should (member marker3 after-whitelist)) + ;; Blacklist should then remove DONE (marker2) + ;; So only TODO items (markers 1 and 3) should remain + (should (= (length after-blacklist) 2)) + (should (member marker1 after-blacklist)) + (should-not (member marker2 after-blacklist)) + (should (member marker3 after-blacklist))))))) + (test-chime-conflicts-teardown))) + +(ert-deftest test-chime-conflict-all-keywords-in-both-lists () + "Test behavior when all keywords are in both whitelist and blacklist. +Current behavior: blacklist wins, all items filtered out." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-keyword-whitelist '("TODO" "DONE")) + (chime-keyword-blacklist '("TODO" "DONE"))) + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep both + (should (= (length after-whitelist) 2)) + ;; Blacklist should remove both + (should (= (length after-blacklist) 0)))))) + (test-chime-conflicts-teardown))) + +;;; Tag Conflict Tests + +(ert-deftest test-chime-conflict-same-tag-in-both-lists () + "Test behavior when same tag is in both whitelist and blacklist. +Current behavior: blacklist wins (item is filtered out)." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Task 1 :urgent:\n") + (insert "* Task 2 :normal:\n") + (insert "* Task 3 :important:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-tags-whitelist '("urgent" "important")) + (chime-tags-blacklist '("urgent"))) ; urgent in both lists + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2 marker3))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep urgent and important (markers 1 and 3) + (should (= (length after-whitelist) 2)) + (should (member marker1 after-whitelist)) + (should (member marker3 after-whitelist)) + ;; Blacklist should then remove urgent (marker1) + ;; So only important (marker3) should remain + (should (= (length after-blacklist) 1)) + (should-not (member marker1 after-blacklist)) + (should (member marker3 after-blacklist))))))) + (test-chime-conflicts-teardown))) + +;;; Mixed Keyword and Tag Conflict Tests + +(ert-deftest test-chime-conflict-keyword-whitelisted-tag-blacklisted () + "Test when item has whitelisted keyword but blacklisted tag. +Current behavior: blacklist wins (OR logic means tag match filters it out)." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1 :urgent:\n") + (insert "* DONE Task 2 :normal:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-keyword-whitelist '("TODO")) + (chime-tags-blacklist '("urgent"))) + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep TODO (marker1) + (should (= (length after-whitelist) 1)) + (should (member marker1 after-whitelist)) + ;; Blacklist should remove items with urgent tag (marker1) + (should (= (length after-blacklist) 0)))))) + (test-chime-conflicts-teardown))) + +(ert-deftest test-chime-conflict-tag-whitelisted-keyword-blacklisted () + "Test when item has whitelisted tag but blacklisted keyword. +Current behavior: blacklist wins (OR logic means keyword match filters it out)." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1 :urgent:\n") + (insert "* DONE Task 2 :normal:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-tags-whitelist '("urgent")) + (chime-keyword-blacklist '("TODO"))) + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep urgent tag (marker1) + (should (= (length after-whitelist) 1)) + (should (member marker1 after-whitelist)) + ;; Blacklist should remove items with TODO keyword (marker1) + (should (= (length after-blacklist) 0)))))) + (test-chime-conflicts-teardown))) + +;;; Complex Conflict Tests + +(ert-deftest test-chime-conflict-multiple-items-partial-conflicts () + "Test multiple items with some having conflicts and some not. +Current behavior: only items with conflicts are filtered out." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1 :urgent:\n") ; whitelisted keyword, blacklisted tag + (insert "* DONE Task 2 :normal:\n") ; not whitelisted + (insert "* TODO Task 3 :urgent:\n") ; whitelisted keyword, blacklisted tag + (insert "* TODO Task 4 :normal:\n") ; whitelisted keyword, ok tag + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker))) + (forward-line 1) + (let ((marker4 (point-marker)) + (chime-keyword-whitelist '("TODO")) + (chime-tags-blacklist '("urgent"))) + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2 marker3 marker4))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep TODO (markers 1, 3, 4) + (should (= (length after-whitelist) 3)) + (should (member marker1 after-whitelist)) + (should (member marker3 after-whitelist)) + (should (member marker4 after-whitelist)) + ;; Blacklist should remove items with urgent tag (markers 1, 3) + ;; Only marker4 (TODO with normal tag) should remain + (should (= (length after-blacklist) 1)) + (should (member marker4 after-blacklist)) + (should-not (member marker1 after-blacklist)) + (should-not (member marker3 after-blacklist)))))))) + (test-chime-conflicts-teardown))) + +(provide 'test-chime-whitelist-blacklist-conflicts) +;;; test-chime-whitelist-blacklist-conflicts.el ends here diff --git a/tests/test-convert-org-contacts-birthdays.el b/tests/test-convert-org-contacts-birthdays.el new file mode 100644 index 0000000..960a998 --- /dev/null +++ b/tests/test-convert-org-contacts-birthdays.el @@ -0,0 +1,674 @@ +;;; test-convert-org-contacts-birthdays.el --- Tests for convert-org-contacts-birthdays.el -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for convert-org-contacts-birthdays.el +;; Tests the conversion of org-contacts BIRTHDAY properties to plain timestamps. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load the conversion utility from parent directory +(load (expand-file-name "../convert-org-contacts-birthdays.el") nil t) + +;;; Tests for birthday parsing + +(ert-deftest test-convert-normal-parse-birthday-with-year-returns-year-month-day () + "Test parsing YYYY-MM-DD format returns (YEAR MONTH DAY)." + (let ((result (chime--parse-birthday "2000-03-15"))) + (should (equal result '(2000 3 15))))) + +(ert-deftest test-convert-normal-parse-birthday-without-year-returns-nil-month-day () + "Test parsing MM-DD format returns (nil MONTH DAY)." + (let ((result (chime--parse-birthday "03-15"))) + (should (equal result '(nil 3 15))))) + +(ert-deftest test-convert-normal-parse-birthday-december-returns-correct-month () + "Test parsing December date returns month 12." + (let ((result (chime--parse-birthday "1985-12-25"))) + (should (equal result '(1985 12 25))))) + +(ert-deftest test-convert-normal-parse-birthday-january-returns-correct-month () + "Test parsing January date returns month 1." + (let ((result (chime--parse-birthday "01-01"))) + (should (equal result '(nil 1 1))))) + +(ert-deftest test-convert-error-parse-birthday-invalid-format-signals-error () + "Test parsing invalid format signals user-error." + (should-error (chime--parse-birthday "2000/03/15") :type 'user-error) + (should-error (chime--parse-birthday "March 15, 2000") :type 'user-error) + (should-error (chime--parse-birthday "15-03-2000") :type 'user-error)) + +;;; Tests for birthday formatting + +(ert-deftest test-convert-normal-format-birthday-timestamp-with-year-returns-yearly-repeater () + "Test formatting with year returns yearly repeating timestamp." + (let ((result (chime--format-birthday-timestamp 2000 3 15))) + ;; Should contain year, date, and +1y repeater + (should (string-match-p "<2000-03-15 [A-Za-z]\\{3\\} \\+1y>" result)))) + +(ert-deftest test-convert-normal-format-birthday-timestamp-without-year-uses-current-year () + "Test formatting without year uses current year." + (let* ((current-year (nth 5 (decode-time))) + (result (chime--format-birthday-timestamp nil 3 15))) + ;; Should contain current year + (should (string-match-p (format "<%d-03-15 [A-Za-z]\\{3\\} \\+1y>" current-year) result)))) + +(ert-deftest test-convert-boundary-format-birthday-timestamp-leap-day-returns-valid-timestamp () + "Test formatting February 29 (leap day) returns valid timestamp." + (let ((result (chime--format-birthday-timestamp 2000 2 29))) + (should (string-match-p "<2000-02-29 [A-Za-z]\\{3\\} \\+1y>" result)))) + +(ert-deftest test-convert-boundary-format-birthday-timestamp-december-31-returns-valid-timestamp () + "Test formatting December 31 returns valid timestamp." + (let ((result (chime--format-birthday-timestamp 2000 12 31))) + (should (string-match-p "<2000-12-31 [A-Za-z]\\{3\\} \\+1y>" result)))) + +;;; Tests for backup file creation + +(ert-deftest test-convert-normal-backup-file-creates-timestamped-backup () + "Test that backup file is created with timestamp." + (let* ((temp-file (make-temp-file "test-contacts")) + (backup-path nil)) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Test Contact\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n")) + (setq backup-path (chime--backup-contacts-file temp-file)) + (should (file-exists-p backup-path)) + (should (string-match-p "\\.backup-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{6\\}$" backup-path))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (when (and backup-path (file-exists-p backup-path)) (delete-file backup-path))))) + +(ert-deftest test-convert-normal-backup-file-preserves-content () + "Test that backup file contains exact copy of original." + (let* ((temp-file (make-temp-file "test-contacts")) + (content "* Test Contact\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n") + (backup-path nil)) + (unwind-protect + (progn + (with-temp-file temp-file (insert content)) + (setq backup-path (chime--backup-contacts-file temp-file)) + (with-temp-buffer + (insert-file-contents backup-path) + (should (string= (buffer-string) content)))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (when (and backup-path (file-exists-p backup-path)) (delete-file backup-path))))) + +;;; Tests for timestamp insertion + +(ert-deftest test-convert-normal-insert-timestamp-after-drawer-adds-timestamp () + "Test that timestamp is inserted after properties drawer." + (with-temp-buffer + (org-mode) + (insert "* Test Contact\n:PROPERTIES:\n:EMAIL: test@example.com\n:BIRTHDAY: 2000-03-15\n:END:\n") + (goto-char (point-min)) + (re-search-forward "^\\* ") + (beginning-of-line) + (chime--insert-birthday-timestamp-after-drawer "2000-03-15") + (should (string-match-p "<2000-03-15 [A-Za-z]\\{3\\} \\+1y>" (buffer-string))))) + +(ert-deftest test-convert-normal-insert-timestamp-preserves-properties () + "Test that inserting timestamp doesn't modify properties." + (with-temp-buffer + (org-mode) + (insert "* Test Contact\n:PROPERTIES:\n:EMAIL: test@example.com\n:BIRTHDAY: 2000-03-15\n:END:\n") + (goto-char (point-min)) + (re-search-forward "^\\* ") + (beginning-of-line) + (chime--insert-birthday-timestamp-after-drawer "2000-03-15") + (should (string-match-p ":EMAIL: test@example.com" (buffer-string))) + (should (string-match-p ":BIRTHDAY: 2000-03-15" (buffer-string))))) + +(ert-deftest test-convert-boundary-insert-timestamp-without-year-uses-current () + "Test that MM-DD format uses current year." + (let ((current-year (nth 5 (decode-time)))) + (with-temp-buffer + (org-mode) + (insert "* Test Contact\n:PROPERTIES:\n:BIRTHDAY: 03-15\n:END:\n") + (goto-char (point-min)) + (re-search-forward "^\\* ") + (beginning-of-line) + (chime--insert-birthday-timestamp-after-drawer "03-15") + (should (string-match-p (format "<%d-03-15" current-year) (buffer-string)))))) + +;;; Tests for contact entry processing + +(ert-deftest test-convert-normal-process-contact-with-birthday-returns-true () + "Test that processing contact with birthday returns t." + (with-temp-buffer + (org-mode) + (insert "* Test Contact\n:PROPERTIES:\n:BIRTHDAY: 2000-03-15\n:END:\n") + (goto-char (point-min)) + (re-search-forward "^\\* ") + (beginning-of-line) + (should (eq t (chime--process-contact-entry))))) + +(ert-deftest test-convert-normal-process-contact-without-birthday-returns-nil () + "Test that processing contact without birthday returns nil." + (with-temp-buffer + (org-mode) + (insert "* Test Contact\n:PROPERTIES:\n:EMAIL: test@example.com\n:END:\n") + (goto-char (point-min)) + (re-search-forward "^\\* ") + (beginning-of-line) + (should (null (chime--process-contact-entry))))) + +;;; Tests for file conversion + +(ert-deftest test-convert-normal-convert-file-multiple-contacts-returns-correct-count () + "Test converting file with multiple contacts returns correct count." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n\n") + (insert "* Bob Baker\n:PROPERTIES:\n:BIRTHDAY: 1990-07-22\n:END:\n\n") + (insert "* Carol Chen\n:PROPERTIES:\n:BIRTHDAY: 12-25\n:END:\n")) + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + (should (= count 3)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-boundary-convert-file-no-contacts-returns-zero () + "Test converting file with no contacts returns zero." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "#+TITLE: Empty File\n\nSome text but no contacts.\n")) + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + (should (= count 0)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-boundary-convert-file-contacts-without-birthdays-returns-zero () + "Test converting file with contacts but no birthdays returns zero." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:EMAIL: alice@example.com\n:END:\n\n") + (insert "* Bob Baker\n:PROPERTIES:\n:EMAIL: bob@example.com\n:END:\n")) + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + (should (= count 0)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-boundary-convert-file-contact-without-properties-drawer () + "Test converting contact without properties drawer is skipped." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\nJust some text, no properties.\n")) + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + (should (= count 0)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-boundary-convert-file-preserves-existing-timestamps () + "Test that existing timestamps in contact body are not modified." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n") + (insert "Meeting scheduled <2025-11-15 Sat>\n")) + (chime--convert-contacts-file-in-place temp-file) + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; Should have birthday timestamp + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)) + ;; Should still have meeting timestamp + (should (string-match-p "<2025-11-15 Sat>" content)) + ;; Should have exactly 2 timestamps + (should (= 2 (how-many "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" (point-min) (point-max))))))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (let ((backup (concat temp-file ".backup-"))) + (dolist (file (directory-files (file-name-directory temp-file) t (regexp-quote (file-name-nondirectory backup)))) + (delete-file file)))))) + +(ert-deftest test-convert-normal-convert-file-timestamp-inserted-after-drawer () + "Test that timestamp is inserted immediately after properties drawer." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n") + (insert "Some notes about Alice.\n")) + (chime--convert-contacts-file-in-place temp-file) + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; Timestamp should come after :END: but before notes + (should (string-match-p ":END:\n<1985-03-15 [A-Za-z]\\{3\\} \\+1y>\nSome notes" content))))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (let ((backup (concat temp-file ".backup-"))) + (dolist (file (directory-files (file-name-directory temp-file) t (regexp-quote (file-name-nondirectory backup)))) + (delete-file file)))))) + +;;; Tests for year extraction + +(ert-deftest test-convert-normal-extract-birthday-year-with-year-returns-year () + "Test extracting year from YYYY-MM-DD returns the year." + (let ((result (chime--extract-birthday-year "2000-03-15"))) + (should (equal result 2000)))) + +(ert-deftest test-convert-normal-extract-birthday-year-without-year-returns-nil () + "Test extracting year from MM-DD returns nil." + (let ((result (chime--extract-birthday-year "03-15"))) + (should (null result)))) + +(ert-deftest test-convert-boundary-extract-birthday-year-very-old-date-returns-year () + "Test extracting year from very old date (1900s) returns the year." + (let ((result (chime--extract-birthday-year "1920-01-01"))) + (should (equal result 1920)))) + +(ert-deftest test-convert-boundary-extract-birthday-year-future-date-returns-year () + "Test extracting year from future date returns the year." + (let ((result (chime--extract-birthday-year "2100-12-31"))) + (should (equal result 2100)))) + +;;; Edge Case Tests + +(ert-deftest test-convert-edge-duplicate-timestamps-not-added () + "Test that running conversion twice doesn't add duplicate timestamps." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n")) + + ;; Run conversion once + (let ((backup1 (cdr (chime--convert-contacts-file-in-place temp-file)))) + ;; Clean up first backup to avoid file-already-exists error + (when (file-exists-p backup1) (delete-file backup1)) + + ;; Run conversion again + (let ((backup2 (cdr (chime--convert-contacts-file-in-place temp-file)))) + + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; Should have exactly one birthday timestamp + (should (= 1 (how-many "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (point-min) (point-max)))))) + + (when (file-exists-p backup2) (delete-file backup2))))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-edge-malformed-properties-drawer-missing-end () + "Test handling of properties drawer missing :END:." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\nMissing END tag\n")) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + ;; Should handle gracefully (likely returns 0 if drawer malformed) + (should (numberp count)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-edge-empty-birthday-property () + "Test handling of empty BIRTHDAY property value." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: \n:END:\n")) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + ;; Should skip empty birthday + (should (= count 0)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-edge-whitespace-in-birthday () + "Test handling of whitespace in BIRTHDAY property." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01 \n:END:\n")) + + (chime--convert-contacts-file-in-place temp-file) + + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; Should handle whitespace and create timestamp + (should (string-match-p "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" content))))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (let ((backup (concat temp-file ".backup-"))) + (dolist (file (directory-files (file-name-directory temp-file) t (regexp-quote (file-name-nondirectory backup)))) + (delete-file file)))))) + +(ert-deftest test-convert-edge-very-large-file () + "Test conversion of file with many contacts (performance test)." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (dotimes (i 100) + (insert (format "* Contact %d\n:PROPERTIES:\n:BIRTHDAY: 1990-01-01\n:END:\n\n" i)))) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + (should (= count 100)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +;;; Integration Tests + +(ert-deftest test-convert-integration-full-workflow-with-realistic-contacts () + "Integration test with realistic contacts file structure." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "#+TITLE: My Contacts\n#+STARTUP: overview\n\n") + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: alice@example.com\n") + (insert ":PHONE: 555-0101\n") + (insert ":BIRTHDAY: 1985-03-15\n") + (insert ":ADDRESS: 123 Main St\n") + (insert ":END:\n") + (insert "Met at conference 2023.\n\n") + (insert "* Bob Baker\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: bob@example.com\n") + (insert ":BIRTHDAY: 1990-07-22\n") + (insert ":END:\n\n") + (insert "* Carol Chen\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: carol@example.com\n") + (insert ":END:\n") + (insert "No birthday for Carol.\n")) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + + ;; Verify conversion count + (should (= count 2)) + + ;; Verify backup exists + (should (file-exists-p backup-file)) + + ;; Verify converted file content + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; File header preserved + (should (string-search "#+TITLE: My Contacts" content)) + + ;; Alice's properties preserved + (should (string-search ":EMAIL: alice@example.com" content)) + (should (string-search ":ADDRESS: 123 Main St" content)) + (should (string-search ":BIRTHDAY: 1985-03-15" content)) + + ;; Alice's birthday timestamp added + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)) + + ;; Alice's notes preserved + (should (string-search "Met at conference 2023" content)) + + ;; Bob's birthday timestamp added + (should (string-match-p "<1990-07-22 [A-Za-z]\\{3\\} \\+1y>" content)) + + ;; Carol has no timestamp added + (goto-char (point-min)) + (re-search-forward "\\* Carol Chen") + (let ((carol-section-start (point)) + (carol-section-end (or (re-search-forward "^\\* " nil t) (point-max)))) + (goto-char carol-section-start) + (should-not (re-search-forward "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" carol-section-end t))))) + + ;; Clean up backup + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-integration-mixed-birthday-formats () + "Integration test with both YYYY-MM-DD and MM-DD formats." + (let ((temp-file (make-temp-file "test-contacts" nil ".org")) + (current-year (nth 5 (decode-time)))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n\n") + (insert "* Bob Baker\n:PROPERTIES:\n:BIRTHDAY: 07-04\n:END:\n")) + + (chime--convert-contacts-file-in-place temp-file) + + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; Alice has year from property + (should (string-match-p "<1985-03-15" content)) + ;; Bob uses current year + (should (string-match-p (format "<%d-07-04" current-year) content))))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (let ((backup (concat temp-file ".backup-"))) + (dolist (file (directory-files (file-name-directory temp-file) t (regexp-quote (file-name-nondirectory backup)))) + (delete-file file)))))) + +(ert-deftest test-convert-integration-file-with-existing-content-preserved () + "Integration test verifying all existing content is preserved." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "#+TITLE: Contacts\n") + (insert "#+FILETAGS: :contacts:\n\n") + (insert "Some introductory text.\n\n") + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (insert "** Notes\n") + (insert "Some nested content.\n") + (insert "*** Deep nested\n") + (insert "More content.\n\n") + (insert "* Final Section\n") + (insert "Closing remarks.\n")) + + (chime--convert-contacts-file-in-place temp-file) + + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; All content preserved + (should (string-search "Some introductory text" content)) + (should (string-search "** Notes" content)) + (should (string-search "Some nested content" content)) + (should (string-search "*** Deep nested" content)) + (should (string-search "Closing remarks" content)) + (should (string-search "#+FILETAGS: :contacts:" content))))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (let ((backup (concat temp-file ".backup-"))) + (dolist (file (directory-files (file-name-directory temp-file) t (regexp-quote (file-name-nondirectory backup)))) + (delete-file file)))))) + +;;; Workflow Tests (Backup → Convert → Verify) + +(ert-deftest test-convert-workflow-backup-created-before-modification () + "Test that backup file is created before original is modified." + (let ((temp-file (make-temp-file "test-contacts" nil ".org")) + (original-content "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n")) + (unwind-protect + (progn + (with-temp-file temp-file + (insert original-content)) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (backup-file (cdr result))) + + ;; Backup exists + (should (file-exists-p backup-file)) + + ;; Backup contains original content (unmodified) + (with-temp-buffer + (insert-file-contents backup-file) + (should (string= (buffer-string) original-content))) + + ;; Original file is modified + (with-temp-buffer + (insert-file-contents temp-file) + (should (string-match-p "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (buffer-string)))) + + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-workflow-backup-content-matches-original () + "Test that backup file contains exact copy of original content." + (let ((temp-file (make-temp-file "test-contacts" nil ".org")) + (original-content "#+TITLE: Contacts\n\n* Alice\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n")) + (unwind-protect + (progn + (with-temp-file temp-file + (insert original-content)) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (backup-file (cdr result))) + + ;; Backup content matches original + (with-temp-buffer + (insert-file-contents backup-file) + (should (string= (buffer-string) original-content))) + + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-workflow-original-modified-with-timestamps () + "Test that original file is modified with timestamps after conversion." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n") + (insert "* Bob\n:PROPERTIES:\n:BIRTHDAY: 1990-07-22\n:END:\n")) + + ;; Get original content + (let ((original-content nil)) + (with-temp-buffer + (insert-file-contents temp-file) + (setq original-content (buffer-string))) + + ;; Should not have timestamps before conversion + (should-not (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]\\{3\\} \\+1y>" original-content)) + + ;; Convert + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + + (should (= count 2)) + + ;; Modified content should have timestamps + (with-temp-buffer + (insert-file-contents temp-file) + (let ((modified-content (buffer-string))) + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" modified-content)) + (should (string-match-p "<1990-07-22 [A-Za-z]\\{3\\} \\+1y>" modified-content)))) + + (when (file-exists-p backup-file) (delete-file backup-file))))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-workflow-rollback-via-backup () + "Test that conversion can be rolled back by restoring from backup." + (let ((temp-file (make-temp-file "test-contacts" nil ".org")) + (original-content "* Alice\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n")) + (unwind-protect + (progn + (with-temp-file temp-file + (insert original-content)) + + ;; Convert + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (backup-file (cdr result))) + + ;; Verify file was modified + (with-temp-buffer + (insert-file-contents temp-file) + (should (string-match-p "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (buffer-string)))) + + ;; Rollback by copying backup over original + (copy-file backup-file temp-file t) + + ;; Verify rollback worked + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + (should (string= content original-content)) + (should-not (string-match-p "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" content)))) + + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-workflow-multiple-backups-distinct-timestamps () + "Test that multiple conversions create backups with distinct timestamps." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n")) + + ;; First conversion + (let ((backup1 (cdr (chime--convert-contacts-file-in-place temp-file)))) + ;; Small delay to ensure different timestamp + (sleep-for 1) + ;; Second conversion + (let ((backup2 (cdr (chime--convert-contacts-file-in-place temp-file)))) + + ;; Backup files should have different names + (should-not (string= backup1 backup2)) + + ;; Both should exist + (should (file-exists-p backup1)) + (should (file-exists-p backup2)) + + (when (file-exists-p backup1) (delete-file backup1)) + (when (file-exists-p backup2) (delete-file backup2))))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(provide 'test-convert-org-contacts-birthdays) +;;; test-convert-org-contacts-birthdays.el ends here diff --git a/tests/test-integration-recurring-events-tooltip.el b/tests/test-integration-recurring-events-tooltip.el new file mode 100644 index 0000000..6acfac3 --- /dev/null +++ b/tests/test-integration-recurring-events-tooltip.el @@ -0,0 +1,370 @@ +;;; test-integration-recurring-events-tooltip.el --- Integration tests for recurring events in tooltip -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Integration tests for bug001: Recurring Events Show Duplicate Entries in Tooltip +;; +;; Tests the complete workflow from org entries with recurring timestamps +;; through org-agenda-list expansion to final tooltip display. +;; +;; Components integrated: +;; - org-agenda-list (org-mode function that expands recurring events) +;; - chime--gather-info (extracts event information from org markers) +;; - chime--deduplicate-events-by-title (deduplicates expanded recurring events) +;; - chime--update-modeline (updates modeline and upcoming events list) +;; - chime--make-tooltip (generates tooltip from upcoming events) +;; +;; Validates: +;; - Recurring events expanded by org-agenda-list are deduplicated correctly +;; - Tooltip shows each recurring event only once (the soonest occurrence) +;; - Multiple different events are all preserved +;; - Mixed recurring and non-recurring events work correctly + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(setq chime-debug t) +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defvar test-integration-recurring--orig-agenda-files nil + "Original org-agenda-files value before test.") + +(defvar test-integration-recurring--orig-modeline-lookahead nil + "Original chime-modeline-lookahead-minutes value.") + +(defvar test-integration-recurring--orig-tooltip-lookahead nil + "Original chime-tooltip-lookahead-hours value.") + +(defun test-integration-recurring-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Save original values + (setq test-integration-recurring--orig-agenda-files org-agenda-files) + (setq test-integration-recurring--orig-modeline-lookahead chime-modeline-lookahead-minutes) + (setq test-integration-recurring--orig-tooltip-lookahead chime-tooltip-lookahead-hours) + ;; Set lookahead to 1 year for testing recurring events + (setq chime-modeline-lookahead-minutes 525600) ; 365 days + (setq chime-tooltip-lookahead-hours 8760)) ; 365 days + +(defun test-integration-recurring-teardown () + "Teardown function run after each test." + ;; Restore original values + (setq org-agenda-files test-integration-recurring--orig-agenda-files) + (setq chime-modeline-lookahead-minutes test-integration-recurring--orig-modeline-lookahead) + (setq chime-tooltip-lookahead-hours test-integration-recurring--orig-tooltip-lookahead) + (chime-delete-test-base-dir)) + +;;; Helper Functions + +(defun test-integration-recurring--create-org-file (content) + "Create org file with CONTENT and set it as org-agenda-files. +Returns the file path." + (let* ((base-file (chime-create-temp-test-file "recurring-test-")) + (org-file (concat base-file ".org"))) + ;; Rename to have .org extension + (rename-file base-file org-file) + ;; Write content to the .org file + (with-temp-buffer + (insert content) + (write-file org-file)) + ;; Set as agenda file + (setq org-agenda-files (list org-file)) + org-file)) + +(defun test-integration-recurring--run-agenda-and-gather-events (agenda-span) + "Run org-agenda-list with AGENDA-SPAN and gather event markers. +Returns list of event info gathered from markers." + (let ((markers nil) + (events nil) + (org-buffers nil)) + ;; Remember which buffers are open before agenda + (setq org-buffers (buffer-list)) + ;; org-agenda-list doesn't return the buffer, it creates "*Org Agenda*" + (org-agenda-list agenda-span (org-read-date nil nil "today")) + (with-current-buffer "*Org Agenda*" + ;; Extract all org-markers from agenda buffer + (setq markers + (->> (org-split-string (buffer-string) "\n") + (--map (plist-get + (org-fix-agenda-info (text-properties-at 0 it)) + 'org-marker)) + (-non-nil)))) + ;; Gather info for each marker BEFORE killing buffers + ;; (markers point to the org file buffers, which must stay alive) + (setq events (-map 'chime--gather-info markers)) + ;; Now kill agenda buffer to clean up + (when (get-buffer "*Org Agenda*") + (kill-buffer "*Org Agenda*")) + events)) + +(defun test-integration-recurring--count-in-string (regexp string) + "Count occurrences of REGEXP in STRING." + (let ((count 0) + (start 0)) + (while (string-match regexp string start) + (setq count (1+ count)) + (setq start (match-end 0))) + count)) + +;;; Normal Cases - Recurring Event Deduplication + +(ert-deftest test-integration-recurring-events-tooltip-daily-repeater-shows-once () + "Test that daily recurring event appears only once in tooltip. + +When org-agenda-list expands a daily recurring event (e.g., +1d) over a +year-long lookahead window, it creates ~365 separate agenda entries. +The tooltip should show only the soonest occurrence, not all 365. + +Components integrated: +- org-agenda-list (expands recurring events into separate instances) +- chime--gather-info (extracts info from each expanded marker) +- chime--deduplicate-events-by-title (removes duplicate titles) +- chime--update-modeline (updates upcoming events list) +- chime--make-tooltip (generates tooltip display) + +Validates: +- Recurring event expanded 365 times is deduplicated to one entry +- Tooltip shows event title exactly once +- The shown occurrence is the soonest one" + (test-integration-recurring-setup) + (unwind-protect + (let* ((now (test-time-now)) + (event-time (test-time-today-at 22 0)) ; 10 PM today + (timestamp (test-timestamp-repeating event-time "+1d")) + (content (format "* Daily Wrap Up\n%s\n" timestamp))) + + ;; Create org file with recurring event + (test-integration-recurring--create-org-file content) + + (with-test-time now + ;; Run org-agenda-list to expand recurring events (365 days) + (let* ((events (test-integration-recurring--run-agenda-and-gather-events 365))) + + ;; Verify org-agenda-list expanded the recurring event multiple times + ;; (should be ~365 instances, one per day) + (should (> (length events) 300)) + + ;; All events should have the same title + (let ((titles (mapcar (lambda (e) (cdr (assoc 'title e))) events))) + (should (cl-every (lambda (title) (string= "Daily Wrap Up" title)) titles))) + + ;; Update modeline with these events (this triggers deduplication) + (chime--update-modeline events) + + ;; The upcoming events list should have only ONE entry + (should (= 1 (length chime--upcoming-events))) + + ;; Verify it's the right event + (let* ((item (car chime--upcoming-events)) + (event (car item)) + (title (cdr (assoc 'title event)))) + (should (string= "Daily Wrap Up" title))) + + ;; Generate tooltip and verify event appears only once + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (stringp tooltip)) + ;; Count occurrences of "Daily Wrap Up" in tooltip + (let ((count (test-integration-recurring--count-in-string + "Daily Wrap Up" tooltip))) + (should (= 1 count))))))) + (test-integration-recurring-teardown))) + +(ert-deftest test-integration-recurring-events-tooltip-weekly-repeater-shows-once () + "Test that weekly recurring event appears only once in tooltip. + +Weekly repeater (+1w) over 1 year creates ~52 instances. +Should be deduplicated to show only the soonest. + +Components integrated: +- org-agenda-list (expands +1w into 52 instances) +- chime--gather-info (processes each instance) +- chime--deduplicate-events-by-title (deduplicates by title) +- chime--update-modeline (updates events list) +- chime--make-tooltip (generates display) + +Validates: +- Weekly recurring events are deduplicated correctly +- Tooltip shows only soonest occurrence" + (test-integration-recurring-setup) + (unwind-protect + (let* ((now (test-time-now)) + (event-time (test-time-tomorrow-at 14 0)) ; 2 PM tomorrow + (timestamp (test-timestamp-repeating event-time "+1w")) + (content (format "* Weekly Team Sync\n%s\n" timestamp))) + + (test-integration-recurring--create-org-file content) + + (with-test-time now + (let* ((events (test-integration-recurring--run-agenda-and-gather-events 365))) + + ;; Should have ~52 weekly instances + (should (> (length events) 45)) + (should (< (length events) 60)) + + ;; Update modeline + (chime--update-modeline events) + + ;; Should be deduplicated to one + (should (= 1 (length chime--upcoming-events))) + + ;; Verify tooltip shows it once + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (let ((count (test-integration-recurring--count-in-string + "Weekly Team Sync" tooltip))) + (should (= 1 count))))))) + (test-integration-recurring-teardown))) + +(ert-deftest test-integration-recurring-events-tooltip-mixed-recurring-and-unique () + "Test mix of recurring and non-recurring events in tooltip. + +When multiple events exist, some recurring and some not, all should +appear but recurring ones should be deduplicated. + +Components integrated: +- org-agenda-list (expands only the recurring event) +- chime--gather-info (processes all events) +- chime--deduplicate-events-by-title (deduplicates recurring only) +- chime--update-modeline (updates events list) +- chime--make-tooltip (generates display) + +Validates: +- Recurring event appears once +- Non-recurring events all appear +- Total count is correct (recurring deduplicated, others preserved)" + (test-integration-recurring-setup) + (unwind-protect + (let* ((now (test-time-now)) + (daily-time (test-time-today-at 22 0)) + (meeting-time (test-time-tomorrow-at 14 0)) + (review-time (test-time-days-from-now 2 15 0)) + (daily-ts (test-timestamp-repeating daily-time "+1d")) + (meeting-ts (test-timestamp-string meeting-time)) + (review-ts (test-timestamp-string review-time)) + (content (concat + (format "* Daily Standup\n%s\n\n" daily-ts) + (format "* Project Review\n%s\n\n" meeting-ts) + (format "* Client Meeting\n%s\n" review-ts)))) + + (test-integration-recurring--create-org-file content) + + (with-test-time now + (let* ((events (test-integration-recurring--run-agenda-and-gather-events 365))) + + ;; Should have many events due to daily repeater + (should (> (length events) 300)) + + ;; Update modeline (triggers deduplication) + (chime--update-modeline events) + + ;; Should have exactly 3 events (daily deduplicated + 2 unique) + (should (= 3 (length chime--upcoming-events))) + + ;; Verify all three titles appear + (let ((titles (mapcar (lambda (item) + (cdr (assoc 'title (car item)))) + chime--upcoming-events))) + (should (member "Daily Standup" titles)) + (should (member "Project Review" titles)) + (should (member "Client Meeting" titles)) + ;; No duplicates + (should (= 3 (length (delete-dups (copy-sequence titles)))))) + + ;; Verify tooltip shows each event once + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (= 1 (test-integration-recurring--count-in-string + "Daily Standup" tooltip))) + (should (= 1 (test-integration-recurring--count-in-string + "Project Review" tooltip))) + (should (= 1 (test-integration-recurring--count-in-string + "Client Meeting" tooltip))))))) + (test-integration-recurring-teardown))) + +;;; Boundary Cases + +(ert-deftest test-integration-recurring-events-tooltip-multiple-different-recurring () + "Test multiple different recurring events are all shown once. + +When there are multiple recurring events with different titles, +each should appear once in the tooltip. + +Components integrated: +- org-agenda-list (expands all recurring events) +- chime--gather-info (processes all expanded instances) +- chime--deduplicate-events-by-title (deduplicates each title separately) +- chime--update-modeline (updates events list) +- chime--make-tooltip (generates display) + +Validates: +- Each recurring event title appears exactly once +- Different recurring frequencies handled correctly +- Deduplication works independently for each title" + (test-integration-recurring-setup) + (unwind-protect + (let* ((now (test-time-now)) + (daily-time (test-time-today-at 11 0)) ; 11 AM (after 10 AM now) + (weekly-time (test-time-tomorrow-at 14 0)) ; 2 PM tomorrow + (daily-ts (test-timestamp-repeating daily-time "+1d")) + (weekly-ts (test-timestamp-repeating weekly-time "+1w")) + (content (concat + (format "* Daily Standup\n%s\n\n" daily-ts) + (format "* Weekly Review\n%s\n" weekly-ts)))) + + (test-integration-recurring--create-org-file content) + + (with-test-time now + (let* ((events (test-integration-recurring--run-agenda-and-gather-events 365))) + + ;; Should have many events (~365 daily + ~52 weekly) + (should (> (length events) 400)) + + ;; Update modeline + (chime--update-modeline events) + + ;; Should be deduplicated to 2 events + (should (= 2 (length chime--upcoming-events))) + + ;; Verify tooltip shows each once + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (= 1 (test-integration-recurring--count-in-string + "Daily Standup" tooltip))) + (should (= 1 (test-integration-recurring--count-in-string + "Weekly Review" tooltip))))))) + (test-integration-recurring-teardown))) + +(provide 'test-integration-recurring-events-tooltip) +;;; test-integration-recurring-events-tooltip.el ends here diff --git a/tests/test-integration-startup.el b/tests/test-integration-startup.el new file mode 100644 index 0000000..f7e0861 --- /dev/null +++ b/tests/test-integration-startup.el @@ -0,0 +1,328 @@ +;;; test-integration-startup.el --- Integration tests for chime startup flow -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Integration tests for chime startup and configuration validation. +;; +;; Tests the complete startup workflow: +;; - Valid org-agenda-files configuration +;; - chime-validate-configuration checks +;; - chime-check async event gathering +;; - Modeline population +;; +;; Components integrated: +;; - chime-validate-configuration (validates runtime requirements) +;; - chime-check (async event gathering via org-agenda-list) +;; - chime--update-modeline (updates modeline string) +;; - org-agenda-list (expands events from org files) +;; +;; Validates: +;; - Chime finds correct number of events from org-agenda-files +;; - Validation passes with proper configuration +;; - Modeline gets populated after check completes +;; - Mixed event types (scheduled, deadline, TODO states) work correctly + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(setq chime-debug t) +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defvar test-integration-startup--orig-agenda-files nil + "Original org-agenda-files value before test.") + +(defvar test-integration-startup--orig-startup-delay nil + "Original chime-startup-delay value.") + +(defvar test-integration-startup--orig-modeline-lookahead nil + "Original chime-modeline-lookahead-minutes value.") + +(defvar test-integration-startup--orig-tooltip-lookahead nil + "Original chime-tooltip-lookahead-hours value.") + +(defun test-integration-startup-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Save original values + (setq test-integration-startup--orig-agenda-files org-agenda-files) + (setq test-integration-startup--orig-startup-delay chime-startup-delay) + (setq test-integration-startup--orig-modeline-lookahead chime-modeline-lookahead-minutes) + (setq test-integration-startup--orig-tooltip-lookahead chime-tooltip-lookahead-hours) + ;; Set short lookahead for faster tests + (setq chime-modeline-lookahead-minutes (* 24 60)) ; 24 hours + (setq chime-tooltip-lookahead-hours 24) ; 24 hours + (setq chime-startup-delay 1)) ; 1 second for tests + +(defun test-integration-startup-teardown () + "Teardown function run after each test." + ;; Restore original values + (setq org-agenda-files test-integration-startup--orig-agenda-files) + (setq chime-startup-delay test-integration-startup--orig-startup-delay) + (setq chime-modeline-lookahead-minutes test-integration-startup--orig-modeline-lookahead) + (setq chime-tooltip-lookahead-hours test-integration-startup--orig-tooltip-lookahead) + (chime-delete-test-base-dir)) + +;;; Helper Functions + +(defun test-integration-startup--create-org-file (content) + "Create org file with CONTENT and set it as org-agenda-files. +Returns the file path." + (let* ((base-file (chime-create-temp-test-file "startup-test-")) + (org-file (concat base-file ".org"))) + ;; Rename to have .org extension + (rename-file base-file org-file) + ;; Write content to the .org file + (with-temp-buffer + (insert content) + (write-file org-file)) + ;; Set as agenda file + (setq org-agenda-files (list org-file)) + org-file)) + +;;; Normal Cases - Valid Startup Configuration + +(ert-deftest test-integration-startup-valid-config-finds-events () + "Test that chime-check finds events with valid org-agenda-files configuration. + +This is the core startup integration test. Validates that when: +- org-agenda-files is properly configured with real .org files +- Files contain scheduled/deadline events +- chime-check is called (normally triggered by startup timer) + +Then: +- Events are successfully gathered from org-agenda-list +- Event count matches expected number +- Modeline string gets populated + +Components integrated: +- org-agenda-files (user configuration) +- org-agenda-list (expands events from org files) +- chime-check (async wrapper around event gathering) +- chime--gather-info (extracts event details) +- chime--update-modeline (updates modeline display)" + (test-integration-startup-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Create events at various times + (event1-time (test-time-at 0 2 0)) ; 2 hours from now + (event2-time (test-time-at 0 5 0)) ; 5 hours from now + (event3-time (test-time-at 1 0 0)) ; Tomorrow same time + (event4-time (test-time-at -1 0 0)) ; Yesterday (overdue) + ;; Generate timestamps + (ts1 (test-timestamp-string event1-time)) + (ts2 (test-timestamp-string event2-time)) + (ts3 (test-timestamp-string event3-time)) + (ts4 (test-timestamp-string event4-time)) + ;; Create org file content + (content (format "#+TITLE: Startup Test Events + +* TODO Event in 2 hours +SCHEDULED: %s + +* TODO Event in 5 hours +SCHEDULED: %s + +* TODO Event tomorrow +SCHEDULED: %s + +* TODO Overdue event +SCHEDULED: %s + +* DONE Completed event (should not notify) +SCHEDULED: %s +" ts1 ts2 ts3 ts4 ts1))) + + ;; Create org file and set as agenda files + (test-integration-startup--create-org-file content) + + ;; Validate configuration should pass + (let ((issues (chime-validate-configuration))) + (should (null issues))) + + (with-test-time now + ;; Call chime-check synchronously (bypasses async/timer for test reliability) + ;; In real startup, this is called via run-at-time after chime-startup-delay + (let ((event-count 0)) + ;; Mock the async-start to run synchronously for testing + (cl-letf (((symbol-function 'async-start) + (lambda (start-func finish-func) + ;; Call start-func synchronously and pass result to finish-func + (funcall finish-func (funcall start-func))))) + ;; Now call chime-check - it will run synchronously + (chime-check) + + ;; Give it a moment to process + (sleep-for 0.1) + + ;; Verify modeline was updated + (should chime-modeline-string) + + ;; Verify we found events (should be 4 TODO events, DONE excluded) + ;; Note: The exact behavior depends on chime's filtering logic + (should chime--upcoming-events) + (setq event-count (length chime--upcoming-events)) + + ;; Should find at least the non-DONE events within lookahead window + (should (>= event-count 3)))))) ; At least 3 events (2h, 5h, tomorrow) + (test-integration-startup-teardown))) + +(ert-deftest test-integration-startup-validation-passes-minimal-config () + "Test validation passes with minimal valid configuration. + +Validates that chime-validate-configuration returns nil (no issues) when: +- org-agenda-files is set to a list with at least one .org file +- The file exists on disk +- org-agenda package is loadable +- All other dependencies are available + +This ensures the startup validation doesn't block legitimate configurations." + (test-integration-startup-setup) + (unwind-protect + (let ((content "#+TITLE: Minimal Test\n\n* TODO Test event\nSCHEDULED: <2025-12-01 Mon 10:00>\n")) + ;; Create minimal org file + (test-integration-startup--create-org-file content) + + ;; Validation should pass + (let ((issues (chime-validate-configuration))) + (should (null issues)))) + (test-integration-startup-teardown))) + +;;; Error Cases - Configuration Failures + +(ert-deftest test-integration-startup-early-return-on-validation-failure () + "Test that chime-check returns early when validation fails without throwing errors. + +This is a regression test for the bug where chime-check used cl-return-from +without being defined as cl-defun, causing '(no-catch --cl-block-chime-check-- nil)' error. + +When validation fails on first check, chime-check should: +- Log the validation failure +- Return nil early (via cl-return-from) +- NOT throw 'no-catch' error +- NOT proceed to event gathering + +This validates the early-return mechanism works correctly." + (test-integration-startup-setup) + (unwind-protect + (progn + ;; Set up invalid configuration (empty org-agenda-files) + (setq org-agenda-files nil) + + ;; Reset validation state so chime-check will validate on next call + (setq chime--validation-done nil) + + ;; Call chime-check - should return early without error + ;; Before the fix, this would throw: (no-catch --cl-block-chime-check-- nil) + (let ((result (chime-check))) + + ;; Should return nil (early return from validation failure) + (should (null result)) + + ;; Validation should NOT be marked done when it fails + ;; (so it can retry on next check in case dependencies load later) + (should (null chime--validation-done)) + + ;; Should NOT have processed any events (early return worked) + (should (null chime--upcoming-events)) + (should (null chime-modeline-string)))) + (test-integration-startup-teardown))) + +;;; Boundary Cases - Edge Conditions + +(ert-deftest test-integration-startup-single-event-found () + "Test that chime-check correctly finds a single event. + +Boundary case: org-agenda-files with only one event. +Validates that the gathering and modeline logic work with minimal data." + (test-integration-startup-setup) + (unwind-protect + (let* ((now (test-time-now)) + (event-time (test-time-at 0 1 0)) ; 1 hour from now + (ts (test-timestamp-string event-time)) + (content (format "* TODO Single Event\nSCHEDULED: %s\n" ts))) + + (test-integration-startup--create-org-file content) + + (with-test-time now + (cl-letf (((symbol-function 'async-start) + (lambda (start-func finish-func) + (funcall finish-func (funcall start-func))))) + (chime-check) + (sleep-for 0.1) + + ;; Should find exactly 1 event + (should (= 1 (length chime--upcoming-events))) + + ;; Modeline should be populated + (should chime-modeline-string) + (should (string-match-p "Single Event" chime-modeline-string))))) + (test-integration-startup-teardown))) + +(ert-deftest test-integration-startup-no-upcoming-events () + "Test chime-check when org file has no upcoming events within lookahead. + +Boundary case: Events exist but are far in the future (beyond lookahead window). +Validates that chime doesn't error and modeline shows appropriate state." + (test-integration-startup-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 30 days from now (beyond 24-hour lookahead) + (event-time (test-time-at 30 0 0)) + (ts (test-timestamp-string event-time)) + (content (format "* TODO Future Event\nSCHEDULED: %s\n" ts))) + + (test-integration-startup--create-org-file content) + + (with-test-time now + (cl-letf (((symbol-function 'async-start) + (lambda (start-func finish-func) + (funcall finish-func (funcall start-func))))) + (chime-check) + (sleep-for 0.1) + + ;; Should find 0 events within lookahead window + (should (or (null chime--upcoming-events) + (= 0 (length chime--upcoming-events)))) + + ;; Modeline should handle this gracefully (nil or empty) + ;; No error should occur + ))) + (test-integration-startup-teardown))) + +(provide 'test-integration-startup) +;;; test-integration-startup.el ends here diff --git a/tests/tests-autoloads.el b/tests/tests-autoloads.el new file mode 100644 index 0000000..88449cb --- /dev/null +++ b/tests/tests-autoloads.el @@ -0,0 +1,148 @@ +;;; tests-autoloads.el --- automatically extracted autoloads (do not edit) -*- lexical-binding: t -*- +;; Generated by the `loaddefs-generate' function. + +;; This file is part of GNU Emacs. + +;;; Code: + +(add-to-list 'load-path (or (and load-file-name (directory-file-name (file-name-directory load-file-name))) (car load-path))) + + + +;;; Generated autoloads from test-chime-all-day-events.el + +(register-definition-prefixes "test-chime-all-day-events" '("test-allday--create-event")) + + +;;; Generated autoloads from test-chime-apply-blacklist.el + +(register-definition-prefixes "test-chime-apply-blacklist" '("test-chime-apply-blacklist-")) + + +;;; Generated autoloads from test-chime-apply-whitelist.el + +(register-definition-prefixes "test-chime-apply-whitelist" '("test-chime-apply-whitelist-")) + + +;;; Generated autoloads from test-chime-check-event.el + +(register-definition-prefixes "test-chime-check-event" '("test-chime-check-event-")) + + +;;; Generated autoloads from test-chime-check-interval.el + +(register-definition-prefixes "test-chime-check-interval" '("test-chime-check-interval-")) + + +;;; Generated autoloads from test-chime-extract-time.el + +(register-definition-prefixes "test-chime-extract-time" '("test-chime-extract-time-")) + + +;;; Generated autoloads from test-chime-format-event-for-tooltip.el + +(register-definition-prefixes "test-chime-format-event-for-tooltip" '("test-chime-format-event-for-tooltip-")) + + +;;; Generated autoloads from test-chime-gather-info.el + +(register-definition-prefixes "test-chime-gather-info" '("test-chime-gather-info-")) + + +;;; Generated autoloads from test-chime-group-events-by-day.el + +(register-definition-prefixes "test-chime-group-events-by-day" '("test-chime-")) + + +;;; Generated autoloads from test-chime-has-timestamp.el + +(register-definition-prefixes "test-chime-has-timestamp" '("test-chime-has-timestamp-")) + + +;;; Generated autoloads from test-chime-modeline.el + +(register-definition-prefixes "test-chime-modeline" '("test-chime-modeline-")) + + +;;; Generated autoloads from test-chime-notification-text.el + +(register-definition-prefixes "test-chime-notification-text" '("test-chime-notification-text-")) + + +;;; Generated autoloads from test-chime-notifications.el + +(register-definition-prefixes "test-chime-notifications" '("test-chime-notifications-")) + + +;;; Generated autoloads from test-chime-notify.el + +(register-definition-prefixes "test-chime-notify" '("test-chime-notify-")) + + +;;; Generated autoloads from test-chime-overdue-todos.el + +(register-definition-prefixes "test-chime-overdue-todos" '("test-")) + + +;;; Generated autoloads from test-chime-process-notifications.el + +(register-definition-prefixes "test-chime-process-notifications" '("test-chime-process-notifications-")) + + +;;; Generated autoloads from test-chime-sanitize-title.el + +(register-definition-prefixes "test-chime-sanitize-title" '("test-chime-sanitize-title-")) + + +;;; Generated autoloads from test-chime-time-left.el + +(register-definition-prefixes "test-chime-time-left" '("test-chime-time-left-")) + + +;;; Generated autoloads from test-chime-timestamp-parse.el + +(register-definition-prefixes "test-chime-timestamp-parse" '("test-chime-timestamp-parse-")) + + +;;; Generated autoloads from test-chime-timestamp-within-interval-p.el + +(register-definition-prefixes "test-chime-timestamp-within-interval-p" '("test-chime-timestamp-within-interval-p-")) + + +;;; Generated autoloads from test-chime-tooltip-bugs.el + +(register-definition-prefixes "test-chime-tooltip-bugs" '("test-tooltip-bugs-")) + + +;;; Generated autoloads from test-chime-update-modeline.el + +(register-definition-prefixes "test-chime-update-modeline" '("test-chime-update-modeline-")) + + +;;; Generated autoloads from test-chime-whitelist-blacklist-conflicts.el + +(register-definition-prefixes "test-chime-whitelist-blacklist-conflicts" '("test-chime-conflicts-")) + + +;;; Generated autoloads from testutil-general.el + +(register-definition-prefixes "testutil-general" '("chime-")) + + +;;; Generated autoloads from testutil-time.el + +(register-definition-prefixes "testutil-time" '("test-time" "with-test-time")) + +;;; End of scraped data + +(provide 'tests-autoloads) + +;; Local Variables: +;; version-control: never +;; no-byte-compile: t +;; no-update-autoloads: t +;; no-native-compile: t +;; coding: utf-8-emacs-unix +;; End: + +;;; tests-autoloads.el ends here diff --git a/tests/testutil-events.el b/tests/testutil-events.el new file mode 100644 index 0000000..c69739e --- /dev/null +++ b/tests/testutil-events.el @@ -0,0 +1,214 @@ +;;; testutil-events.el --- Event creation and gathering utilities for tests -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Utilities for creating and gathering events in tests. +;; Reduces duplication across test files that work with org events. +;; +;; Key functions: +;; - test-create-org-event: Create org event content string +;; - test-gather-events-from-content: Create file, gather events, clean up +;; - test-make-event-data: Create event data structure programmatically + +;;; Code: + +(require 'testutil-general) +(require 'testutil-time) + +;;; Event Content Creation + +(defun test-create-org-event (title time &optional scheduled-p all-day-p) + "Create org event content string with TITLE at TIME. +If SCHEDULED-P is non-nil, use SCHEDULED: keyword (default is plain timestamp). +If ALL-DAY-P is non-nil, create all-day event without time component. +Returns formatted org content string. + +Examples: + (test-create-org-event \"Meeting\" (test-time-now)) + => \"* Meeting\\n<2025-01-15 Wed 10:00>\\n\" + + (test-create-org-event \"Call\" (test-time-now) t) + => \"* TODO Call\\nSCHEDULED: <2025-01-15 Wed 10:00>\\n\" + + (test-create-org-event \"Birthday\" (test-time-now) nil t) + => \"* Birthday\\n<2025-01-15 Wed>\\n\"" + (let ((timestamp (test-timestamp-string time all-day-p)) + (todo-kw (if scheduled-p "TODO " ""))) + (if scheduled-p + (format "* %s%s\nSCHEDULED: %s\n" todo-kw title timestamp) + (format "* %s\n%s\n" title timestamp)))) + +(defun test-create-org-events (event-specs) + "Create multiple org events from EVENT-SPECS list. +Each spec is (TITLE TIME &optional SCHEDULED-P ALL-DAY-P). +Returns concatenated org content string. + +Example: + (test-create-org-events + '((\"Meeting\" ,(test-time-at 0 2 0) t) + (\"Call\" ,(test-time-at 0 4 0) t))) + => \"* TODO Meeting\\nSCHEDULED: ...\\n* TODO Call\\nSCHEDULED: ...\\n\"" + (mapconcat (lambda (spec) + (apply #'test-create-org-event spec)) + event-specs + "\n")) + +;;; Event Gathering + +(defun test-gather-events-from-content (content) + "Create temp org file with CONTENT, gather events using chime--gather-info. +Returns list of event data structures. +Automatically creates and cleans up buffer. + +Example: + (let* ((content (test-create-org-event \"Meeting\" (test-time-now) t)) + (events (test-gather-events-from-content content))) + (should (= 1 (length events))) + (should (string= \"Meeting\" (cdr (assoc 'title (car events))))))" + (let* ((test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + (events nil)) + (unwind-protect + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (push (chime--gather-info (point-marker)) events) + (forward-line 1)) + (nreverse events)) + (kill-buffer test-buffer)))) + +(defun test-gather-single-event-from-content (content) + "Like test-gather-events-from-content but returns single event (not list). +Signals error if content contains multiple events. +Useful when test expects exactly one event. + +Example: + (let* ((content (test-create-org-event \"Call\" (test-time-now) t)) + (event (test-gather-single-event-from-content content))) + (should (string= \"Call\" (cdr (assoc 'title event)))))" + (let ((events (test-gather-events-from-content content))) + (unless (= 1 (length events)) + (error "Expected exactly 1 event, found %d" (length events))) + (car events))) + +;;; Event Data Structure Creation + +(defun test-make-event-data (title time-alist &optional intervals) + "Create event data structure programmatically. +TITLE is the event title string. +TIME-ALIST is list of (TIMESTAMP-STR . TIME-OBJECT) cons cells. +INTERVALS is optional list of (MINUTES . SEVERITY) cons cells. + +This is useful for creating events without going through org-mode parsing. + +Example: + (let* ((time (test-time-now)) + (ts-str (test-timestamp-string time)) + (event (test-make-event-data + \"Meeting\" + (list (cons ts-str time)) + '((10 . medium))))) + (should (string= \"Meeting\" (cdr (assoc 'title event)))))" + `((times . ,time-alist) + (title . ,title) + (intervals . ,(or intervals '((10 . medium)))))) + +(defun test-make-simple-event (title time &optional interval-minutes severity) + "Create simple event data structure with single time and interval. +TITLE is event title. +TIME is the event time (Emacs time object). +INTERVAL-MINUTES defaults to 10. +SEVERITY defaults to 'medium. + +Convenience wrapper around test-make-event-data for common case. + +Example: + (let ((event (test-make-simple-event \"Call\" (test-time-now) 5 'high))) + (should (string= \"Call\" (cdr (assoc 'title event)))))" + (let* ((ts-str (test-timestamp-string time)) + (interval (or interval-minutes 10)) + (sev (or severity 'medium))) + (test-make-event-data title + (list (cons ts-str time)) + (list (cons interval sev))))) + +;;; Macros for Common Test Patterns + +(defmacro with-test-event-file (content &rest body) + "Create temp org file with CONTENT, execute BODY, clean up. +Binds `test-file' and `test-buffer' in BODY. + +Example: + (with-test-event-file (test-create-org-event \"Meeting\" (test-time-now)) + (with-current-buffer test-buffer + (goto-char (point-min)) + (should (search-forward \"Meeting\" nil t))))" + (declare (indent 1)) + `(let* ((test-file (chime-create-temp-test-file-with-content ,content)) + (test-buffer (find-file-noselect test-file))) + (unwind-protect + (progn ,@body) + (kill-buffer test-buffer)))) + +(defmacro with-gathered-events (content events-var &rest body) + "Create temp file with CONTENT, gather events into EVENTS-VAR, execute BODY. +Automatically creates file, gathers events, and cleans up. + +Example: + (with-gathered-events (test-create-org-event \"Call\" (test-time-now)) + events + (should (= 1 (length events))) + (should (string= \"Call\" (cdr (assoc 'title (car events))))))" + (declare (indent 2)) + `(let ((,events-var (test-gather-events-from-content ,content))) + ,@body)) + +;;; Setup/Teardown Helpers + +(defun test-standard-setup () + "Standard setup for tests: create test base dir. +Most tests can use this instead of custom setup function." + (chime-create-test-base-dir)) + +(defun test-standard-teardown () + "Standard teardown for tests: delete test base dir. +Most tests can use this instead of custom teardown function." + (chime-delete-test-base-dir)) + +(defmacro with-test-setup (&rest body) + "Execute BODY with standard test setup/teardown. +Ensures test base dir is created before and cleaned up after. + +Example: + (ert-deftest test-something () + (with-test-setup + (let ((file (chime-create-temp-test-file))) + (should (file-exists-p file)))))" + (declare (indent 0)) + `(progn + (test-standard-setup) + (unwind-protect + (progn ,@body) + (test-standard-teardown)))) + +(provide 'testutil-events) +;;; testutil-events.el ends here diff --git a/tests/testutil-general.el b/tests/testutil-general.el new file mode 100644 index 0000000..556e520 --- /dev/null +++ b/tests/testutil-general.el @@ -0,0 +1,184 @@ +;;; testutil-general.el --- -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; This library provides general helper functions and constants for managing +;; test directories and files across test suites. +;; +;; It establishes a user-local hidden directory as the root for all test assets, +;; provides utilities to create this directory safely, create temporary files +;; and subdirectories within it, and clean up after tests. +;; +;; This library should be required by test suites to ensure consistent, +;; reliable, and isolated file-system resources. +;; +;;; Code: + +(defconst chime-test-base-dir + (expand-file-name "~/.temp-chime-tests/") + "Base directory for all CHIME test files and directories. +All test file-system artifacts should be created under this hidden +directory in the user's home. This avoids relying on ephemeral system +directories like /tmp and reduces flaky test failures caused by external +cleanup.") + +(defun chime-create-test-base-dir () + "Create the test base directory `chime-test-base-dir' if it does not exist. +Returns the absolute path to the test base directory. +Signals an error if creation fails." + (let ((dir (file-name-as-directory chime-test-base-dir))) + (unless (file-directory-p dir) + (make-directory dir t)) + (if (file-directory-p dir) dir + (error "Failed to create test base directory %s" dir)))) + +(defun chime-create--directory-ensuring-parents (dirpath) + "Create nested directories specified by DIRPATH. +Error if DIRPATH exists already. +Ensure DIRPATH is within `chime-test-base-dir`." + (let* ((base (file-name-as-directory chime-test-base-dir)) + (fullpath (expand-file-name dirpath base))) + (unless (string-prefix-p base fullpath) + (error "Directory path %s is outside base test directory %s" fullpath base)) + (when (file-exists-p fullpath) + (error "Directory path already exists: %s" fullpath)) + (make-directory fullpath t) + fullpath)) + +(defun chime-create--file-ensuring-parents (filepath content &optional executable) + "Create file at FILEPATH (relative to `chime-test-base-dir`) with CONTENT. +Error if file exists already. +Create parent directories as needed. +If EXECUTABLE is non-nil, set execute permissions on file. +Ensure FILEPATH is within `chime-test-base-dir`." + (let* ((base (file-name-as-directory chime-test-base-dir)) + (fullpath (expand-file-name filepath base)) + (parent-dir (file-name-directory fullpath))) + (unless (string-prefix-p base fullpath) + (error "File path %s is outside base test directory %s" fullpath base)) + (when (file-exists-p fullpath) + (error "File already exists: %s" fullpath)) + (unless (file-directory-p parent-dir) + (make-directory parent-dir t)) + (with-temp-buffer + (when content + (insert content)) + (write-file fullpath)) + (when executable + (chmod fullpath #o755)) + fullpath)) + +(defun chime-create-directory-or-file-ensuring-parents (path &optional content executable) + "Create a directory or file specified by PATH relative to `chime-test-base-dir`. +If PATH ends with a slash, create nested directories. +Else create a file with optional CONTENT. +If EXECUTABLE is non-nil and creating a file, set executable permissions. +Error if the target path already exists. +Return the full created path." + (let ((is-dir (string-suffix-p "/" path))) + (if is-dir + (chime-create--directory-ensuring-parents path) + (chime-create--file-ensuring-parents path content executable)))) + + +;; (defun chime-create-file-with-content-ensuring-parents (filepath content &optional executable) +;; "Create a file at FILEPATH with CONTENT, ensuring parent directories exist. +;; FILEPATH will be relative to `chime-test-base-dir'. +;; Signals an error if the file already exists. +;; If EXECUTABLE is non-nil, set executable permission on the file. +;; Errors if the resulting path is outside `chime-test-base-dir`." +;; (let* ((base (file-name-as-directory chime-test-base-dir)) +;; (fullpath (if (file-name-absolute-p filepath) +;; (expand-file-name filepath) +;; (expand-file-name filepath base)))) +;; (unless (string-prefix-p base fullpath) +;; (error "File path %s is outside base test directory %s" fullpath base)) +;; (let ((parent-dir (file-name-directory fullpath))) +;; (when (file-exists-p fullpath) +;; (error "File already exists: %s" fullpath)) +;; (unless (file-directory-p parent-dir) +;; (make-directory parent-dir t)) +;; (with-temp-buffer +;; (insert content) +;; (write-file fullpath)) +;; (when executable +;; (chmod fullpath #o755)) +;; fullpath))) + +(defun chime-fix-permissions-recursively (dir) + "Recursively set read/write permissions for user under DIR. +Directories get user read, write, and execute permissions to allow recursive +operations." + (when (file-directory-p dir) + (dolist (entry (directory-files-recursively dir ".*" t)) + (when (file-exists-p entry) + (let* ((attrs (file-attributes entry)) + (is-dir (car attrs)) + (mode (file-modes entry)) + (user-r (logand #o400 mode)) + (user-w (logand #o200 mode)) + (user-x (logand #o100 mode)) + new-mode) + (setq new-mode mode) + (unless user-r + (setq new-mode (logior new-mode #o400))) + (unless user-w + (setq new-mode (logior new-mode #o200))) + (when is-dir + ;; Ensure user-execute for directories + (unless user-x + (setq new-mode (logior new-mode #o100)))) + (unless (= mode new-mode) + (set-file-modes entry new-mode))))))) + +(defun chime-delete-test-base-dir () + "Recursively delete test base directory `chime-test-base-dir' and contents. +Ensures all contained files and directories have user read/write permissions +so deletion is not blocked by permissions. +After deletion, verifies that the directory no longer exists. +Signals an error if the directory still exists after deletion attempt." + (let ((dir (file-name-as-directory chime-test-base-dir))) + (when (file-directory-p dir) + (chime-fix-permissions-recursively dir) + (delete-directory dir t)) + (when (file-directory-p dir) + (error "Test base directory %s still exists after deletion" dir)))) + +(defun chime-create-temp-test-file (&optional prefix) + "Create a uniquely named temporary file under `chime-test-base-dir'. +Optional argument PREFIX is a string to prefix the filename, defaults +to \"tempfile-\". Returns the absolute path to the newly created empty file. +Errors if base test directory cannot be created or file creation fails." + (let ((base (chime-create-test-base-dir)) + (file nil)) + (setq file (make-temp-file (expand-file-name (or prefix "tempfile-") base))) + (unless (file-exists-p file) + (error "Failed to create temporary test file under %s" base)) + file)) + +(defun chime-create-test-subdirectory (subdir) + "Ensure subdirectory SUBDIR (relative to `chime-test-base-dir') exists. +Creates parent directories as needed. +Returns the absolute path to the subdirectory. +Signals an error if creation fails. +SUBDIR must be a relative path string." + (let* ((base (chime-create-test-base-dir)) + (fullpath (expand-file-name subdir base))) + (unless (file-directory-p fullpath) + (make-directory fullpath t)) + (if (file-directory-p fullpath) fullpath + (error "Failed to create test subdirectory %s" subdir)))) + +(defun chime-create-temp-test-file-with-content (content &optional prefix) + "Create uniquely named temp file in =chime-test-base-dir= and write CONTENT to it. +Optional PREFIX is a filename prefix string, default \"tempfile-\". +Returns absolute path to the created file." + (let ((file (chime-create-temp-test-file prefix))) + (with-temp-buffer + (insert content) + (write-file file)) + file)) + +(provide 'testutil-general) +;;; testutil-general.el ends here. diff --git a/tests/testutil-time.el b/tests/testutil-time.el new file mode 100644 index 0000000..3a17ae0 --- /dev/null +++ b/tests/testutil-time.el @@ -0,0 +1,143 @@ +;;; testutil-time.el --- Time utilities for dynamic test timestamps -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Utilities for generating dynamic timestamps in tests. +;; Tests should use relative time relationships (TODAY, TOMORROW, etc.) +;; instead of hardcoded dates to avoid test expiration. + +;;; Code: + +(require 'org) + +;;; Core Time Generation + +(defun test-time-now () + "Return a base 'now' time that's always valid. +Uses actual current time + 30 days to ensure tests remain valid. +Always returns 10:00 AM on that day for consistency." + (let* ((now (current-time)) + (decoded (decode-time now)) + (future-time (time-add now (days-to-time 30)))) + ;; Set to 10:00 AM for consistency + (encode-time 0 0 10 + (decoded-time-day (decode-time future-time)) + (decoded-time-month (decode-time future-time)) + (decoded-time-year (decode-time future-time))))) + +(defun test-time-at (days hours minutes) + "Return time relative to test-time-now. +DAYS, HOURS, MINUTES can be positive (future) or negative (past). +Examples: + (test-time-at 0 0 0) ; NOW + (test-time-at 0 2 0) ; 2 hours from now + (test-time-at -1 0 0) ; Yesterday at same time + (test-time-at 1 0 0) ; Tomorrow at same time" + (let* ((base (test-time-now)) + (seconds (+ (* days 86400) + (* hours 3600) + (* minutes 60)))) + (time-add base (seconds-to-time seconds)))) + +;;; Convenience Functions + +(defun test-time-today-at (hour minute) + "Return time for TODAY at HOUR:MINUTE. +Example: (test-time-today-at 14 30) ; Today at 2:30 PM" + (let* ((base (test-time-now)) + (decoded (decode-time base))) + (encode-time 0 minute hour + (decoded-time-day decoded) + (decoded-time-month decoded) + (decoded-time-year decoded)))) + +(defun test-time-yesterday-at (hour minute) + "Return time for YESTERDAY at HOUR:MINUTE." + (test-time-at -1 (- hour 10) minute)) + +(defun test-time-tomorrow-at (hour minute) + "Return time for TOMORROW at HOUR:MINUTE." + (test-time-at 1 (- hour 10) minute)) + +(defun test-time-days-ago (days &optional hour minute) + "Return time for DAYS ago, optionally at HOUR:MINUTE. +If HOUR/MINUTE not provided, uses 10:00 AM." + (let ((h (or hour 10)) + (m (or minute 0))) + (test-time-at (- days) (- h 10) m))) + +(defun test-time-days-from-now (days &optional hour minute) + "Return time for DAYS from now, optionally at HOUR:MINUTE. +If HOUR/MINUTE not provided, uses 10:00 AM." + (let ((h (or hour 10)) + (m (or minute 0))) + (test-time-at days (- h 10) m))) + +;;; Timestamp String Generation + +(defun test-timestamp-string (time &optional all-day-p) + "Convert Emacs TIME to org timestamp string. +If ALL-DAY-P is non-nil, omit time component: <2025-10-24 Thu> +Otherwise include time: <2025-10-24 Thu 14:00> + +Correctly calculates day-of-week name to match the date." + (let* ((decoded (decode-time time)) + (year (decoded-time-year decoded)) + (month (decoded-time-month decoded)) + (day (decoded-time-day decoded)) + (hour (decoded-time-hour decoded)) + (minute (decoded-time-minute decoded)) + (dow (decoded-time-weekday decoded)) + (day-names ["Sun" "Mon" "Tue" "Wed" "Thu" "Fri" "Sat"]) + (day-name (aref day-names dow))) + (if all-day-p + (format "<%04d-%02d-%02d %s>" year month day day-name) + (format "<%04d-%02d-%02d %s %02d:%02d>" year month day day-name hour minute)))) + +(defun test-timestamp-range-string (start-time end-time) + "Create range timestamp from START-TIME to END-TIME. +Example: <2025-10-24 Thu>--<2025-10-27 Sun>" + (format "%s--%s" + (test-timestamp-string start-time t) + (test-timestamp-string end-time t))) + +(defun test-timestamp-repeating (time repeater &optional all-day-p) + "Add REPEATER to timestamp for TIME. +REPEATER should be like '+1w', '.+1d', '++1m' +Example: <2025-10-24 Thu +1w>" + (let ((base-ts (test-timestamp-string time all-day-p))) + ;; Remove closing > and add repeater + (concat (substring base-ts 0 -1) " " repeater ">"))) + +;;; Mock Helpers + +(defmacro with-test-time (base-time &rest body) + "Execute BODY with mocked current-time returning BASE-TIME. +BASE-TIME can be generated with test-time-* functions. + +Example: + (with-test-time (test-time-now) + (do-something-that-uses-current-time))" + `(cl-letf (((symbol-function 'current-time) + (lambda () ,base-time))) + ,@body)) + +(provide 'testutil-time) +;;; testutil-time.el ends here |
