aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-11-18 11:13:39 -0600
committerCraig Jennings <c@cjennings.net>2025-11-18 11:13:39 -0600
commit4835fadabf243b33fb78557e45428055675e7300 (patch)
tree2e8ccd7995ffa6f6dd99943d829fb8b7e3112874
downloadchime-4835fadabf243b33fb78557e45428055675e7300.tar.gz
chime-4835fadabf243b33fb78557e45428055675e7300.zip
changed repositories
-rw-r--r--.gitignore3
-rw-r--r--COPYING674
-rw-r--r--Makefile114
-rw-r--r--README.org1081
-rw-r--r--TESTING.org221
-rw-r--r--chime-debug.el374
-rw-r--r--chime-org-contacts.el188
-rw-r--r--chime.el1825
-rw-r--r--convert-org-contacts-birthdays.el236
-rw-r--r--sounds/chime.wavbin0 -> 156716 bytes
-rw-r--r--tests/Makefile244
-rw-r--r--tests/test-chime--deduplicate-events-by-title.el200
-rw-r--r--tests/test-chime--time=.el108
-rw-r--r--tests/test-chime--today.el93
-rw-r--r--tests/test-chime--truncate-title.el120
-rw-r--r--tests/test-chime-12hour-format.el227
-rw-r--r--tests/test-chime-all-day-events.el274
-rw-r--r--tests/test-chime-apply-blacklist.el247
-rw-r--r--tests/test-chime-apply-whitelist.el229
-rw-r--r--tests/test-chime-calendar-url.el64
-rw-r--r--tests/test-chime-check-event.el215
-rw-r--r--tests/test-chime-check-interval.el148
-rw-r--r--tests/test-chime-debug-functions.el224
-rw-r--r--tests/test-chime-extract-time.el331
-rw-r--r--tests/test-chime-format-event-for-tooltip.el260
-rw-r--r--tests/test-chime-format-refresh.el150
-rw-r--r--tests/test-chime-gather-info.el475
-rw-r--r--tests/test-chime-group-events-by-day.el268
-rw-r--r--tests/test-chime-has-timestamp.el277
-rw-r--r--tests/test-chime-modeline-no-events-text.el290
-rw-r--r--tests/test-chime-modeline.el1076
-rw-r--r--tests/test-chime-notification-text.el542
-rw-r--r--tests/test-chime-notifications.el259
-rw-r--r--tests/test-chime-notify.el259
-rw-r--r--tests/test-chime-org-contacts.el317
-rw-r--r--tests/test-chime-overdue-todos.el403
-rw-r--r--tests/test-chime-process-notifications.el344
-rw-r--r--tests/test-chime-sanitize-title.el402
-rw-r--r--tests/test-chime-time-left.el305
-rw-r--r--tests/test-chime-timestamp-parse.el413
-rw-r--r--tests/test-chime-timestamp-within-interval-p.el325
-rw-r--r--tests/test-chime-tooltip-bugs.el392
-rw-r--r--tests/test-chime-tooltip-day-calculation.el326
-rw-r--r--tests/test-chime-update-modeline-helpers.el166
-rw-r--r--tests/test-chime-update-modeline.el474
-rw-r--r--tests/test-chime-validate-configuration.el279
-rw-r--r--tests/test-chime-validation-retry.el435
-rw-r--r--tests/test-chime-whitelist-blacklist-conflicts.el253
-rw-r--r--tests/test-convert-org-contacts-birthdays.el674
-rw-r--r--tests/test-integration-recurring-events-tooltip.el370
-rw-r--r--tests/test-integration-startup.el328
-rw-r--r--tests/tests-autoloads.el148
-rw-r--r--tests/testutil-events.el214
-rw-r--r--tests/testutil-general.el184
-rw-r--r--tests/testutil-time.el143
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
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..e72bfdd
--- /dev/null
+++ b/COPYING
@@ -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
new file mode 100644
index 0000000..5b9f3ca
--- /dev/null
+++ b/sounds/chime.wav
Binary files differ
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