Compare commits
20 commits
44553c3544
...
de46c582f4
| Author | SHA1 | Date | |
|---|---|---|---|
| de46c582f4 | |||
| 1910e207cb | |||
| 4795114093 | |||
| 6c67cba949 | |||
| c7cd5f9142 | |||
| f8b3e8b0a9 | |||
| f5d1bb04bd | |||
| 74c21ee031 | |||
| a2a3a7eca8 | |||
| 926a3ff21f | |||
| 9efcfc9973 | |||
| 30045182e7 | |||
| 4e31c8d3e5 | |||
| 518ef93f67 | |||
| cd9ea442a6 | |||
| 100c0d3399 | |||
| a3354e2b0b | |||
| 9fc104d0e9 | |||
| 0bd3f08093 | |||
| 7e1042aacd |
74 changed files with 24419 additions and 40 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules/
|
||||
www/
|
||||
dist/
|
||||
dist-electron/
|
||||
android/
|
||||
ios/
|
||||
.vite/
|
||||
.vitest/
|
||||
coverage/
|
||||
12
CHANGELOG.md
Normal file
12
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Changelog — Gestion table tournante Libre
|
||||
|
||||
## 2026.10.05.01 — 5 octobre 2026
|
||||
|
||||
**Ce qui change pour vous**
|
||||
- L'application s'ouvre et affiche sa version dans un bandeau permanent et dans
|
||||
le titre de sa fenêtre.
|
||||
|
||||
**Version de format des fichiers** : aucune — cette version n'enregistre encore
|
||||
aucun événement.
|
||||
|
||||
**En remplaçant** : rien à faire, c'est la première version.
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are 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.
|
||||
|
||||
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.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
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 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 work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
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 AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
26
README.md
26
README.md
|
|
@ -0,0 +1,26 @@
|
|||
# Gestion table tournante Libre
|
||||
|
||||
Logiciel hors ligne qui place les personnes d'un événement autour de tables et
|
||||
les fait tourner sur plusieurs tours, pour que chacune rencontre le plus de
|
||||
monde possible.
|
||||
|
||||
## Commandes
|
||||
|
||||
| commande | effet |
|
||||
|---|---|
|
||||
| `npm install` | installe les outils de construction et d'épreuve |
|
||||
| `npx playwright install chromium` | télécharge le Chromium des épreuves navigateur, une fois par machine |
|
||||
| `npm run dev` | sert l'application en développement, sur la plateforme web |
|
||||
| `npm test` | série `node` : les fonctions pures, à chaque modification |
|
||||
| `npm run test:long` | épreuves lourdes, avant un commit |
|
||||
| `npm run test:navigateur` | épreuves de rendu dans Chromium, avant une livraison |
|
||||
| `npm run build` | construit la page dans `www/` |
|
||||
| `scripts/construire_windows.sh` | construit l'exécutable Windows portable, sous `podman` |
|
||||
|
||||
L'exécutable porte la version datée du logiciel dans son nom, selon le gabarit
|
||||
`gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe`.
|
||||
|
||||
## Licence
|
||||
|
||||
© 2026 TechnoLibre. GNU Affero General Public License, version 3 ou ultérieure ;
|
||||
texte complet dans `LICENSE`.
|
||||
5
capacitor.config.json
Normal file
5
capacitor.config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"appId": "ca.technolibre.gestiontabletournante",
|
||||
"appName": "Gestion table tournante Libre",
|
||||
"webDir": "www"
|
||||
}
|
||||
35
electron-builder.config.cjs
Normal file
35
electron-builder.config.cjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Configuration d'electron-builder : un exécutable Windows portable à fichier
|
||||
// unique (§ 16), nommé d'après la version datée (§ 18.2).
|
||||
//
|
||||
// electron-builder ne lit ce fichier que par « --config
|
||||
// electron-builder.config.cjs » : son nom n'est pas de ceux qu'il cherche
|
||||
// seul, et sans lui il retombe sur ses défauts, dont un installateur. Le
|
||||
// script scripts/construire_windows.sh le lui passe.
|
||||
//
|
||||
// Le nom du livrable sort de deriver, appliqué à version.json : la
|
||||
// configuration ne le compose pas (§ 13.2). scripts/version.js est un module
|
||||
// ESM, chargé ici par require : Node l'accepte depuis 20.19 et 22.12, pour un
|
||||
// module sans await de premier niveau.
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { deriver } = require('./scripts/version.js');
|
||||
|
||||
const lireJson = (nom) => JSON.parse(readFileSync(join(__dirname, nom), 'utf8'));
|
||||
|
||||
module.exports = {
|
||||
// Un seul identifiant pour toutes les plateformes, celui de Capacitor.
|
||||
appId: lireJson('capacitor.config.json').appId,
|
||||
productName: lireJson('package.json').productName,
|
||||
// Champ LegalCopyright de la ressource de version de l'exécutable.
|
||||
copyright: '© 2026 TechnoLibre',
|
||||
directories: { output: 'dist-electron' },
|
||||
// La page construite, la coquille, et package.json, dont electron-builder
|
||||
// tire le point d'entrée. package.json ne déclarant aucune dépendance
|
||||
// d'exécution, aucun node_modules n'entre dans le paquet.
|
||||
files: ['www/**', 'electron/**', 'package.json'],
|
||||
artifactName: deriver(lireJson('version.json').version).nomFichier,
|
||||
win: { target: [{ target: 'portable', arch: ['x64'] }] },
|
||||
};
|
||||
56
electron/main.js
Normal file
56
electron/main.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Processus principal de la coquille Electron (§ 13.1) : une fenêtre qui
|
||||
// charge la page construite par Vite dans www/, et un seul canal IPC, que
|
||||
// electron/preload.cjs expose à la page sous window.gtt.
|
||||
//
|
||||
// La page n'a ni Node ni accès au disque : isolation de contexte active,
|
||||
// intégration de Node coupée, rendu en bac à sable. Elle n'atteint le
|
||||
// processus principal que par les canaux que ce module traite. Un nom importé
|
||||
// interprété comme du balisage reste ainsi une faute d'affichage, et non un
|
||||
// accès au disque.
|
||||
import { app, BrowserWindow, ipcMain, Menu } from 'electron';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Chemins relatifs à ce module : ils valent dans le dépôt comme dans
|
||||
// l'archive app.asar de l'exécutable, qui reproduit electron/ et www/.
|
||||
const PRECHARGEMENT = fileURLToPath(new URL('./preload.cjs', import.meta.url));
|
||||
const PAGE = fileURLToPath(new URL('../www/index.html', import.meta.url));
|
||||
|
||||
// Dossier de l'exécutable livré (§ 8.6, point 1). L'exécutable portable est
|
||||
// une archive auto-extractible : l'application tourne depuis un dossier
|
||||
// temporaire effacé à la sortie, et le lanceur publie son propre dossier dans
|
||||
// PORTABLE_EXECUTABLE_DIR avant de la lancer. Hors de ce lanceur, la variable
|
||||
// est absente ; vide, elle ne désigne aucun dossier. Dans les deux cas, le
|
||||
// canal rend null. Le gestionnaire est posé avant le chargement de la page,
|
||||
// qui peut l'appeler dès son premier script.
|
||||
ipcMain.handle('gtt:dossier-executable', () => process.env.PORTABLE_EXECUTABLE_DIR || null);
|
||||
|
||||
function creerFenetre() {
|
||||
const fenetre = new BrowserWindow({
|
||||
width: 1366,
|
||||
height: 768,
|
||||
webPreferences: {
|
||||
preload: PRECHARGEMENT,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
// La page ne quitte jamais www/ : toute nouvelle fenêtre est refusée, toute
|
||||
// navigation annulée, quelle que soit sa cible.
|
||||
fenetre.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||
fenetre.webContents.on('will-navigate', (evenement) => evenement.preventDefault());
|
||||
fenetre.loadFile(PAGE);
|
||||
}
|
||||
|
||||
// Sans menu posé par l'application, Electron installe le sien : en anglais,
|
||||
// alors que l'interface est en français (§ 2), et porteur de « Reload » et
|
||||
// des outils de développement. null le retire, et retire avec lui la barre
|
||||
// de menu des fenêtres sous Windows et Linux ; il est posé avant la première
|
||||
// fenêtre, qui naîtrait sinon avec la barre.
|
||||
app.whenReady().then(() => {
|
||||
Menu.setApplicationMenu(null);
|
||||
creerFenetre();
|
||||
});
|
||||
19
electron/preload.cjs
Normal file
19
electron/preload.cjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Préchargement de la coquille (§ 13.1) : le seul pont entre la page et le
|
||||
// processus principal. Il expose window.gtt et rien d'autre ; la page n'en
|
||||
// reçoit que des fonctions qui appellent un canal nommé, jamais ipcRenderer
|
||||
// lui-même.
|
||||
//
|
||||
// Un préchargement exécuté en bac à sable est un script CommonJS, d'où
|
||||
// l'extension .cjs dans un paquet de type module. Son require ne connaît
|
||||
// qu'electron et quelques modules de Node.
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('gtt', {
|
||||
plateforme: 'electron',
|
||||
// Dossier publié par le lanceur portable (§ 8.6, point 1), ou null hors de
|
||||
// lui ; voir electron/main.js.
|
||||
dossierExecutable: () => ipcRenderer.invoke('gtt:dossier-executable'),
|
||||
});
|
||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- Le titre est posé par l'application, depuis ses libellés (§ 14.6). -->
|
||||
<title></title>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
6011
package-lock.json
generated
Normal file
6011
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
package.json
Normal file
35
package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "gestion-table-tournante-libre",
|
||||
"productName": "Gestion table tournante Libre",
|
||||
"version": "2026.1005.1",
|
||||
"description": "Placement tournant des participants d’un événement, hors ligne",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "TechnoLibre (http://www.technolibre.ca)",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "vite --mode developpement",
|
||||
"build": "vite build --mode developpement",
|
||||
"build:livraison": "node scripts/version.js controler && vite build --mode livraison",
|
||||
"build:documentation": "vite build --mode documentation",
|
||||
"test": "vitest run --project node",
|
||||
"test:surveille": "vitest --project node",
|
||||
"test:long": "vitest run --project node-long",
|
||||
"test:navigateur": "vitest run --project navigateur",
|
||||
"test:tout": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.2",
|
||||
"@capacitor/core": "^8.5.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@vitest/browser-playwright": "^5.0.3",
|
||||
"electron": "^44.5.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"fast-check": "^4.10.2",
|
||||
"playwright": "^1.63.0",
|
||||
"svelte": "^5.57.1",
|
||||
"vite": "^8.3.2",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
171
scripts/construire_windows.sh
Executable file
171
scripts/construire_windows.sh
Executable file
|
|
@ -0,0 +1,171 @@
|
|||
#!/usr/bin/env bash
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
# Construit l'exécutable Windows portable (§ 16) dans dist-electron/, par
|
||||
# electron-builder exécuté sous podman, puis imprime son chemin, sa taille et
|
||||
# ce qu'en dit « file ».
|
||||
#
|
||||
# scripts/construire_windows.sh refuse si l'exécutable existe
|
||||
# scripts/construire_windows.sh --reconstruire l'efface, puis le reconstruit
|
||||
#
|
||||
# Sortie : 0, l'exécutable est construit ; 1, refus ou échec ; 2, option
|
||||
# inconnue ; 130 ou 143, interrompu par INT ou TERM. La racine du projet est
|
||||
# le parent du répertoire du script.
|
||||
#
|
||||
# Chaque étape arrête la suite quand elle échoue :
|
||||
# 1. « node scripts/version.js controler » applique les huit points du
|
||||
# § 18.5 : une version sans section au changelog ne se construit pas.
|
||||
# 2. Le nom du livrable est celui que deriver tire de version.json : ce script
|
||||
# ne le compose pas. electron-builder.config.cjs doit passer le même nom à
|
||||
# electron-builder ; s'il en passe un autre, tout s'arrête là.
|
||||
# 3. Un exécutable de ce nom dans dist-electron/ arrête tout (§ 18.2), sauf
|
||||
# --reconstruire, qui l'efface d'abord. Passé cette étape, un fichier de ce
|
||||
# nom ne peut venir que de cette exécution.
|
||||
# 4. La page est construite sur l'hôte, en mode livraison.
|
||||
# 5. electron-builder emballe www/ et electron/ dans le conteneur.
|
||||
# 6. Contrôle de fin de construction (§ 18.5) : le seul exécutable apparu au
|
||||
# premier niveau de dist-electron/ porte le nom de l'étape 2.
|
||||
# De l'étape 5 à l'acceptation par l'étape 6, toute sortie retire le fichier
|
||||
# de ce nom : une construction qui échoue, ou que le contrôle refuse, ne
|
||||
# laisse aucun exécutable sous ce nom.
|
||||
set -euo pipefail
|
||||
|
||||
# Image sans Wine, épinglée par empreinte. La cible portable n'appelle pas
|
||||
# Wine ; une cible qui l'exige, un installateur, échoue dans cette image au
|
||||
# lieu de produire le livrable que le § 16 exclut. L'empreinte fige l'image,
|
||||
# qu'une étiquette ferait avancer à chaque publication.
|
||||
readonly IMAGE='docker.io/electronuserland/builder@sha256:e41cd059a4fdc0831ccd2a55cb59cbc6b73dc5f44c625778f7fda38874a70df3'
|
||||
readonly SORTIE='dist-electron'
|
||||
readonly CONFIGURATION='electron-builder.config.cjs'
|
||||
|
||||
refuser() {
|
||||
printf 'Construction refusée : %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Noms des fichiers .exe au premier niveau de dist-electron/, un par ligne.
|
||||
executables() {
|
||||
find "$SORTIE" -maxdepth 1 -type f -name '*.exe' -printf '%f\n'
|
||||
}
|
||||
|
||||
# Retire le fichier du nom du livrable qu'aucun contrôle de fin n'a accepté.
|
||||
# electron-builder l'écrit avant d'avoir fini : resté là après un échec ou un
|
||||
# refus, il serait refusé à l'exécution suivante comme un exécutable déjà
|
||||
# construit.
|
||||
retirer_non_controle() {
|
||||
[[ -e "$cible" ]] || return 0
|
||||
rm -f -- "$cible"
|
||||
printf '%s retiré : il n’a pas passé le contrôle de fin de construction.\n' "$cible" >&2
|
||||
}
|
||||
|
||||
reconstruire=0
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
--reconstruire) reconstruire=1 ;;
|
||||
*)
|
||||
printf 'usage : scripts/construire_windows.sh [--reconstruire]\n' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
racine="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd -- "$racine"
|
||||
|
||||
command -v podman >/dev/null || refuser 'podman introuvable : electron-builder s’exécute dans un conteneur.'
|
||||
|
||||
# 1. Contrôle de version.
|
||||
node scripts/version.js controler \
|
||||
|| refuser 'le contrôle de version du § 18.5 échoue (voir ci-dessus).'
|
||||
|
||||
# 2. Nom du livrable. Un seul lancement de node imprime deux lignes : le nom
|
||||
# que deriver tire de version.json, puis celui que la configuration passe à
|
||||
# electron-builder. L'étape 1 a déjà appliqué deriver à version.json : un
|
||||
# échec vient de la configuration. Les deux noms doivent coïncider avant
|
||||
# l'étape 3, la première qui efface : une configuration refusée n'efface
|
||||
# rien, même sous --reconstruire, et « rm » ne reçoit que le nom calculé.
|
||||
noms="$(node -e "
|
||||
const { deriver } = require('./scripts/version.js');
|
||||
console.log(deriver(require('./version.json').version).nomFichier);
|
||||
console.log(require('./$CONFIGURATION').artifactName);
|
||||
")" || refuser "$CONFIGURATION ne se charge pas."
|
||||
nom="${noms%%$'\n'*}"
|
||||
nom_configuration="${noms#*$'\n'}"
|
||||
[[ "$nom_configuration" == "$nom" ]] \
|
||||
|| refuser "$CONFIGURATION nomme le livrable $nom_configuration ; version.json donne $nom (§ 18.2)."
|
||||
cible="$SORTIE/$nom"
|
||||
|
||||
# 3. Refus d'écraser.
|
||||
if [[ -e "$cible" ]]; then
|
||||
if (( ! reconstruire )); then
|
||||
refuser "$cible existe déjà. Un numéro ne désigne qu'un binaire (§ 18.2) : changer la version, ou relancer avec --reconstruire si cet exécutable n'a pas quitté cette machine."
|
||||
fi
|
||||
rm -f -- "$cible"
|
||||
fi
|
||||
|
||||
# 4. La page.
|
||||
npm run --silent build:livraison || refuser 'la construction de la page échoue.'
|
||||
[[ -f www/index.html ]] || refuser 'www/index.html manque après la construction de la page.'
|
||||
|
||||
# 5. L'exécutable. Le projet est monté en lecture seule : seuls dist-electron/
|
||||
# et les volumes nommés sont inscriptibles. Le volume monté sur
|
||||
# /project/node_modules masque celui de l'hôte, que le « npm ci » du
|
||||
# conteneur, sous un autre Node, écraserait. « --ignore-scripts » n'exécute
|
||||
# aucun script d'installation : electron-builder télécharge lui-même
|
||||
# l'Electron Windows qu'il emballe. /root/.cache garde ce téléchargement et
|
||||
# les outils d'electron-builder, /root/.npm les archives npm, d'une
|
||||
# construction à l'autre.
|
||||
# La commande ne passe aucune variable, et --env-host=false refuse
|
||||
# l'environnement de l'hôte quel que soit containers.conf, dont un
|
||||
# env_host = true le transmettrait entier : BUILD_NUMBER et les numéros de
|
||||
# construction des services d'intégration continue y remplaceraient le
|
||||
# quatrième champ de la version Windows. podman relaie encore les variables
|
||||
# de mandataire de l'hôte (http_proxy, https_proxy, ftp_proxy, no_proxy, en
|
||||
# minuscules comme en majuscules) : derrière un mandataire, npm et
|
||||
# electron-builder en ont besoin pour télécharger, et elles ne touchent pas
|
||||
# à la version.
|
||||
mkdir -p -- "$SORTIE"
|
||||
declare -A avant=()
|
||||
while IFS= read -r fichier; do
|
||||
avant["$fichier"]=1
|
||||
done < <(executables)
|
||||
|
||||
# Le retrait s'arme avant podman. INT et TERM mènent à exit, donc au retrait,
|
||||
# une fois podman terminé : le shell n'exécute un piège qu'après la commande
|
||||
# en cours, et podman ne peut plus rien écrire après le retrait. Sans ces deux
|
||||
# pièges, TERM arrête le shell sur-le-champ, podman continue d'écrire après le
|
||||
# retrait, et INT reçu par le shell seul n'interrompt rien.
|
||||
trap retirer_non_controle EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
printf 'Construction de %s sous podman.\n' "$nom"
|
||||
podman run --rm --env-host=false \
|
||||
-v "$racine:/project:ro" \
|
||||
-v "$racine/$SORTIE:/project/$SORTIE" \
|
||||
-v gestion_table_tournante_libre-node_modules:/project/node_modules \
|
||||
-v gestion_table_tournante_libre-cache:/root/.cache \
|
||||
-v gestion_table_tournante_libre-npm:/root/.npm \
|
||||
-w /project \
|
||||
"$IMAGE" \
|
||||
bash -c "npm ci --ignore-scripts --no-audit --no-fund && npx --no-install electron-builder --win --publish never --config $CONFIGURATION" \
|
||||
|| refuser "electron-builder échoue sous podman (code $?)."
|
||||
|
||||
# 6. Contrôle de fin de construction.
|
||||
apparus=()
|
||||
while IFS= read -r fichier; do
|
||||
[[ -n "${avant["$fichier"]-}" ]] || apparus+=("$fichier")
|
||||
done < <(executables)
|
||||
if (( ${#apparus[@]} == 0 )); then
|
||||
refuser "aucun exécutable n'est apparu dans $SORTIE/ ; attendu : $nom."
|
||||
fi
|
||||
if (( ${#apparus[@]} > 1 )) || [[ "${apparus[0]}" != "$nom" ]]; then
|
||||
refuser "exécutable apparu : ${apparus[*]} ; attendu : $nom, le seul nom que version.json donne (§ 18.5)."
|
||||
fi
|
||||
# Accepté : l'exécutable reste, même si l'impression qui suit est interrompue.
|
||||
trap - EXIT INT TERM
|
||||
|
||||
printf 'Exécutable : %s\n' "$racine/$cible"
|
||||
printf 'Taille : %s octets\n' "$(wc -c < "$cible")"
|
||||
printf 'Type : %s\n' "$(file -b -- "$cible" 2>/dev/null || echo 'inconnu, « file » indisponible')"
|
||||
476
scripts/construire_windows.test.js
Normal file
476
scripts/construire_windows.test.js
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le script de construction de l'exécutable Windows (§ 16, § 18.2, § 18.5) :
|
||||
// ce qu'il refuse avant de construire, la commande qu'il passe au conteneur,
|
||||
// le contrôle de fin de construction, et ce qu'il laisse quand la
|
||||
// construction échoue ou qu'un signal l'interrompt.
|
||||
//
|
||||
// Chaque épreuve copie le script et les fichiers qu'il lit dans un arbre
|
||||
// temporaire, et le lance avec, en tête du PATH, un podman et un npm
|
||||
// factices. Ils consignent leurs arguments. podman écrit chaque nom de
|
||||
// FAUX_PRODUIT, un par ligne, dans le dossier de l'hôte qu'il reçoit monté
|
||||
// sur /project/dist-electron, comme electron-builder y écrirait
|
||||
// l'exécutable ; il y écrit aussi, à chaque lancement, les deux exécutables
|
||||
// qu'electron-builder pose toujours sous win-unpacked/, aux profondeurs 2
|
||||
// et 3 ; puis il sort en FAUX_CODE ; sous FAUX_ATTENTE, il s'annonce sur
|
||||
// sa sortie et attend une ligne sur son entrée avant d'écrire. npm écrit
|
||||
// www/index.html, sauf sous FAUX_SANS_PAGE, puis sort en FAUX_CODE_NPM.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import {
|
||||
accessSync,
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
constants,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { delimiter, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, test } from '../test/lanceur.js';
|
||||
import { versionVoisine } from '../test/version_voisine.js';
|
||||
import { deriver } from './version.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
const VERSION = JSON.parse(readFileSync(join(RACINE, 'version.json'), 'utf8')).version;
|
||||
const NOM = deriver(VERSION).nomFichier;
|
||||
// Le livrable d'une autre version du même jour : il reste dans dist-electron/
|
||||
// quoi que fasse la construction de la version courante.
|
||||
const NOM_AUTRE_VERSION = deriver(versionVoisine(VERSION)).nomFichier;
|
||||
|
||||
// Délai d'un lancement du script. Il reste sous celui d'une épreuve de
|
||||
// Vitest, 5 s, qui n'interrompt pas un appel synchrone : un script qui ne
|
||||
// s'arrête pas fait échouer son épreuve en le disant, au lieu de figer la
|
||||
// série. Le délai écoulé, le script reçoit KILL : il piège TERM et attend
|
||||
// la fin de la commande en cours, si bien que TERM n'arrêterait pas un
|
||||
// script dont un enfant ne finit pas.
|
||||
const DELAI_MS = 4_000;
|
||||
|
||||
// Ce que le script lit, et ce que lit le contrôle de version qu'il lance.
|
||||
const COPIES = [
|
||||
'version.json',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'CHANGELOG.md',
|
||||
'README.md',
|
||||
'capacitor.config.json',
|
||||
'electron-builder.config.cjs',
|
||||
'scripts/version.js',
|
||||
'scripts/construire_windows.sh',
|
||||
'src/version.genere.js',
|
||||
'src/main.js',
|
||||
];
|
||||
|
||||
const LANCE = 'podman factice lancé';
|
||||
|
||||
const PODMAN_FACTICE = [
|
||||
'#!/usr/bin/env bash',
|
||||
'printf "%s\\0" "$@" > "$JOURNAL/podman"',
|
||||
'echo podman >> "$JOURNAL/ordre"',
|
||||
'for argument in "$@"; do',
|
||||
' case "$argument" in',
|
||||
' *:/project/dist-electron) sortie="${argument%:/project/dist-electron}" ;;',
|
||||
' esac',
|
||||
'done',
|
||||
'if [[ -n "${FAUX_ATTENTE-}" ]]; then',
|
||||
` echo '${LANCE}'`,
|
||||
' read -r _',
|
||||
'fi',
|
||||
'mkdir -p "$sortie/win-unpacked/resources"',
|
||||
'printf MZ > "$sortie/win-unpacked/Gestion table tournante Libre.exe"',
|
||||
'printf MZ > "$sortie/win-unpacked/resources/elevate.exe"',
|
||||
'while IFS= read -r produit; do',
|
||||
' [[ -z "$produit" ]] || printf MZ > "$sortie/$produit"',
|
||||
'done <<< "${FAUX_PRODUIT-}"',
|
||||
'exit "${FAUX_CODE:-0}"',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const NPM_FACTICE = [
|
||||
'#!/usr/bin/env bash',
|
||||
'printf "%s\\0" "$@" > "$JOURNAL/npm"',
|
||||
'echo npm >> "$JOURNAL/ordre"',
|
||||
'[[ -n "${FAUX_SANS_PAGE-}" ]] || { mkdir -p www && echo "<!doctype html>" > www/index.html; }',
|
||||
'exit "${FAUX_CODE_NPM:-0}"',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
// node qui se consigne dans l'ordre des lancements, puis passe la main au
|
||||
// vrai : sert là où le PATH est réduit à bin/.
|
||||
const NODE_CONSIGNE = [
|
||||
'#!/usr/bin/env bash',
|
||||
'echo node >> "$JOURNAL/ordre"',
|
||||
`exec '${process.execPath}' "$@"`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
// Outils que le script appelle, hors podman, npm et node. Un PATH réduit à
|
||||
// bin/ en reçoit des liens : seul podman y manque.
|
||||
const OUTILS = ['bash', 'dirname', 'find', 'mkdir', 'rm', 'wc', 'file'];
|
||||
|
||||
// Chemin d'un exécutable dans le PATH de l'épreuve, ou null.
|
||||
function chercher(nom) {
|
||||
for (const dossier of (process.env.PATH ?? '').split(delimiter)) {
|
||||
try {
|
||||
accessSync(join(dossier, nom), constants.X_OK);
|
||||
return join(dossier, nom);
|
||||
} catch {
|
||||
// Absent de ce dossier : le suivant.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const lireSiPresent = (chemin) => (existsSync(chemin) ? readFileSync(chemin, 'utf8') : '');
|
||||
|
||||
// Arguments consignés par un factice, ou null s'il n'a pas été lancé.
|
||||
function argumentsDe(journal, factice) {
|
||||
const texte = lireSiPresent(join(journal, factice));
|
||||
return texte === '' ? null : texte.split('\0').slice(0, -1);
|
||||
}
|
||||
|
||||
// Contenu de chaque fichier .exe au premier niveau de dist-electron/.
|
||||
function executables(projet) {
|
||||
const sortie = join(projet, 'dist-electron');
|
||||
if (!existsSync(sortie)) return {};
|
||||
return Object.fromEntries(
|
||||
readdirSync(sortie)
|
||||
.filter((nom) => nom.endsWith('.exe'))
|
||||
.sort()
|
||||
.map((nom) => [nom, readFileSync(join(sortie, nom), 'utf8')]),
|
||||
);
|
||||
}
|
||||
|
||||
// Un arbre neuf, prêt au lancement : le projet recopié, bin/ et ses
|
||||
// factices, le journal, l'environnement du script. produit est un nom ou une
|
||||
// liste de noms que podman écrit ; page: false et codeNpm règlent npm ;
|
||||
// attente règle podman. sansPodman réduit le PATH à bin/, qui reçoit les
|
||||
// OUTILS et NODE_CONSIGNE, sans podman. preparer reçoit la racine du projet.
|
||||
function preparerArbre({ produit, code, codeNpm, page = true, attente = false, sansPodman = false, preparer } = {}) {
|
||||
const base = mkdtempSync(join(tmpdir(), 'construire-windows-'));
|
||||
const projet = join(base, 'projet');
|
||||
const bin = join(base, 'bin');
|
||||
const journal = join(base, 'journal');
|
||||
for (const chemin of COPIES) {
|
||||
mkdirSync(dirname(join(projet, chemin)), { recursive: true });
|
||||
copyFileSync(join(RACINE, chemin), join(projet, chemin));
|
||||
}
|
||||
mkdirSync(bin);
|
||||
mkdirSync(journal);
|
||||
const factices = sansPodman
|
||||
? [['npm', NPM_FACTICE], ['node', NODE_CONSIGNE]]
|
||||
: [['podman', PODMAN_FACTICE], ['npm', NPM_FACTICE]];
|
||||
for (const [nom, contenu] of factices) {
|
||||
writeFileSync(join(bin, nom), contenu);
|
||||
chmodSync(join(bin, nom), 0o755);
|
||||
}
|
||||
if (sansPodman) {
|
||||
for (const nom of OUTILS) {
|
||||
const chemin = chercher(nom);
|
||||
if (chemin !== null) symlinkSync(chemin, join(bin, nom));
|
||||
}
|
||||
}
|
||||
preparer?.(projet);
|
||||
|
||||
const environnement = {
|
||||
...process.env,
|
||||
PATH: sansPodman ? bin : [bin, dirname(process.execPath), process.env.PATH].join(delimiter),
|
||||
JOURNAL: journal,
|
||||
};
|
||||
for (const cle of ['FAUX_PRODUIT', 'FAUX_CODE', 'FAUX_ATTENTE', 'FAUX_SANS_PAGE', 'FAUX_CODE_NPM']) {
|
||||
delete environnement[cle];
|
||||
}
|
||||
if (produit !== undefined) environnement.FAUX_PRODUIT = [produit].flat().join('\n');
|
||||
if (code !== undefined) environnement.FAUX_CODE = String(code);
|
||||
if (codeNpm !== undefined) environnement.FAUX_CODE_NPM = String(codeNpm);
|
||||
if (!page) environnement.FAUX_SANS_PAGE = '1';
|
||||
if (attente) environnement.FAUX_ATTENTE = '1';
|
||||
return { base, projet, journal, environnement, script: join(projet, 'scripts', 'construire_windows.sh') };
|
||||
}
|
||||
|
||||
// Lance le script dans un arbre neuf, puis rend ce qu'il a imprimé, ce que
|
||||
// les factices ont consigné et les exécutables de dist-electron/. Les
|
||||
// paramètres sont ceux de preparerArbre, plus options, les arguments du
|
||||
// script, et relever, appelé sur la racine du projet avant son retrait :
|
||||
// son résultat est rendu sous « releve ».
|
||||
function construire({ options = [], relever, ...parametres } = {}) {
|
||||
const arbre = preparerArbre(parametres);
|
||||
try {
|
||||
const resultat = spawnSync(arbre.script, options, {
|
||||
cwd: arbre.base,
|
||||
encoding: 'utf8',
|
||||
env: arbre.environnement,
|
||||
timeout: DELAI_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
assert.notEqual(
|
||||
resultat.error?.code,
|
||||
'ETIMEDOUT',
|
||||
`scripts/construire_windows.sh ne s'arrête pas en ${DELAI_MS} ms :\n${resultat.stderr}`,
|
||||
);
|
||||
return {
|
||||
statut: resultat.status,
|
||||
sortie: resultat.stdout,
|
||||
erreurs: `${resultat.error?.message ?? ''}${resultat.stderr}`,
|
||||
projet: arbre.projet,
|
||||
ordre: lireSiPresent(join(arbre.journal, 'ordre')).split('\n').filter(Boolean),
|
||||
npm: argumentsDe(arbre.journal, 'npm'),
|
||||
podman: argumentsDe(arbre.journal, 'podman'),
|
||||
executables: executables(arbre.projet),
|
||||
releve: relever?.(arbre.projet),
|
||||
};
|
||||
} finally {
|
||||
rmSync(arbre.base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const ecrireExecutable = (contenu, nom = NOM) => (projet) => {
|
||||
mkdirSync(join(projet, 'dist-electron'), { recursive: true });
|
||||
writeFileSync(join(projet, 'dist-electron', nom), contenu);
|
||||
};
|
||||
|
||||
// La configuration recopiée tape le nom du livrable : la ligne ajoutée
|
||||
// remplace celui qu'elle tient de deriver.
|
||||
const taperLeNom = (nom) => (projet) =>
|
||||
appendFileSync(join(projet, 'electron-builder.config.cjs'), `module.exports.artifactName = ${JSON.stringify(nom)};\n`);
|
||||
|
||||
// Une construction réussie, partagée par les épreuves qui la lisent.
|
||||
let reussie;
|
||||
const constructionReussie = () => (reussie ??= construire({ produit: NOM }));
|
||||
|
||||
describe('construction Windows : refus avant de construire', () => {
|
||||
test('une version sans section au changelog ne se construit pas (§ 16)', () => {
|
||||
const r = construire({
|
||||
preparer: (projet) =>
|
||||
writeFileSync(join(projet, 'CHANGELOG.md'), '# Changelog — Gestion table tournante Libre\n'),
|
||||
});
|
||||
assert.equal(r.statut, 1, r.erreurs);
|
||||
assert.match(r.erreurs, /^3\. CHANGELOG\.md/m);
|
||||
assert.match(r.erreurs, /Construction refusée/);
|
||||
assert.deepEqual(r.ordre, []);
|
||||
});
|
||||
|
||||
test("un exécutable du même nom n'est pas écrasé (§ 18.2)", () => {
|
||||
const r = construire({ produit: NOM, preparer: ecrireExecutable('ancien') });
|
||||
assert.equal(r.statut, 1, r.erreurs);
|
||||
assert.ok(r.erreurs.includes(`dist-electron/${NOM}`), r.erreurs);
|
||||
assert.match(r.erreurs, /--reconstruire/);
|
||||
assert.deepEqual(r.ordre, []);
|
||||
assert.deepEqual(r.executables, { [NOM]: 'ancien' });
|
||||
});
|
||||
|
||||
test('une option inconnue sort en 2 et rappelle l’usage', () => {
|
||||
const r = construire({ options: ['--forcer'] });
|
||||
assert.equal(r.statut, 2, r.erreurs);
|
||||
assert.match(r.erreurs, /usage .*--reconstruire/);
|
||||
assert.deepEqual(r.ordre, []);
|
||||
});
|
||||
|
||||
test('sans podman dans le PATH, refuse avant le contrôle de version', () => {
|
||||
const r = construire({ sansPodman: true });
|
||||
assert.equal(r.statut, 1, r.erreurs);
|
||||
assert.match(r.erreurs, /podman introuvable/);
|
||||
assert.deepEqual(r.ordre, []);
|
||||
});
|
||||
|
||||
test("une configuration d'electron-builder qui ne se charge pas arrête tout, avant la page", () => {
|
||||
const r = construire({
|
||||
preparer: (projet) =>
|
||||
writeFileSync(join(projet, 'electron-builder.config.cjs'), "throw new Error('configuration illisible');\n"),
|
||||
});
|
||||
assert.equal(r.statut, 1, r.erreurs);
|
||||
assert.match(r.erreurs, /electron-builder\.config\.cjs ne se charge pas/);
|
||||
assert.deepEqual(r.ordre, []);
|
||||
});
|
||||
|
||||
test('une configuration qui nomme le livrable autrement que deriver depuis version.json arrête tout, avant la page (§ 18.2)', () => {
|
||||
// podman produirait le nom tapé : seul le nom calculé depuis version.json
|
||||
// distingue cette construction d'une construction conforme.
|
||||
const r = construire({ produit: NOM_AUTRE_VERSION, preparer: taperLeNom(NOM_AUTRE_VERSION) });
|
||||
assert.equal(r.statut, 1, r.erreurs);
|
||||
assert.ok(r.erreurs.includes(NOM_AUTRE_VERSION) && r.erreurs.includes(NOM), r.erreurs);
|
||||
assert.deepEqual(r.ordre, []);
|
||||
assert.deepEqual(r.executables, {});
|
||||
});
|
||||
|
||||
test("--reconstruire n'efface rien quand la configuration nomme le livrable autrement : ni l'exécutable, ni le fichier qu'elle désigne", () => {
|
||||
const tape = '../version.json';
|
||||
const r = construire({
|
||||
options: ['--reconstruire'],
|
||||
preparer: (projet) => {
|
||||
ecrireExecutable('ancien')(projet);
|
||||
taperLeNom(tape)(projet);
|
||||
},
|
||||
relever: (projet) => existsSync(join(projet, 'version.json')),
|
||||
});
|
||||
assert.equal(r.statut, 1, r.erreurs);
|
||||
assert.ok(r.erreurs.includes(tape) && r.erreurs.includes(NOM), r.erreurs);
|
||||
assert.deepEqual(r.ordre, []);
|
||||
assert.deepEqual(r.executables, { [NOM]: 'ancien' });
|
||||
assert.equal(r.releve, true, 'version.json effacé');
|
||||
});
|
||||
});
|
||||
|
||||
describe('construction Windows : la page', () => {
|
||||
test('une page qui ne se construit pas arrête tout, avant podman', () => {
|
||||
const r = construire({ produit: NOM, codeNpm: 1 });
|
||||
assert.equal(r.statut, 1, r.erreurs);
|
||||
assert.match(r.erreurs, /la construction de la page échoue/);
|
||||
assert.deepEqual(r.ordre, ['npm']);
|
||||
assert.deepEqual(r.executables, {});
|
||||
});
|
||||
|
||||
test('une page absente après sa construction arrête tout, avant podman', () => {
|
||||
const r = construire({ produit: NOM, page: false });
|
||||
assert.equal(r.statut, 1, r.erreurs);
|
||||
assert.match(r.erreurs, /www\/index\.html manque/);
|
||||
assert.deepEqual(r.ordre, ['npm']);
|
||||
assert.deepEqual(r.executables, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('construction Windows : la construction', () => {
|
||||
test("construit la page puis l'exécutable, et imprime son chemin, sa taille et son type", () => {
|
||||
const r = constructionReussie();
|
||||
assert.equal(r.statut, 0, r.erreurs);
|
||||
assert.deepEqual(r.ordre, ['npm', 'podman']);
|
||||
assert.deepEqual(r.npm, ['run', '--silent', 'build:livraison']);
|
||||
assert.deepEqual(r.executables, { [NOM]: 'MZ' });
|
||||
assert.ok(r.sortie.includes(join(r.projet, 'dist-electron', NOM)), r.sortie);
|
||||
assert.match(r.sortie, /^Taille +: 2 octets$/m);
|
||||
assert.match(r.sortie, /^Type +: \S/m);
|
||||
});
|
||||
|
||||
test("le conteneur : image épinglée, projet en lecture seule, node_modules de l'hôte masqué, aucune variable de l'hôte, quel que soit containers.conf", () => {
|
||||
const { statut, erreurs, projet, podman } = constructionReussie();
|
||||
assert.equal(statut, 0, erreurs);
|
||||
assert.equal(podman[0], 'run');
|
||||
// Tout ce qui suit l'image est la commande du conteneur : les options de
|
||||
// podman la précèdent toutes.
|
||||
const image = podman.findIndex((a) => /^docker\.io\/electronuserland\/builder@sha256:[0-9a-f]{64}$/.test(a));
|
||||
assert.notEqual(image, -1, 'image non épinglée par empreinte');
|
||||
assert.deepEqual(podman.slice(image + 1, -1), ['bash', '-c']);
|
||||
const options = podman.slice(1, image);
|
||||
assert.ok(options.includes('--rm'));
|
||||
assert.ok(options.includes(`${projet}:/project:ro`), 'projet non monté en lecture seule');
|
||||
assert.ok(options.includes(`${projet}/dist-electron:/project/dist-electron`));
|
||||
assert.ok(
|
||||
options.some((a) => /^[\w.-]+:\/project\/node_modules$/.test(a)),
|
||||
'node_modules non masqué par un volume nommé',
|
||||
);
|
||||
// Aucune variable passée, et l'environnement de l'hôte refusé sur la ligne
|
||||
// de commande : un env_host = true de containers.conf le transmettrait
|
||||
// entier, BUILD_NUMBER compris.
|
||||
assert.deepEqual(options.filter((a) => a === '-e' || a.startsWith('--env')), ['--env-host=false']);
|
||||
const commande = podman.at(-1);
|
||||
assert.match(commande, /npm ci --ignore-scripts/);
|
||||
assert.match(
|
||||
commande,
|
||||
/electron-builder --win --publish never --config electron-builder\.config\.cjs/,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepte le livrable quand dist-electron/ garde celui d'une autre version et les exécutables de win-unpacked/", () => {
|
||||
const r = construire({ produit: NOM, preparer: ecrireExecutable('autre', NOM_AUTRE_VERSION) });
|
||||
assert.equal(r.statut, 0, r.erreurs);
|
||||
assert.deepEqual(r.executables, { [NOM]: 'MZ', [NOM_AUTRE_VERSION]: 'autre' });
|
||||
});
|
||||
|
||||
test("--reconstruire remplace un exécutable du même nom", () => {
|
||||
const r = construire({ options: ['--reconstruire'], produit: NOM, preparer: ecrireExecutable('ancien') });
|
||||
assert.equal(r.statut, 0, r.erreurs);
|
||||
assert.deepEqual(r.executables, { [NOM]: 'MZ' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('construction Windows : contrôle de fin de construction (§ 18.5)', () => {
|
||||
test("refuse un exécutable dont le nom ne dérive pas de version.json", () => {
|
||||
const autre = 'Gestion table tournante Libre.exe';
|
||||
const r = construire({ produit: autre });
|
||||
assert.equal(r.statut, 1, r.sortie);
|
||||
assert.ok(r.erreurs.includes(autre) && r.erreurs.includes(NOM), r.erreurs);
|
||||
assert.doesNotMatch(r.sortie, /^Taille/m);
|
||||
});
|
||||
|
||||
test("refuse quand aucun exécutable n'apparaît", () => {
|
||||
const r = construire();
|
||||
assert.equal(r.statut, 1, r.sortie);
|
||||
assert.match(r.erreurs, /aucun exécutable/);
|
||||
});
|
||||
|
||||
test("un échec sous podman ne laisse aucun exécutable sous ce nom, même avec --reconstruire", () => {
|
||||
const r = construire({ options: ['--reconstruire'], code: 7, preparer: ecrireExecutable('ancien') });
|
||||
assert.equal(r.statut, 1, r.sortie);
|
||||
assert.match(r.erreurs, /podman.*7/);
|
||||
assert.deepEqual(r.executables, {});
|
||||
});
|
||||
|
||||
test("un échec sous podman retire ce qu'electron-builder a déjà écrit sous ce nom, et rien d'autre", () => {
|
||||
const r = construire({ produit: NOM, code: 7, preparer: ecrireExecutable('autre', NOM_AUTRE_VERSION) });
|
||||
assert.equal(r.statut, 1, r.sortie);
|
||||
assert.match(r.erreurs, /podman.*7/);
|
||||
assert.ok(r.erreurs.includes(`dist-electron/${NOM} retiré`), r.erreurs);
|
||||
assert.deepEqual(r.executables, { [NOM_AUTRE_VERSION]: 'autre' });
|
||||
});
|
||||
|
||||
test("deux exécutables apparus : refusé, et celui qui porte le nom attendu est retiré", () => {
|
||||
const autre = 'Gestion table tournante Libre.exe';
|
||||
const r = construire({ produit: [NOM, autre] });
|
||||
assert.equal(r.statut, 1, r.sortie);
|
||||
assert.ok(r.erreurs.includes(autre) && r.erreurs.includes(NOM), r.erreurs);
|
||||
assert.ok(r.erreurs.includes(`dist-electron/${NOM} retiré`), r.erreurs);
|
||||
assert.equal(r.executables[NOM], undefined, `dist-electron/${NOM} reste après un refus`);
|
||||
assert.doesNotMatch(r.sortie, /^Taille/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('construction Windows : interruption', () => {
|
||||
// Le signal ne vise que le shell, comme un kill depuis un autre terminal.
|
||||
// podman écrit après l'avoir reçu, comme electron-builder qui achève son
|
||||
// travail : l'épreuve ne réagit qu'à des événements, l'annonce du podman
|
||||
// factice puis la fin du script, et le délai ne fait que les borner.
|
||||
for (const [signal, code] of [['SIGTERM', 143], ['SIGINT', 130]]) {
|
||||
test(`${signal} reçu pendant podman : le script attend sa fin, retire ce qu'il a écrit et sort en ${code}`, async () => {
|
||||
const arbre = preparerArbre({ produit: NOM, attente: true });
|
||||
const script = spawn(arbre.script, [], { cwd: arbre.base, env: arbre.environnement });
|
||||
script.stdin.on('error', () => {});
|
||||
let sortie = '';
|
||||
let erreurs = '';
|
||||
script.stderr.setEncoding('utf8').on('data', (morceau) => (erreurs += morceau));
|
||||
const lance = new Promise((resoudre) => {
|
||||
script.stdout.setEncoding('utf8').on('data', (morceau) => {
|
||||
sortie += morceau;
|
||||
if (sortie.includes(LANCE)) resoudre();
|
||||
});
|
||||
});
|
||||
const fin = once(script, 'close', { signal: AbortSignal.timeout(DELAI_MS) }).catch((erreur) => {
|
||||
throw new Error(`scripts/construire_windows.sh ne s'arrête pas en ${DELAI_MS} ms (${erreur.name})\n${erreurs}`);
|
||||
});
|
||||
fin.catch(() => {});
|
||||
try {
|
||||
await Promise.race([lance, fin]);
|
||||
assert.ok(sortie.includes(LANCE), `podman factice jamais lancé\n${erreurs}`);
|
||||
script.kill(signal);
|
||||
script.stdin.end('\n');
|
||||
const [statut, signalDeFin] = await fin;
|
||||
assert.deepEqual({ statut, signalDeFin }, { statut: code, signalDeFin: null }, erreurs);
|
||||
assert.ok(erreurs.includes(`dist-electron/${NOM} retiré`), erreurs);
|
||||
assert.deepEqual(executables(arbre.projet), {});
|
||||
} finally {
|
||||
script.stdin.destroy();
|
||||
script.kill('SIGKILL');
|
||||
rmSync(arbre.base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
62
scripts/oracle/pcg32_reference.sh
Executable file
62
scripts/oracle/pcg32_reference.sh
Executable file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env bash
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
# Imprime la sortie de la démo de l'implémentation de référence de PCG32, que
|
||||
# rejoue src/demo/prng.test.js (§ 14.11 : un vecteur pris hors du code éprouvé).
|
||||
#
|
||||
# scripts/oracle/pcg32_reference.sh
|
||||
#
|
||||
# Le script récupère pcg-c-basic en profondeur 1 dans un répertoire
|
||||
# temporaire, au commit que cite src/demo/prng.test.js, et imprime le hachage
|
||||
# du commit extrait. Il compile pcg32-demo.c avec pcg_basic.c (gcc -O2), puis
|
||||
# exécute la démo sans argument : elle ensemence par
|
||||
# pcg32_srandom_r(&rng, 42u, 54u) et imprime cinq tours de tirages. Le commit
|
||||
# est demandé par son hachage, et non comme tête d'une branche : le dépôt peut
|
||||
# avancer sans changer ce que le script compile. Le répertoire temporaire est
|
||||
# retiré à la sortie. La récupération demande un accès réseau, et échoue
|
||||
# plutôt que de demander un identifiant.
|
||||
#
|
||||
# Sortie : 0, la démo a tourné ; 1, outil absent, récupération, compilation ou
|
||||
# exécution en échec ; 2, argument reçu.
|
||||
set -euo pipefail
|
||||
|
||||
readonly DEPOT='https://github.com/imneme/pcg-c-basic'
|
||||
readonly COMMIT='bc39cd76ac3d541e618606bcc6e1e5ba5e5e6aa3'
|
||||
|
||||
refuser() {
|
||||
printf 'Oracle PCG32 : %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (($# > 0)); then
|
||||
printf 'usage : scripts/oracle/pcg32_reference.sh\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for outil in git gcc; do
|
||||
command -v "$outil" >/dev/null || refuser "$outil introuvable."
|
||||
done
|
||||
|
||||
temporaire="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$temporaire"' EXIT
|
||||
source_c="$temporaire/pcg-c-basic"
|
||||
demo="$temporaire/pcg32-demo"
|
||||
|
||||
# Un dépôt vide reçoit le seul commit COMMIT, sans historique, et l'extrait.
|
||||
recuperer() {
|
||||
git init --quiet -- "$source_c" &&
|
||||
GIT_TERMINAL_PROMPT=0 git -C "$source_c" fetch --quiet --depth 1 -- "$DEPOT" "$COMMIT" &&
|
||||
git -C "$source_c" checkout --quiet FETCH_HEAD
|
||||
}
|
||||
recuperer || refuser "récupération de $DEPOT au commit $COMMIT impossible."
|
||||
commit="$(git -C "$source_c" rev-parse HEAD)"
|
||||
|
||||
gcc -O2 -o "$demo" "$source_c/pcg32-demo.c" "$source_c/pcg_basic.c" \
|
||||
|| refuser 'compilation de pcg32-demo.c et pcg_basic.c impossible.'
|
||||
|
||||
printf 'dépôt : %s\n' "$DEPOT"
|
||||
printf 'commit : %s\n' "$commit"
|
||||
printf 'compilation : gcc -O2 pcg32-demo.c pcg_basic.c\n'
|
||||
printf 'exécution : pcg32-demo, sans argument\n\n'
|
||||
"$demo" || refuser 'la démo a échoué.'
|
||||
724
scripts/version.js
Normal file
724
scripts/version.js
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La version datée (§ 18). version.json en est l'unique source ; ce script en
|
||||
// dérive tout le reste et contrôle que rien n'en diverge.
|
||||
//
|
||||
// Une version s'écrit sous quatre formes :
|
||||
// affichée AAAA.MM.JJ.NN, quatre champs à longueur fixe, rang de 01 à 99 ;
|
||||
// son ordre alphabétique est l'ordre chronologique ;
|
||||
// technique AAAA.(MM×100+JJ).NN, trois entiers sans zéro de tête, la forme
|
||||
// qu'exigent package.json et electron-builder ;
|
||||
// Windows la forme technique suivie de « .0 » : les quatre champs de la
|
||||
// ressource de version de l'exécutable, de 16 bits chacun ;
|
||||
// nom gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe, les
|
||||
// points devenus soulignés, seul point restant celui de
|
||||
// l'extension.
|
||||
// Le jour étant inférieur à 100, MM×100+JJ se redécompose d'une seule façon :
|
||||
// la dérivation est monotone et bijective.
|
||||
//
|
||||
// En ligne de commande, la racine du projet est le parent du répertoire du
|
||||
// script :
|
||||
// node scripts/version.js engendrer réécrit src/version.genere.js, le
|
||||
// champ version de package.json et les
|
||||
// deux champs de version du paquet
|
||||
// racine de package-lock.json ;
|
||||
// node scripts/version.js controler applique les huit points du § 18.5 à
|
||||
// la date civile locale de la machine ;
|
||||
// imprime les échecs et sort en code 1,
|
||||
// ou sort en code 0.
|
||||
// Ce script n'appartient ni au moteur ni au générateur de démonstrations, et
|
||||
// l'interdit de lecture d'horloge du § 14.7 ne le vise pas : dateCivileLocale,
|
||||
// qu'appelle la commande « controler », lit l'horloge de la machine.
|
||||
import { mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Le nom du livrable (§ 18.2) ne s'écrit qu'ici : le produit, « _v », la
|
||||
// version dont les points deviennent des soulignés, l'extension. nomDuLivrable
|
||||
// sert à deriver pour une version et au gabarit pour les champs nommés ; le
|
||||
// point 7 compare chaque nom cité à ce calcul.
|
||||
const PRODUIT = 'gestion_table_tournante_libre';
|
||||
const PREFIXE_NOM = `${PRODUIT}_v`;
|
||||
const EXTENSION = '.exe';
|
||||
const nomDuLivrable = (version) => `${PREFIXE_NOM}${version.replaceAll('.', '_')}${EXTENSION}`;
|
||||
const GABARIT_NOM = nomDuLivrable('<AAAA>.<MM>.<JJ>.<NN>');
|
||||
const UN_JOUR_MS = 86_400_000;
|
||||
|
||||
const MOIS = [
|
||||
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||
];
|
||||
|
||||
// Forme affichée AAAA.MM.JJ.NN. Le rang s'y lit aussi sur trois chiffres ou
|
||||
// plus sans zéro de tête : la forme est alors reconnue, et le refus nomme le
|
||||
// plafond du rang. Un rang « 001 » reste une faute de forme.
|
||||
const FORME_AFFICHEE = /^(\d{4})\.(\d{2})\.(\d{2})\.(\d{2}|[1-9]\d{2,})$/;
|
||||
// Trois entiers sans zéro de tête ; MM×100+JJ s'écrit sur trois ou quatre
|
||||
// chiffres, le rang sur un ou deux.
|
||||
const FORME_TECHNIQUE = /^(\d{4})\.([1-9]\d{2,3})\.([1-9]\d?)$/;
|
||||
|
||||
const deuxChiffres = (n) => String(n).padStart(2, '0');
|
||||
|
||||
function estBissextile(annee) {
|
||||
return (annee % 4 === 0 && annee % 100 !== 0) || annee % 400 === 0;
|
||||
}
|
||||
|
||||
function joursDuMois(annee, mois) {
|
||||
if (mois === 2) return estBissextile(annee) ? 29 : 28;
|
||||
return mois === 4 || mois === 6 || mois === 9 || mois === 11 ? 30 : 31;
|
||||
}
|
||||
|
||||
// Lève quand { annee, mois, jour } n'est pas une date du calendrier
|
||||
// grégorien dont l'année s'écrit sur quatre chiffres sans zéro de tête — la
|
||||
// forme technique écrit l'année en entier, et un zéro de tête s'y perdrait.
|
||||
// « contexte » ouvre le message.
|
||||
function exigerDate({ annee, mois, jour }, contexte) {
|
||||
if (!Number.isInteger(annee) || annee < 1000 || annee > 9999) {
|
||||
throw new RangeError(`${contexte} : année ${annee} hors de 1000 à 9999`);
|
||||
}
|
||||
if (!Number.isInteger(mois) || mois < 1 || mois > 12) {
|
||||
throw new RangeError(`${contexte} : mois ${mois} hors de 1 à 12`);
|
||||
}
|
||||
const dernier = joursDuMois(annee, mois);
|
||||
if (!Number.isInteger(jour) || jour < 1 || jour > dernier) {
|
||||
throw new RangeError(
|
||||
`${contexte} : jour ${jour} hors de 1 à ${dernier} en ${MOIS[mois - 1]} ${annee}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formes dérivées d'une version affichée AAAA.MM.JJ.NN : { affichee,
|
||||
* technique, windows, nomFichier, annee, mois, jour, rang }, les quatre
|
||||
* derniers en entiers. Lève sur une forme non conforme, une date civile
|
||||
* invalide, un rang hors de 1 à 99.
|
||||
*/
|
||||
export function deriver(versionAffichee) {
|
||||
if (typeof versionAffichee !== 'string') {
|
||||
throw new TypeError(`version attendue sous forme de chaîne, reçu ${typeof versionAffichee}`);
|
||||
}
|
||||
const contexte = `version « ${versionAffichee} »`;
|
||||
const champs = FORME_AFFICHEE.exec(versionAffichee);
|
||||
if (champs === null) {
|
||||
throw new Error(
|
||||
`${contexte} : forme attendue AAAA.MM.JJ.NN, quatre champs à longueur fixe (§ 18.1)`,
|
||||
);
|
||||
}
|
||||
const [annee, mois, jour, rang] = champs.slice(1).map(Number);
|
||||
exigerDate({ annee, mois, jour }, contexte);
|
||||
if (rang > 99) throw new RangeError(`${contexte} : rang ${rang}, le rang plafonne à 99 (§ 18.1)`);
|
||||
if (rang < 1) throw new RangeError(`${contexte} : rang 00 hors de 01 à 99`);
|
||||
const technique = `${annee}.${mois * 100 + jour}.${rang}`;
|
||||
return {
|
||||
affichee: versionAffichee,
|
||||
technique,
|
||||
windows: `${technique}.0`,
|
||||
nomFichier: nomDuLivrable(versionAffichee),
|
||||
annee,
|
||||
mois,
|
||||
jour,
|
||||
rang,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Version affichée d'une version technique AAAA.(MM×100+JJ).NN. Lève quand
|
||||
* la chaîne ne désigne pas exactement une version : forme non conforme, zéro
|
||||
* de tête, date civile invalide, rang hors de 1 à 99. Sur une chaîne
|
||||
* acceptée, deriver(redecomposer(t)).technique === t.
|
||||
*/
|
||||
export function redecomposer(versionTechnique) {
|
||||
if (typeof versionTechnique !== 'string') {
|
||||
throw new TypeError(
|
||||
`version technique attendue sous forme de chaîne, reçu ${typeof versionTechnique}`,
|
||||
);
|
||||
}
|
||||
const contexte = `version technique « ${versionTechnique} »`;
|
||||
const champs = FORME_TECHNIQUE.exec(versionTechnique);
|
||||
if (champs === null) {
|
||||
throw new Error(
|
||||
`${contexte} : forme attendue AAAA.(MM×100+JJ).NN, trois entiers sans zéro de tête, rang de 1 à 99 (§ 18.1)`,
|
||||
);
|
||||
}
|
||||
const [annee, moisJour, rang] = champs.slice(1).map(Number);
|
||||
const mois = Math.floor(moisJour / 100);
|
||||
const jour = moisJour % 100;
|
||||
exigerDate({ annee, mois, jour }, contexte);
|
||||
return `${annee}.${deuxChiffres(mois)}.${deuxChiffres(jour)}.${deuxChiffres(rang)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date en toutes lettres du titre d'une section du changelog : « 9 mars
|
||||
* 2031 », « 1er janvier 2027 ». Lève sur une date hors du calendrier.
|
||||
*/
|
||||
export function dateEnLettres({ annee, mois, jour }) {
|
||||
exigerDate({ annee, mois, jour }, 'date');
|
||||
return `${jour === 1 ? '1er' : jour} ${MOIS[mois - 1]} ${annee}`;
|
||||
}
|
||||
|
||||
// --- Le module engendré et les recopies ------------------------------------
|
||||
|
||||
const MODULE = 'src/version.genere.js';
|
||||
const CONSEIL_ENGENDRER = 'lancer « node scripts/version.js engendrer »';
|
||||
|
||||
const EN_TETE_LICENCE = [
|
||||
'// © 2026 TechnoLibre (http://www.technolibre.ca)',
|
||||
'// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Contenu exact de src/version.genere.js pour une version affichée :
|
||||
* l'en-tête de licence, une ligne vide, puis la constante VERSION sous ses
|
||||
* formes affichée et technique. Le module ne porte que la version ; la
|
||||
* provenance de la construction vient du mode de Vite.
|
||||
*/
|
||||
export function engendrerModule(versionAffichee) {
|
||||
const { affichee, technique } = deriver(versionAffichee);
|
||||
return [
|
||||
...EN_TETE_LICENCE,
|
||||
'',
|
||||
'// Engendré par scripts/version.js depuis version.json — ne pas modifier.',
|
||||
'export const VERSION = Object.freeze({',
|
||||
` affichee: '${affichee}',`,
|
||||
` technique: '${technique}',`,
|
||||
'});',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// JSON écrit comme npm l'écrit : indentation de deux espaces, saut de ligne
|
||||
// final.
|
||||
const enJson = (valeur) => `${JSON.stringify(valeur, null, 2)}\n`;
|
||||
|
||||
// Contenu d'un fichier relatif à la racine, ou null s'il n'existe pas.
|
||||
function lireTexte(racine, chemin) {
|
||||
try {
|
||||
return readFileSync(join(racine, chemin), 'utf8');
|
||||
} catch (erreur) {
|
||||
if (erreur.code === 'ENOENT') return null;
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Valeur JSON d'un fichier relatif à la racine. Lève en nommant le fichier
|
||||
// quand il est illisible, et quand il manque, avec « raisonSiAbsent » pour
|
||||
// suite du message.
|
||||
function lireJson(racine, chemin, raisonSiAbsent) {
|
||||
const texte = lireTexte(racine, chemin);
|
||||
if (texte === null) throw new Error(`${chemin} absent : ${raisonSiAbsent}`);
|
||||
try {
|
||||
return JSON.parse(texte);
|
||||
} catch (erreur) {
|
||||
throw new Error(`${chemin} illisible : ${erreur.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Version affichée que porte version.json, non vérifiée. Lève sur un
|
||||
// fichier absent, illisible ou sans champ « version » textuel.
|
||||
function lireVersion(racine) {
|
||||
const donnees = lireJson(racine, 'version.json', "c'est l'unique source de la version (§ 18.3)");
|
||||
if (typeof donnees?.version !== 'string') {
|
||||
throw new Error('version.json : champ « version » absent ou non textuel');
|
||||
}
|
||||
return donnees.version;
|
||||
}
|
||||
|
||||
// Formes dérivées de la version de version.json. Chaque refus nomme
|
||||
// version.json.
|
||||
function versionDeLaSource(racine) {
|
||||
const affichee = lireVersion(racine);
|
||||
try {
|
||||
return deriver(affichee);
|
||||
} catch (erreur) {
|
||||
throw new Error(`version.json : ${erreur.message}`, { cause: erreur });
|
||||
}
|
||||
}
|
||||
|
||||
// Vrai pour un objet JSON, ni tableau ni null.
|
||||
const estObjet = (valeur) =>
|
||||
typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||||
|
||||
const RAISON_PAQUET = 'il porte la forme technique de la version (§ 18.3)';
|
||||
const RAISON_VERROU = `« npm install » le crée, puis ${CONSEIL_ENGENDRER}`;
|
||||
|
||||
/**
|
||||
* Réécrit, depuis version.json, le module engendré, le champ version de
|
||||
* package.json et les champs version et packages[""].version de
|
||||
* package-lock.json, en forme technique. Tout est lu et vérifié avant la
|
||||
* première écriture : une version non conforme, un package.json ou un verrou
|
||||
* absent font lever sans rien écrire. Un fichier déjà à jour n'est pas
|
||||
* réécrit. Rend les chemins réécrits, relatifs à la racine.
|
||||
*/
|
||||
export function engendrer(racine) {
|
||||
const { affichee, technique } = versionDeLaSource(racine);
|
||||
const paquet = lireJson(racine, 'package.json', RAISON_PAQUET);
|
||||
if (!estObjet(paquet)) throw new Error('package.json : un objet JSON est attendu');
|
||||
const verrou = lireJson(racine, 'package-lock.json', RAISON_VERROU);
|
||||
if (!estObjet(verrou) || !estObjet(verrou.packages) || !estObjet(verrou.packages[''])) {
|
||||
throw new Error('package-lock.json : paquet racine packages[""] absent');
|
||||
}
|
||||
const cibles = [
|
||||
[MODULE, engendrerModule(affichee)],
|
||||
['package.json', enJson({ ...paquet, version: technique })],
|
||||
[
|
||||
'package-lock.json',
|
||||
enJson({
|
||||
...verrou,
|
||||
version: technique,
|
||||
packages: { ...verrou.packages, '': { ...verrou.packages[''], version: technique } },
|
||||
}),
|
||||
],
|
||||
];
|
||||
const reecrits = [];
|
||||
for (const [chemin, contenu] of cibles) {
|
||||
if (lireTexte(racine, chemin) === contenu) continue;
|
||||
mkdirSync(dirname(join(racine, chemin)), { recursive: true });
|
||||
writeFileSync(join(racine, chemin), contenu);
|
||||
reecrits.push(chemin);
|
||||
}
|
||||
return reecrits;
|
||||
}
|
||||
|
||||
// --- Le contrôle du § 18.5 ------------------------------------------------
|
||||
|
||||
// Les deux lignes obligatoires de la section de tête du changelog (§ 18.4) :
|
||||
// le libellé en gras, les deux-points, puis une réponse.
|
||||
const LIGNES_OBLIGATOIRES = [
|
||||
['**Version de format des fichiers**', /^\*\*Version de format des fichiers\*\*\s*:\s*\S/],
|
||||
['**En remplaçant**', /^\*\*En remplaçant\*\*\s*:\s*\S/],
|
||||
];
|
||||
|
||||
// Périmètre du balayage du point 6, posé ici et non après coup : les fichiers
|
||||
// de la racine, et les arbres des sources, des scripts, de la coquille et des
|
||||
// épreuves. Seuls ces arbres sont parcourus : les dépendances et les sorties
|
||||
// de construction (www/, dist*/, android/) n'en font pas partie, et un
|
||||
// répertoire node_modules n'est jamais descendu. En sont exclus :
|
||||
// - version.json, package-lock.json, le module engendré et le changelog, où
|
||||
// la version se recopie par construction et que les points 1 à 3
|
||||
// contrôlent ;
|
||||
// - les documents (*.md), dont le point 7 contrôle les noms cités ;
|
||||
// - les épreuves de la dérivation, qui citent des versions par nécessité ;
|
||||
// - les données d'épreuve, sous test/fixtures/.
|
||||
// package.json reste balayé : la forme technique y est à sa place, la forme
|
||||
// affichée non.
|
||||
const ARBRES_BALAYES = ['src', 'scripts', 'electron', 'test'];
|
||||
const FICHIERS_EXCLUS = new Set([
|
||||
'version.json',
|
||||
'package-lock.json',
|
||||
MODULE,
|
||||
'CHANGELOG.md',
|
||||
'scripts/version.test.js',
|
||||
]);
|
||||
const REPERTOIRES_EXCLUS = new Set(['test/fixtures']);
|
||||
const DOCUMENT = /\.md$/i;
|
||||
const ARBRE_DES_SOURCES = 'src/';
|
||||
|
||||
// Une chaîne de chaque forme, bornée : ni chiffre ni point devant, aucun
|
||||
// chiffre derrière. La forme Windows contient la forme technique.
|
||||
const CHAINE_AFFICHEE = /(?<![\d.])\d{4}\.\d{2}\.\d{2}\.\d{2}(?!\d)/g;
|
||||
const CHAINE_TECHNIQUE = /(?<![\d.])\d{4}\.\d{3,4}\.\d{1,2}(?!\d)/g;
|
||||
|
||||
// Documents du point 7, à la racine.
|
||||
const DOCUMENTS_FIXES = ['README.md', 'CHANGELOG.md'];
|
||||
const GUIDE = /^GUIDE-.*\.md$/;
|
||||
const LITTERAL_NON_SUBSTITUE = /#_#/g;
|
||||
// Un nom d'exécutable cité : depuis le nom du produit jusqu'à la première
|
||||
// extension, sans blanc ni délimiteur de Markdown ou de code. La casse est
|
||||
// ignorée à la recherche, pas à la conformité. Le préfixe seul, sans
|
||||
// extension, désigne une famille de fichiers et n'est pas un nom.
|
||||
const NOM_CITE = new RegExp(
|
||||
`${RegExp.escape(PRODUIT)}[^\\s\`'"()[\\]{}|*]*?${RegExp.escape(EXTENSION)}(?!\\w)`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
// Vrai quand le nom cité est le gabarit, ou le nom que deriver calcule pour
|
||||
// la version dont il porte les chiffres, lus après le préfixe. Tout nom admis
|
||||
// sort ainsi de deriver, à l'octet.
|
||||
function nomConforme(nom) {
|
||||
if (nom === GABARIT_NOM) return true;
|
||||
const chiffres = nom.slice(PREFIXE_NOM.length).match(/\d+/g) ?? [];
|
||||
return essayer(() => deriver(chiffres.join('.')).nomFichier) === nom;
|
||||
}
|
||||
|
||||
// Résultat de « calcul », ou null s'il lève.
|
||||
function essayer(calcul) {
|
||||
try {
|
||||
return calcul();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ordre de deux versions dérivées, comparées champ par champ comme quatre
|
||||
// entiers : négatif quand a précède b.
|
||||
function comparerVersions(a, b) {
|
||||
return a.annee - b.annee || a.mois - b.mois || a.jour - b.jour || a.rang - b.rang;
|
||||
}
|
||||
|
||||
// Rang d'une date civile dans le calendrier, en jours : la différence de
|
||||
// deux rangs compte les jours qui séparent les deux dates.
|
||||
const numeroDeJour = ({ annee, mois, jour }) => Date.UTC(annee, mois - 1, jour) / UN_JOUR_MS;
|
||||
|
||||
// { annee, mois, jour } d'une date civile AAAA-MM-JJ ; lève sur toute autre
|
||||
// forme et sur une date hors du calendrier.
|
||||
function lireDateCivile(dateCivile) {
|
||||
const champs =
|
||||
typeof dateCivile === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateCivile) : null;
|
||||
if (champs === null) {
|
||||
throw new TypeError(`dateCivile attendue sous la forme AAAA-MM-JJ, reçu ${String(dateCivile)}`);
|
||||
}
|
||||
const [annee, mois, jour] = champs.slice(1).map(Number);
|
||||
exigerDate({ annee, mois, jour }, `dateCivile « ${dateCivile} »`);
|
||||
return { annee, mois, jour };
|
||||
}
|
||||
|
||||
// Sections du changelog : chaque titre « ## », avec son numéro de ligne, son
|
||||
// texte et les lignes de son corps. Un bloc de code, de la ligne qui l'ouvre
|
||||
// par ``` ou ~~~ à celle qui le ferme, n'apporte ni titre ni ligne de corps :
|
||||
// ce qu'il contient s'affiche comme du code, et une ligne obligatoire citée
|
||||
// là n'est pas écrite.
|
||||
function sectionsDuChangelog(texte) {
|
||||
const sections = [];
|
||||
let dansUnBloc = false;
|
||||
texte.split(/\r?\n/).forEach((ligne, i) => {
|
||||
if (/^\s*(```|~~~)/.test(ligne)) {
|
||||
dansUnBloc = !dansUnBloc;
|
||||
return;
|
||||
}
|
||||
if (dansUnBloc) return;
|
||||
const titre = /^## (.*)$/.exec(ligne);
|
||||
if (titre !== null) {
|
||||
sections.push({ ligne: i + 1, titre: titre[1].trim(), corps: [] });
|
||||
} else if (sections.length > 0) {
|
||||
sections[sections.length - 1].corps.push(ligne);
|
||||
}
|
||||
});
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Chemins relatifs, en « / », des fichiers d'un arbre, sans descendre dans
|
||||
// node_modules ni dans un répertoire exclu. Un arbre absent n'ajoute rien.
|
||||
function parcourir(racine, arbre, chemins) {
|
||||
let entrees;
|
||||
try {
|
||||
entrees = readdirSync(join(racine, arbre), { withFileTypes: true });
|
||||
} catch (erreur) {
|
||||
if (erreur.code === 'ENOENT' || erreur.code === 'ENOTDIR') return;
|
||||
throw erreur;
|
||||
}
|
||||
for (const entree of entrees) {
|
||||
const chemin = `${arbre}/${entree.name}`;
|
||||
if (entree.isDirectory()) {
|
||||
if (entree.name !== 'node_modules' && !REPERTOIRES_EXCLUS.has(chemin)) {
|
||||
parcourir(racine, chemin, chemins);
|
||||
}
|
||||
} else if (entree.isFile()) {
|
||||
chemins.push(chemin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fichiers de la racine, triés ; un répertoire ou un lien n'en est pas.
|
||||
function fichiersDeLaRacine(racine) {
|
||||
return readdirSync(racine, { withFileTypes: true })
|
||||
.filter((entree) => entree.isFile())
|
||||
.map((entree) => entree.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Fichiers du périmètre du point 6, triés.
|
||||
function fichiersBalayes(racine) {
|
||||
const chemins = fichiersDeLaRacine(racine);
|
||||
for (const arbre of ARBRES_BALAYES) parcourir(racine, arbre, chemins);
|
||||
return chemins.filter((chemin) => !FICHIERS_EXCLUS.has(chemin) && !DOCUMENT.test(chemin)).sort();
|
||||
}
|
||||
|
||||
// Chaque occurrence du motif dans le texte : { ligne, chaine }.
|
||||
function occurrences(texte, motif) {
|
||||
const trouvees = [];
|
||||
texte.split(/\r?\n/).forEach((ligne, i) => {
|
||||
for (const [chaine] of ligne.matchAll(motif)) trouvees.push({ ligne: i + 1, chaine });
|
||||
});
|
||||
return trouvees;
|
||||
}
|
||||
|
||||
// Point 1 : package.json et les deux champs du verrou portent la forme
|
||||
// technique dérivée de version.json, et elle se redécompose en la version
|
||||
// affichée.
|
||||
function controlerRecopies(racine, { affichee, technique }, echec) {
|
||||
try {
|
||||
const porte = lireJson(racine, 'package.json', RAISON_PAQUET)?.version;
|
||||
const retour = essayer(() => redecomposer(porte));
|
||||
if (porte !== technique || retour !== affichee) {
|
||||
const lue = retour === null ? '' : ` (soit ${retour})`;
|
||||
echec(
|
||||
1,
|
||||
`package.json porte la version ${JSON.stringify(porte)}${lue}, version.json donne ${affichee}, soit ${technique} : ${CONSEIL_ENGENDRER}`,
|
||||
);
|
||||
}
|
||||
} catch (erreur) {
|
||||
echec(1, erreur.message);
|
||||
}
|
||||
try {
|
||||
const verrou = lireJson(racine, 'package-lock.json', RAISON_VERROU);
|
||||
const champs = [
|
||||
['version', verrou?.version],
|
||||
['packages[""].version', verrou?.packages?.['']?.version],
|
||||
];
|
||||
for (const [champ, porte] of champs) {
|
||||
if (porte !== technique) {
|
||||
echec(
|
||||
1,
|
||||
`package-lock.json, champ « ${champ} » : ${JSON.stringify(porte)}, version.json donne ${technique} : ${CONSEIL_ENGENDRER}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (erreur) {
|
||||
echec(1, erreur.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Point 2 : le module engendré reproduit, caractère pour caractère, ce que
|
||||
// le script engendre ; l'échec cite la première ligne qui diffère.
|
||||
function controlerModule(racine, { affichee }, echec) {
|
||||
const present = lireTexte(racine, MODULE);
|
||||
if (present === null) {
|
||||
echec(2, `${MODULE} absent : ${CONSEIL_ENGENDRER}`);
|
||||
return;
|
||||
}
|
||||
const attendu = engendrerModule(affichee);
|
||||
if (present === attendu) return;
|
||||
const lignesPresentes = present.split('\n');
|
||||
const lignesAttendues = attendu.split('\n');
|
||||
const longueur = Math.max(lignesPresentes.length, lignesAttendues.length);
|
||||
let i = 0;
|
||||
while (i < longueur && lignesPresentes[i] === lignesAttendues[i]) i += 1;
|
||||
const citer = (ligne) => (ligne === undefined ? 'la fin du fichier' : `« ${ligne} »`);
|
||||
echec(
|
||||
2,
|
||||
`${MODULE}, ligne ${i + 1} : ${citer(lignesPresentes[i])} au lieu de ${citer(lignesAttendues[i])}, que le script engendre : ${CONSEIL_ENGENDRER}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Point 3 : la section de tête porte la version courante, la date en lettres
|
||||
// que cette version engendre, et les deux lignes obligatoires. Sans version
|
||||
// valide, seules la forme du titre et les deux lignes se contrôlent.
|
||||
function controlerTete(sections, version, echec) {
|
||||
const tete = sections[0];
|
||||
if (tete === undefined) {
|
||||
echec(3, 'CHANGELOG.md : aucune section « ## » ; la section de tête porte la version courante');
|
||||
return;
|
||||
}
|
||||
const lieu = `CHANGELOG.md, ligne ${tete.ligne}`;
|
||||
const titre = /^(\S+) — (.+)$/.exec(tete.titre);
|
||||
if (titre === null) {
|
||||
echec(3, `${lieu} : le titre « ## ${tete.titre} » n'a pas la forme « ## <version> — <date en lettres> »`);
|
||||
} else if (version !== null) {
|
||||
const [, porte, date] = titre;
|
||||
const attendue = dateEnLettres(version);
|
||||
if (porte !== version.affichee) {
|
||||
echec(
|
||||
3,
|
||||
`${lieu} : la section de tête porte ${porte}, version.json porte ${version.affichee} ; la section de cette version s'écrit en tête (§ 18.4)`,
|
||||
);
|
||||
} else if (date !== attendue) {
|
||||
echec(3, `${lieu} : la date « ${date} » n'est pas « ${attendue} », que la version ${porte} engendre`);
|
||||
}
|
||||
}
|
||||
for (const [libelle, motif] of LIGNES_OBLIGATOIRES) {
|
||||
if (!tete.corps.some((ligne) => motif.test(ligne))) {
|
||||
echec(3, `${lieu} : la ligne obligatoire « ${libelle} : … » manque à la section de tête (§ 18.4)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Point 4 : chaque titre de section s'ouvre sur une version, aucune version
|
||||
// n'est portée deux fois, et chaque section porte une version strictement
|
||||
// antérieure à celle de la section qui la précède.
|
||||
function controlerOrdre(sections, echec) {
|
||||
const vues = new Map();
|
||||
let precedente = null;
|
||||
for (const section of sections) {
|
||||
const version = essayer(() => deriver(section.titre.split(/\s/)[0]));
|
||||
if (version === null) {
|
||||
echec(
|
||||
4,
|
||||
`CHANGELOG.md, ligne ${section.ligne} : le titre « ## ${section.titre} » ne s'ouvre pas sur une version AAAA.MM.JJ.NN`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const deja = vues.get(version.affichee);
|
||||
if (deja !== undefined) {
|
||||
echec(4, `CHANGELOG.md, lignes ${deja} et ${section.ligne} : deux sections portent la version ${version.affichee}`);
|
||||
} else {
|
||||
vues.set(version.affichee, section.ligne);
|
||||
if (precedente !== null && comparerVersions(version, precedente.version) > 0) {
|
||||
echec(
|
||||
4,
|
||||
`CHANGELOG.md, ligne ${section.ligne} : la section ${version.affichee} suit la section ${precedente.version.affichee} (ligne ${precedente.ligne}) ; les versions vont en ordre strictement décroissant`,
|
||||
);
|
||||
}
|
||||
}
|
||||
precedente = { version, ligne: section.ligne };
|
||||
}
|
||||
}
|
||||
|
||||
// Point 5 : la date de la version ne dépasse pas de plus d'un jour la date
|
||||
// civile de la machine. La tolérance absorbe l'écart de fuseau.
|
||||
function controlerDate(version, civile, echec) {
|
||||
const avance = numeroDeJour(version) - numeroDeJour(civile);
|
||||
if (avance > 1) {
|
||||
echec(
|
||||
5,
|
||||
`version.json : la version ${version.affichee} est datée du ${dateEnLettres(version)}, ${avance} jours après la date civile de la machine, le ${dateEnLettres(civile)} ; la tolérance est d'un jour`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Point 6 : aucune chaîne de forme affichée hors de version.json, du module
|
||||
// engendré et du changelog ; aucune de forme technique hors de package.json
|
||||
// et du module engendré. Rend le nombre de fichiers de src/ examinés, que
|
||||
// contrôle le point 8. Un fichier binaire, qui porte un octet nul, n'est pas
|
||||
// examiné.
|
||||
function controlerFormes(racine, echec) {
|
||||
let sourcesExaminees = 0;
|
||||
for (const chemin of fichiersBalayes(racine)) {
|
||||
const texte = lireTexte(racine, chemin);
|
||||
if (texte === null || texte.includes('\u0000')) continue;
|
||||
if (chemin.startsWith(ARBRE_DES_SOURCES)) sourcesExaminees += 1;
|
||||
for (const { ligne, chaine } of occurrences(texte, CHAINE_AFFICHEE)) {
|
||||
echec(
|
||||
6,
|
||||
`${chemin}, ligne ${ligne} : « ${chaine} », forme affichée, hors de version.json, du module engendré et du changelog`,
|
||||
);
|
||||
}
|
||||
if (chemin === 'package.json') continue;
|
||||
for (const { ligne, chaine } of occurrences(texte, CHAINE_TECHNIQUE)) {
|
||||
echec(6, `${chemin}, ligne ${ligne} : « ${chaine} », forme technique, hors de package.json et du module engendré`);
|
||||
}
|
||||
}
|
||||
return sourcesExaminees;
|
||||
}
|
||||
|
||||
// Point 7 : aucun document ne porte le littéral #_# ni ne cite un nom
|
||||
// d'exécutable hors du gabarit du § 18.2. Rend le nombre de documents
|
||||
// examinés, que contrôle le point 8.
|
||||
function controlerDocuments(racine, echec) {
|
||||
const documents = fichiersDeLaRacine(racine).filter(
|
||||
(nom) => DOCUMENTS_FIXES.includes(nom) || GUIDE.test(nom),
|
||||
);
|
||||
for (const document of documents) {
|
||||
const texte = lireTexte(racine, document);
|
||||
for (const { ligne, chaine } of occurrences(texte, LITTERAL_NON_SUBSTITUE)) {
|
||||
echec(7, `${document}, ligne ${ligne} : littéral « ${chaine} », un gabarit non substitué`);
|
||||
}
|
||||
for (const { ligne, chaine } of occurrences(texte, NOM_CITE)) {
|
||||
if (!nomConforme(chaine)) {
|
||||
echec(7, `${document}, ligne ${ligne} : « ${chaine} » ne se conforme pas au gabarit ${GABARIT_NOM} (§ 18.2)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return documents.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique à l'arbre « racine » les huit points du § 18.5, à la date civile
|
||||
* « dateCivile » (AAAA-MM-JJ) de la machine qui construit. Rend la liste des
|
||||
* échecs, chacun préfixé du numéro de son point (« 1. … ») et nommant
|
||||
* l'endroit qui diverge ; liste vide = conforme. Lève sur une dateCivile mal
|
||||
* formée.
|
||||
*/
|
||||
export function controler(racine, { dateCivile }) {
|
||||
const civile = lireDateCivile(dateCivile);
|
||||
const echecs = [];
|
||||
const echec = (point, texte) => echecs.push(`${point}. ${texte}`);
|
||||
|
||||
let version = null;
|
||||
try {
|
||||
version = versionDeLaSource(racine);
|
||||
} catch (erreur) {
|
||||
echec(1, erreur.message);
|
||||
}
|
||||
if (version !== null) {
|
||||
controlerRecopies(racine, version, echec);
|
||||
controlerModule(racine, version, echec);
|
||||
}
|
||||
const changelog = lireTexte(racine, 'CHANGELOG.md');
|
||||
if (changelog === null) {
|
||||
echec(3, "CHANGELOG.md absent : la section de la version courante s'écrit à la livraison (§ 18.4)");
|
||||
} else {
|
||||
const sections = sectionsDuChangelog(changelog);
|
||||
controlerTete(sections, version, echec);
|
||||
controlerOrdre(sections, echec);
|
||||
}
|
||||
if (version !== null) controlerDate(version, civile, echec);
|
||||
const sourcesExaminees = controlerFormes(racine, echec);
|
||||
const documentsExamines = controlerDocuments(racine, echec);
|
||||
if (sourcesExaminees === 0) {
|
||||
echec(8, `le balayage du point 6 n'examine aucun fichier sous ${ARBRE_DES_SOURCES} : zéro fichier examiné n'est pas zéro problème (§ 14.2)`);
|
||||
}
|
||||
if (documentsExamines === 0) {
|
||||
echec(8, 'le balayage du point 7 n\'examine aucun document (README.md, CHANGELOG.md, GUIDE-*.md)');
|
||||
}
|
||||
return echecs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date civile locale d'un instant, sous la forme AAAA-MM-JJ ; par défaut,
|
||||
* celle de la machine au moment de l'appel.
|
||||
*/
|
||||
export function dateCivileLocale(instant = new Date()) {
|
||||
return `${instant.getFullYear()}-${deuxChiffres(instant.getMonth() + 1)}-${deuxChiffres(instant.getDate())}`;
|
||||
}
|
||||
|
||||
// --- Ligne de commande ------------------------------------------------------
|
||||
|
||||
const USAGE = 'usage : node scripts/version.js engendrer | controler';
|
||||
|
||||
// Exécute une commande sur l'arbre « racine » et rend le code de sortie : 0
|
||||
// réussi, 1 échec, 2 commande inconnue.
|
||||
function executer(commande, racine) {
|
||||
if (commande === 'engendrer') {
|
||||
try {
|
||||
const reecrits = engendrer(racine);
|
||||
const version = lireVersion(racine);
|
||||
console.log(
|
||||
reecrits.length === 0
|
||||
? `Version ${version} : les recopies sont à jour.`
|
||||
: `Version ${version} : réécrit ${reecrits.join(', ')}.`,
|
||||
);
|
||||
return 0;
|
||||
} catch (erreur) {
|
||||
console.error(`Version : ${erreur.message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (commande === 'controler') {
|
||||
const echecs = controler(racine, { dateCivile: dateCivileLocale() });
|
||||
if (echecs.length === 0) {
|
||||
console.log(`Version ${lireVersion(racine)} : conforme aux huit points du § 18.5.`);
|
||||
return 0;
|
||||
}
|
||||
console.error(`Version : ${echecs.length} échec(s) au contrôle du § 18.5.`);
|
||||
for (const echec of echecs) console.error(echec);
|
||||
return 1;
|
||||
}
|
||||
console.error(USAGE);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Vrai quand ce fichier est le script que Node a lancé, et non un module
|
||||
// importé.
|
||||
function estLanceDirectement() {
|
||||
if (process.argv[1] === undefined) return false;
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (estLanceDirectement()) {
|
||||
process.exitCode = executer(process.argv[2], fileURLToPath(new URL('..', import.meta.url)));
|
||||
}
|
||||
858
scripts/version.test.js
Normal file
858
scripts/version.test.js
Normal file
|
|
@ -0,0 +1,858 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves de la version datée (§ 18). Ce fichier cite des versions en clair
|
||||
// par nécessité : le balayage du point 6 du § 18.5 l'exclut, et lui seul.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, test } from '../test/lanceur.js';
|
||||
import {
|
||||
controler,
|
||||
dateCivileLocale,
|
||||
dateEnLettres,
|
||||
deriver,
|
||||
engendrer,
|
||||
engendrerModule,
|
||||
redecomposer,
|
||||
} from './version.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
const SCRIPT = fileURLToPath(new URL('./version.js', import.meta.url));
|
||||
|
||||
const UN_JOUR_MS = 86_400_000;
|
||||
|
||||
// Les jours d'une année, énumérés par le calendrier de Date.UTC : l'oracle ne
|
||||
// partage rien avec la règle des mois et des années bissextiles du module
|
||||
// éprouvé.
|
||||
function joursDe(annee) {
|
||||
const jours = [];
|
||||
for (
|
||||
let instant = Date.UTC(annee, 0, 1);
|
||||
new Date(instant).getUTCFullYear() === annee;
|
||||
instant += UN_JOUR_MS
|
||||
) {
|
||||
const date = new Date(instant);
|
||||
jours.push({ annee, mois: date.getUTCMonth() + 1, jour: date.getUTCDate() });
|
||||
}
|
||||
return jours;
|
||||
}
|
||||
|
||||
// Dernier jour du mois, lu dans le même calendrier : le jour 0 d'un mois est
|
||||
// la veille de son premier jour.
|
||||
function dernierJour(annee, mois) {
|
||||
return new Date(Date.UTC(annee, mois, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
const deux = (n) => String(n).padStart(2, '0');
|
||||
const forme = ({ annee, mois, jour }, rang) =>
|
||||
`${annee}.${deux(mois)}.${deux(jour)}.${deux(rang)}`;
|
||||
const champs = (technique) => technique.split('.').map(Number);
|
||||
|
||||
// Vrai quand a précède strictement b, comparés champ à champ comme des
|
||||
// entiers.
|
||||
function precede(a, b) {
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return a[i] < b[i];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const MOIS = [
|
||||
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||
];
|
||||
|
||||
describe('version : dérivation (§ 18.1, § 18.2)', () => {
|
||||
test('une version affichée donne ses formes technique et Windows et le nom du livrable', () => {
|
||||
assert.deepEqual(deriver('2026.10.05.01'), {
|
||||
affichee: '2026.10.05.01',
|
||||
technique: '2026.1005.1',
|
||||
windows: '2026.1005.1.0',
|
||||
nomFichier: 'gestion_table_tournante_libre_v2026_10_05_01.exe',
|
||||
annee: 2026,
|
||||
mois: 10,
|
||||
jour: 5,
|
||||
rang: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('le mois garde son zéro de tête dans la forme affichée et le perd dans la technique', () => {
|
||||
assert.equal(deriver('2026.01.05.01').technique, '2026.105.1');
|
||||
assert.equal(redecomposer('2026.105.1'), '2026.01.05.01');
|
||||
});
|
||||
|
||||
test('la forme technique croît avec la date, champ à champ', () => {
|
||||
const suite = ['2026.01.05.01', '2026.10.05.01', '2026.12.31.01', '2027.01.05.01'].map(
|
||||
(version) => champs(deriver(version).technique),
|
||||
);
|
||||
assert.deepEqual(suite, [
|
||||
[2026, 105, 1],
|
||||
[2026, 1005, 1],
|
||||
[2026, 1231, 1],
|
||||
[2027, 105, 1],
|
||||
]);
|
||||
for (let i = 1; i < suite.length; i += 1) {
|
||||
assert.ok(precede(suite[i - 1], suite[i]), `${suite[i - 1]} < ${suite[i]}`);
|
||||
}
|
||||
assert.ok(
|
||||
precede(champs(deriver('2026.10.05.01').technique), champs(deriver('2026.10.05.02').technique)),
|
||||
);
|
||||
});
|
||||
|
||||
test('aller-retour sur chaque jour de 2026 et de 2028, sur les 99 rangs', () => {
|
||||
const jours = [...joursDe(2026), ...joursDe(2028)];
|
||||
assert.equal(jours.length, 365 + 366);
|
||||
const affichees = [];
|
||||
const noms = [];
|
||||
let veille = null;
|
||||
jours.forEach((date, i) => {
|
||||
const rang = 1 + (i % 99);
|
||||
const version = forme(date, rang);
|
||||
const derivee = deriver(version);
|
||||
assert.equal(derivee.technique, `${date.annee}.${date.mois * 100 + date.jour}.${rang}`);
|
||||
assert.equal(derivee.windows, `${derivee.technique}.0`);
|
||||
assert.equal(
|
||||
derivee.nomFichier,
|
||||
`gestion_table_tournante_libre_v${version.replaceAll('.', '_')}.exe`,
|
||||
);
|
||||
assert.equal(redecomposer(derivee.technique), version);
|
||||
// Chaque champ de la ressource de version Windows tient sur 16 bits.
|
||||
for (const champ of champs(derivee.windows)) assert.ok(champ <= 0xffff, derivee.windows);
|
||||
const jour = champs(derivee.technique).slice(0, 2);
|
||||
if (veille !== null) assert.ok(precede(veille, jour), `${veille} < ${jour}`);
|
||||
veille = jour;
|
||||
affichees.push(forme(date, 1));
|
||||
noms.push(deriver(forme(date, 1)).nomFichier);
|
||||
});
|
||||
// L'ordre alphabétique des formes affichées et des noms de fichiers est
|
||||
// l'ordre chronologique.
|
||||
assert.deepEqual([...affichees].sort(), affichees);
|
||||
assert.deepEqual([...noms].sort(), noms);
|
||||
});
|
||||
|
||||
test('le dernier jour de chaque mois passe, le lendemain est refusé', () => {
|
||||
let mois = 0;
|
||||
for (const annee of [2026, 2028]) {
|
||||
for (let m = 1; m <= 12; m += 1) {
|
||||
const dernier = dernierJour(annee, m);
|
||||
assert.doesNotThrow(() => deriver(forme({ annee, mois: m, jour: dernier }, 1)));
|
||||
assert.throws(() => deriver(forme({ annee, mois: m, jour: dernier + 1 }, 1)), /jour/);
|
||||
assert.throws(() => deriver(forme({ annee, mois: m, jour: 0 }, 1)), /jour/);
|
||||
mois += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(mois, 24);
|
||||
assert.equal(dernierJour(2026, 2), 28);
|
||||
assert.equal(dernierJour(2028, 2), 29);
|
||||
});
|
||||
|
||||
test('refuse une forme non conforme, une date invalide, un rang hors de 01 à 99', () => {
|
||||
const refus = [
|
||||
['2026.1.5.1', /forme/],
|
||||
['2026.13.01.01', /mois/],
|
||||
['2026.00.10.01', /mois/],
|
||||
['2026.02.30.01', /jour/],
|
||||
['2026.10.05.00', /rang/],
|
||||
['2026.10.05.100', /rang.*99/],
|
||||
['2026.10.05.001', /forme/], // zéro de tête : une faute de forme, pas de plafond
|
||||
['2026.13.45.100', /mois/], // la date se contrôle avant le plafond du rang
|
||||
['2026.10.05.1', /forme/],
|
||||
['2026.10.05', /forme/],
|
||||
['2026.10.05.01 ', /forme/],
|
||||
['v2026.10.05.01', /forme/],
|
||||
['2026-10-05-01', /forme/],
|
||||
['0999.10.05.01', /année/],
|
||||
];
|
||||
for (const [version, motif] of refus) {
|
||||
assert.throws(() => deriver(version), motif, version);
|
||||
}
|
||||
assert.throws(() => deriver(20261005), TypeError);
|
||||
});
|
||||
|
||||
test('accepte le rang 99', () => {
|
||||
assert.equal(deriver('2026.10.05.99').technique, '2026.1005.99');
|
||||
assert.equal(redecomposer('2026.1005.99'), '2026.10.05.99');
|
||||
});
|
||||
|
||||
test('redecomposer refuse ce qui ne désigne pas exactement une version', () => {
|
||||
const refus = [
|
||||
['2026.0105.1', /forme/], // zéro de tête
|
||||
['2026.1005.01', /forme/], // zéro de tête
|
||||
['2026.1005.0', /forme/], // rang 0
|
||||
['2026.1005.100', /forme/], // rang au-delà de 99
|
||||
['2026.99.1', /forme/], // mois et jour sur deux chiffres
|
||||
['2026.1005', /forme/], // deux champs
|
||||
['2026.10.05.01', /forme/], // forme affichée
|
||||
['26.1005.1', /forme/], // année sur deux chiffres
|
||||
['2026.1305.1', /mois/], // mois 13
|
||||
['2026.230.1', /jour/], // 30 février
|
||||
['2026.1000.1', /jour/], // jour 0
|
||||
];
|
||||
for (const [technique, motif] of refus) {
|
||||
assert.throws(() => redecomposer(technique), motif, technique);
|
||||
}
|
||||
assert.throws(() => redecomposer(2026.1005), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version : date en lettres (§ 18.4)', () => {
|
||||
test('écrit le jour, le mois en lettres et l’année ; le premier du mois est « 1er »', () => {
|
||||
assert.equal(dateEnLettres({ annee: 2026, mois: 10, jour: 5 }), '5 octobre 2026');
|
||||
assert.equal(dateEnLettres({ annee: 2027, mois: 1, jour: 1 }), '1er janvier 2027');
|
||||
assert.equal(dateEnLettres({ annee: 2028, mois: 2, jour: 29 }), '29 février 2028');
|
||||
});
|
||||
|
||||
test('nomme les douze mois', () => {
|
||||
assert.deepEqual(
|
||||
MOIS.map((_, i) => dateEnLettres({ annee: 2026, mois: i + 1, jour: 2 })),
|
||||
MOIS.map((nom) => `2 ${nom} 2026`),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuse une date hors du calendrier', () => {
|
||||
assert.throws(() => dateEnLettres({ annee: 2026, mois: 2, jour: 29 }), /jour/);
|
||||
assert.throws(() => dateEnLettres({ annee: 2026, mois: 13, jour: 1 }), /mois/);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Arbres d'épreuve -------------------------------------------------------
|
||||
|
||||
const VERSION = '2026.10.05.01';
|
||||
const TECHNIQUE = '2026.1005.1';
|
||||
const DATE = '2026-10-05';
|
||||
|
||||
// Le module engendré pour VERSION, octet pour octet.
|
||||
const MODULE = [
|
||||
'// © 2026 TechnoLibre (http://www.technolibre.ca)',
|
||||
'// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)',
|
||||
'',
|
||||
'// Engendré par scripts/version.js depuis version.json — ne pas modifier.',
|
||||
'export const VERSION = Object.freeze({',
|
||||
" affichee: '2026.10.05.01',",
|
||||
" technique: '2026.1005.1',",
|
||||
'});',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const TITRE_CHANGELOG = '# Changelog — Gestion table tournante Libre';
|
||||
|
||||
// Une section de changelog complète : titre, une rubrique, les deux lignes
|
||||
// obligatoires.
|
||||
const section = (titre) =>
|
||||
[
|
||||
`## ${titre}`,
|
||||
'',
|
||||
'**Ce qui change pour vous**',
|
||||
'- Le bandeau affiche la version.',
|
||||
'',
|
||||
'**Version de format des fichiers** : inchangée.',
|
||||
'',
|
||||
'**En remplaçant** : rien à faire.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const changelog = (...titres) => [TITRE_CHANGELOG, '', ...titres.map(section)].join('\n');
|
||||
|
||||
const enJson = (valeur) => `${JSON.stringify(valeur, null, 2)}\n`;
|
||||
|
||||
function ecrire(racine, chemin, contenu) {
|
||||
const complet = join(racine, chemin);
|
||||
mkdirSync(dirname(complet), { recursive: true });
|
||||
writeFileSync(complet, contenu);
|
||||
}
|
||||
|
||||
const lire = (racine, chemin) => readFileSync(join(racine, chemin), 'utf8');
|
||||
|
||||
function modifierJson(racine, chemin, modification) {
|
||||
const valeur = JSON.parse(lire(racine, chemin));
|
||||
modification(valeur);
|
||||
ecrire(racine, chemin, enJson(valeur));
|
||||
}
|
||||
|
||||
// Arbre conforme à VERSION et à DATE : la source, ses trois recopies, un
|
||||
// changelog, un README qui cite le gabarit du nom, une source et une
|
||||
// configuration à la racine.
|
||||
function arbreConforme() {
|
||||
const racine = mkdtempSync(join(tmpdir(), 'version-'));
|
||||
ecrire(racine, 'version.json', `{ "version": "${VERSION}" }\n`);
|
||||
ecrire(racine, 'package.json', enJson({ name: 'essai', version: TECHNIQUE, type: 'module' }));
|
||||
ecrire(
|
||||
racine,
|
||||
'package-lock.json',
|
||||
enJson({
|
||||
name: 'essai',
|
||||
version: TECHNIQUE,
|
||||
lockfileVersion: 3,
|
||||
requires: true,
|
||||
packages: { '': { name: 'essai', version: TECHNIQUE } },
|
||||
}),
|
||||
);
|
||||
ecrire(racine, 'src/version.genere.js', MODULE);
|
||||
ecrire(
|
||||
racine,
|
||||
'src/main.js',
|
||||
"import { VERSION } from './version.genere.js';\nexport const titre = VERSION.affichee;\n",
|
||||
);
|
||||
ecrire(racine, 'vite.config.js', 'export default {};\n');
|
||||
ecrire(racine, 'CHANGELOG.md', changelog(`${VERSION} — 5 octobre 2026`));
|
||||
ecrire(
|
||||
racine,
|
||||
'README.md',
|
||||
"# Essai\n\nL'exécutable se nomme `gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe`.\n",
|
||||
);
|
||||
return racine;
|
||||
}
|
||||
|
||||
// Exécute l'épreuve sur un arbre conforme neuf, supprimé ensuite.
|
||||
function surArbre(epreuve) {
|
||||
const racine = arbreConforme();
|
||||
try {
|
||||
return epreuve(racine);
|
||||
} finally {
|
||||
rmSync(racine, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Numéros de point des échecs, sans doublon, croissants. Un échec sans
|
||||
// numéro fait échouer l'épreuve.
|
||||
function points(echecs) {
|
||||
const numeros = echecs.map((echec) => {
|
||||
const numero = /^([1-8])\. /.exec(echec);
|
||||
assert.ok(numero, `échec sans numéro de point : ${echec}`);
|
||||
return Number(numero[1]);
|
||||
});
|
||||
return [...new Set(numeros)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// Contrôle l'arbre et exige que les échecs nomment exactement les points
|
||||
// attendus, et que chaque motif se lise dans l'un d'eux.
|
||||
function exigerEchecs(racine, attendus, motifs, dateCivile = DATE) {
|
||||
const echecs = controler(racine, { dateCivile });
|
||||
const liste = echecs.join('\n');
|
||||
assert.deepEqual(points(echecs), attendus, liste);
|
||||
for (const motif of motifs) {
|
||||
assert.ok(
|
||||
echecs.some((echec) => motif.test(echec)),
|
||||
`${motif} ne se lit dans aucun échec :\n${liste}`,
|
||||
);
|
||||
}
|
||||
return echecs;
|
||||
}
|
||||
|
||||
describe('version : contrôle du § 18.5, un défaut à la fois', () => {
|
||||
test("l'arbre de référence est conforme", () =>
|
||||
surArbre((racine) => {
|
||||
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
|
||||
}));
|
||||
|
||||
test('point 1 : package.json porte une autre version technique', () =>
|
||||
surArbre((racine) => {
|
||||
modifierJson(racine, 'package.json', (paquet) => {
|
||||
paquet.version = '2026.1006.1';
|
||||
});
|
||||
exigerEchecs(racine, [1], [/^1\. package\.json .*2026\.1006\.1.*2026\.1005\.1/]);
|
||||
}));
|
||||
|
||||
test('point 1 : le champ version du verrou porte une autre version', () =>
|
||||
surArbre((racine) => {
|
||||
modifierJson(racine, 'package-lock.json', (verrou) => {
|
||||
verrou.version = '2026.1004.1';
|
||||
});
|
||||
exigerEchecs(racine, [1], [/^1\. package-lock\.json, champ « version » .*2026\.1004\.1/]);
|
||||
}));
|
||||
|
||||
test('point 1 : le champ packages[""].version du verrou porte une autre version', () =>
|
||||
surArbre((racine) => {
|
||||
modifierJson(racine, 'package-lock.json', (verrou) => {
|
||||
verrou.packages[''].version = '2026.1004.1';
|
||||
});
|
||||
exigerEchecs(
|
||||
racine,
|
||||
[1],
|
||||
[/^1\. package-lock\.json, champ « packages\[""\]\.version » .*2026\.1004\.1/],
|
||||
);
|
||||
}));
|
||||
|
||||
test('point 1 : le verrou manque', () =>
|
||||
surArbre((racine) => {
|
||||
unlinkSync(join(racine, 'package-lock.json'));
|
||||
exigerEchecs(racine, [1], [/^1\. package-lock\.json absent/]);
|
||||
}));
|
||||
|
||||
test('point 1 : version.json manque', () =>
|
||||
surArbre((racine) => {
|
||||
unlinkSync(join(racine, 'version.json'));
|
||||
exigerEchecs(racine, [1], [/^1\. version\.json absent/]);
|
||||
}));
|
||||
|
||||
test('point 1 : version.json porte une version non conforme', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'version.json', '{ "version": "2026.10.05.100" }\n');
|
||||
exigerEchecs(racine, [1], [/^1\. version\.json : .*rang 100.*99/]);
|
||||
}));
|
||||
|
||||
test('point 2 : le module engendré est modifié à la main', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'src/version.genere.js', MODULE.replace("'2026.1005.1'", "'2026.1005.2'"));
|
||||
exigerEchecs(racine, [2], [/^2\. src\/version\.genere\.js, ligne 7 /]);
|
||||
}));
|
||||
|
||||
test('point 2 : le module engendré manque', () =>
|
||||
surArbre((racine) => {
|
||||
unlinkSync(join(racine, 'src/version.genere.js'));
|
||||
exigerEchecs(racine, [2], [/^2\. src\/version\.genere\.js absent/]);
|
||||
}));
|
||||
|
||||
test('point 3 : le titre de tête ne porte pas la version courante', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'CHANGELOG.md', changelog('2026.10.04.01 — 4 octobre 2026'));
|
||||
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : .*2026\.10\.04\.01.*2026\.10\.05\.01/]);
|
||||
}));
|
||||
|
||||
test('point 3 : la date en lettres du titre de tête est fausse', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'CHANGELOG.md', changelog(`${VERSION} — 6 octobre 2026`));
|
||||
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : .*6 octobre 2026.*5 octobre 2026/]);
|
||||
}));
|
||||
|
||||
test('point 3 : la ligne de la version de format manque', () =>
|
||||
surArbre((racine) => {
|
||||
const texte = lire(racine, 'CHANGELOG.md');
|
||||
ecrire(racine, 'CHANGELOG.md', texte.replace('**Version de format des fichiers** : inchangée.\n', ''));
|
||||
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md.*Version de format des fichiers/]);
|
||||
}));
|
||||
|
||||
test('point 3 : la ligne « En remplaçant » manque', () =>
|
||||
surArbre((racine) => {
|
||||
const texte = lire(racine, 'CHANGELOG.md');
|
||||
ecrire(racine, 'CHANGELOG.md', texte.replace('**En remplaçant** : rien à faire.\n', ''));
|
||||
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md.*En remplaçant/]);
|
||||
}));
|
||||
|
||||
test('point 3 : le changelog manque', () =>
|
||||
surArbre((racine) => {
|
||||
unlinkSync(join(racine, 'CHANGELOG.md'));
|
||||
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md absent/]);
|
||||
}));
|
||||
|
||||
test('point 3 : une ligne obligatoire absente de la section de tête échoue, même portée par une section plus ancienne', () => {
|
||||
const lignes = [
|
||||
['**Version de format des fichiers** : inchangée.\n', /^3\. CHANGELOG\.md, ligne 3 : .*Version de format des fichiers/],
|
||||
['**En remplaçant** : rien à faire.\n', /^3\. CHANGELOG\.md, ligne 3 : .*En remplaçant/],
|
||||
];
|
||||
for (const [ligne, motif] of lignes) {
|
||||
surArbre((racine) => {
|
||||
const texte = changelog(`${VERSION} — 5 octobre 2026`, '2026.10.04.01 — 4 octobre 2026');
|
||||
// replace ne retire que la première occurrence : celle de la section de tête.
|
||||
ecrire(racine, 'CHANGELOG.md', texte.replace(ligne, ''));
|
||||
exigerEchecs(racine, [3], [motif]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('point 3 : une ligne obligatoire citée dans un bloc de code ne compte pas', () =>
|
||||
surArbre((racine) => {
|
||||
const texte = lire(racine, 'CHANGELOG.md');
|
||||
ecrire(
|
||||
racine,
|
||||
'CHANGELOG.md',
|
||||
texte.replace('**En remplaçant** : rien à faire.\n', '```\n**En remplaçant** : rien à faire.\n```\n'),
|
||||
);
|
||||
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : .*En remplaçant/]);
|
||||
}));
|
||||
|
||||
test('points 3 et 4 : une section « À venir » en tête', () =>
|
||||
surArbre((racine) => {
|
||||
// La section « À venir » porte ses deux lignes obligatoires : seul son
|
||||
// titre fait tomber le point 3.
|
||||
ecrire(racine, 'CHANGELOG.md', changelog('À venir', `${VERSION} — 5 octobre 2026`));
|
||||
exigerEchecs(racine, [3, 4], [
|
||||
/^3\. CHANGELOG\.md, ligne 3 : le titre « ## À venir » n'a pas la forme/,
|
||||
/^4\. CHANGELOG\.md, ligne 3 : le titre « ## À venir » ne s'ouvre pas sur une version/,
|
||||
]);
|
||||
}));
|
||||
|
||||
test('point 4 : une section « À venir » entre deux versions', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(
|
||||
racine,
|
||||
'CHANGELOG.md',
|
||||
changelog(`${VERSION} — 5 octobre 2026`, 'À venir', '2026.10.04.01 — 4 octobre 2026'),
|
||||
);
|
||||
exigerEchecs(racine, [4], [
|
||||
/^4\. CHANGELOG\.md, ligne 12 : le titre « ## À venir » ne s'ouvre pas sur une version/,
|
||||
]);
|
||||
}));
|
||||
|
||||
test('points 3 et 4 : un titre « ## » cité dans un bloc de code ne découpe pas la section', () =>
|
||||
surArbre((racine) => {
|
||||
const texte = lire(racine, 'CHANGELOG.md');
|
||||
ecrire(
|
||||
racine,
|
||||
'CHANGELOG.md',
|
||||
texte.replace(
|
||||
'- Le bandeau affiche la version.\n',
|
||||
'- Le bandeau affiche la version.\n\n```markdown\n## Notes de la soirée\n```\n',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
|
||||
}));
|
||||
|
||||
test('points 3 et 4 : un sous-titre « ### » ne découpe pas la section', () =>
|
||||
surArbre((racine) => {
|
||||
const texte = lire(racine, 'CHANGELOG.md');
|
||||
ecrire(
|
||||
racine,
|
||||
'CHANGELOG.md',
|
||||
texte.replace('**Ce qui change pour vous**\n', '**Ce qui change pour vous**\n\n### Le bandeau\n'),
|
||||
);
|
||||
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
|
||||
}));
|
||||
|
||||
test('point 4 : deux sections portent la même version', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(
|
||||
racine,
|
||||
'CHANGELOG.md',
|
||||
changelog(`${VERSION} — 5 octobre 2026`, `${VERSION} — 5 octobre 2026`),
|
||||
);
|
||||
exigerEchecs(racine, [4], [/^4\. CHANGELOG\.md, lignes 3 et 12 : .*2026\.10\.05\.01/]);
|
||||
}));
|
||||
|
||||
test('point 4 : une section plus récente suit une plus ancienne', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(
|
||||
racine,
|
||||
'CHANGELOG.md',
|
||||
changelog(`${VERSION} — 5 octobre 2026`, '2026.10.06.01 — 6 octobre 2026'),
|
||||
);
|
||||
exigerEchecs(racine, [4], [/^4\. CHANGELOG\.md, ligne 12 : .*2026\.10\.06\.01.*2026\.10\.05\.01/]);
|
||||
}));
|
||||
|
||||
test('point 4 : des sections en ordre strictement décroissant sont admises', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(
|
||||
racine,
|
||||
'CHANGELOG.md',
|
||||
changelog(
|
||||
`${VERSION} — 5 octobre 2026`,
|
||||
'2026.10.04.02 — 4 octobre 2026',
|
||||
'2026.10.04.01 — 4 octobre 2026',
|
||||
'2025.12.31.01 — 31 décembre 2025',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
|
||||
}));
|
||||
|
||||
test("point 5 : la version est datée de plus d'un jour après la date civile", () =>
|
||||
surArbre((racine) => {
|
||||
exigerEchecs(racine, [5], [/^5\. version\.json : .*5 octobre 2026.*3 octobre 2026/], '2026-10-03');
|
||||
}));
|
||||
|
||||
test("point 5 : un jour d'avance sur la date civile est toléré", () =>
|
||||
surArbre((racine) => {
|
||||
assert.deepEqual(controler(racine, { dateCivile: '2026-10-04' }), []);
|
||||
assert.deepEqual(controler(racine, { dateCivile: '2027-03-01' }), []);
|
||||
}));
|
||||
|
||||
test('point 6 : une forme affichée dans src/ hors du module engendré', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'src/interface/bandeau.js', "// Bandeau.\n\nexport const v = 'v2026.10.05.01';\n");
|
||||
exigerEchecs(racine, [6], [/^6\. src\/interface\/bandeau\.js, ligne 3 : « 2026\.10\.05\.01 », forme affichée/]);
|
||||
}));
|
||||
|
||||
test('point 6 : une forme technique hors de package.json et du module engendré', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'vite.config.js', "export default { define: { V: '2026.1005.1' } };\n");
|
||||
ecrire(racine, 'scripts/construire.sh', '#!/bin/sh\nWINDOWS=2026.1005.1.0\n');
|
||||
exigerEchecs(racine, [6], [
|
||||
/^6\. vite\.config\.js, ligne 1 : « 2026\.1005\.1 », forme technique/,
|
||||
/^6\. scripts\/construire\.sh, ligne 2 : « 2026\.1005\.1 », forme technique/,
|
||||
]);
|
||||
}));
|
||||
|
||||
test('point 6 : une forme affichée dans package.json', () =>
|
||||
surArbre((racine) => {
|
||||
modifierJson(racine, 'package.json', (paquet) => {
|
||||
paquet.description = 'version 2026.10.05.01';
|
||||
});
|
||||
exigerEchecs(racine, [6], [/^6\. package\.json, ligne \d+ : « 2026\.10\.05\.01 », forme affichée/]);
|
||||
}));
|
||||
|
||||
test('point 6 : les épreuves de test/ sont dans le périmètre', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'test/bandeau.test.js', "export const attendu = '2026.10.05.01';\n");
|
||||
exigerEchecs(racine, [6], [/^6\. test\/bandeau\.test\.js, ligne 1 /]);
|
||||
}));
|
||||
|
||||
test('point 6 : la coquille electron/ est dans le périmètre', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'electron/main.js', "const titre = 'Gestion table tournante Libre — 2026.10.05.01';\n");
|
||||
exigerEchecs(racine, [6], [/^6\. electron\/main\.js, ligne 1 : « 2026\.10\.05\.01 », forme affichée/]);
|
||||
}));
|
||||
|
||||
test('point 6 : une version ne se lit pas dans un nombre plus long qui la contient', () =>
|
||||
surArbre((racine) => {
|
||||
// Un chiffre ou un point devant, un chiffre derrière : chaque ligne
|
||||
// prolonge une version d'un côté, sous l'une des deux formes.
|
||||
ecrire(
|
||||
racine,
|
||||
'src/donnees.js',
|
||||
[
|
||||
"export const a = '12026.10.05.01';",
|
||||
"export const b = '1.2026.10.05.01';",
|
||||
"export const c = '2026.10.05.012';",
|
||||
"export const d = '12026.1005.1';",
|
||||
"export const e = '1.2026.1005.1';",
|
||||
"export const f = '2026.1005.123';",
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
|
||||
}));
|
||||
|
||||
test("point 6 : un fichier binaire, qui porte un octet nul, n'est pas lu comme du texte", () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'src/interface/icone.png', `PNG\u0000${VERSION} ${TECHNIQUE}\n`);
|
||||
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
|
||||
}));
|
||||
|
||||
test('point 6 : données, guides, dépendances et sorties restent hors du périmètre', () =>
|
||||
surArbre((racine) => {
|
||||
const versions = `${VERSION} ${TECHNIQUE} ${TECHNIQUE}.0\n`;
|
||||
for (const chemin of [
|
||||
'scripts/version.test.js',
|
||||
'test/fixtures/demonstration.json',
|
||||
'GUIDE-USAGE.md',
|
||||
'docs/notes.md',
|
||||
'node_modules/paquet/index.js',
|
||||
'src/node_modules/paquet/index.js',
|
||||
'www/assets/index.js',
|
||||
'dist/sortie.js',
|
||||
'dist-electron/main.js',
|
||||
'android/app/build.gradle',
|
||||
]) {
|
||||
ecrire(racine, chemin, versions);
|
||||
}
|
||||
ecrire(racine, 'README.md', `${lire(racine, 'README.md')}\nVersion ${versions}`);
|
||||
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
|
||||
}));
|
||||
|
||||
test('point 7 : un document porte le littéral #_#', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'README.md', `${lire(racine, 'README.md')}\nVersion : #_#\n`);
|
||||
exigerEchecs(racine, [7], [/^7\. README\.md, ligne 5 : .*#_#/]);
|
||||
}));
|
||||
|
||||
test("point 7 : un document cite un nom d'exécutable hors du gabarit", () =>
|
||||
surArbre((racine) => {
|
||||
const fautifs = [
|
||||
'gestion_table_tournante_libre_v2026.10.05.01.exe',
|
||||
'gestion_table_tournante_libre_v2026_10_05_1.exe',
|
||||
'gestion_table_tournante_libre_v2026_13_05_01.exe',
|
||||
'gestion_table_tournante_libre_v<AAAA>.exe',
|
||||
'gestion_table_tournante_libre.exe',
|
||||
'Gestion_Table_Tournante_Libre_v2026_10_05_01.EXE',
|
||||
];
|
||||
ecrire(racine, 'GUIDE-WINDOWS.md', fautifs.map((nom) => `Lancer \`${nom}\`.\n`).join(''));
|
||||
const echecs = exigerEchecs(
|
||||
racine,
|
||||
[7],
|
||||
fautifs.map((nom, i) => new RegExp(`^7\\. GUIDE-WINDOWS\\.md, ligne ${i + 1} : « ${nom.replace(/[.<>]/g, '\\$&')} »`)),
|
||||
);
|
||||
assert.equal(echecs.length, fautifs.length, echecs.join('\n'));
|
||||
}));
|
||||
|
||||
test('point 7 : le gabarit, un nom conforme et le préfixe seul sont admis', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(
|
||||
racine,
|
||||
'GUIDE-WINDOWS.md',
|
||||
[
|
||||
'Le fichier dont le nom commence par `gestion_table_tournante_libre_v`.',
|
||||
'Gabarit : `gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe`.',
|
||||
'Par exemple gestion_table_tournante_libre_v2026_10_05_01.exe.',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
|
||||
}));
|
||||
|
||||
test('point 7 : le changelog est balayé', () =>
|
||||
surArbre((racine) => {
|
||||
const texte = lire(racine, 'CHANGELOG.md');
|
||||
ecrire(
|
||||
racine,
|
||||
'CHANGELOG.md',
|
||||
texte.replace(
|
||||
'- Le bandeau affiche la version.\n',
|
||||
'- Le bandeau affiche la version.\n- Lancer `gestion_table_tournante_libre.exe`.\n- Version #_#.\n',
|
||||
),
|
||||
);
|
||||
const echecs = exigerEchecs(racine, [7], [
|
||||
/^7\. CHANGELOG\.md, ligne 7 : « gestion_table_tournante_libre\.exe »/,
|
||||
/^7\. CHANGELOG\.md, ligne 8 : littéral « #_# »/,
|
||||
]);
|
||||
assert.equal(echecs.length, 2, echecs.join('\n'));
|
||||
}));
|
||||
|
||||
test('point 8 : un balayage qui ne trouve aucune source échoue', () =>
|
||||
surArbre((racine) => {
|
||||
rmSync(join(racine, 'src'), { recursive: true });
|
||||
// Sans src/, le module engendré manque aussi : le point 2 tombe avec.
|
||||
exigerEchecs(racine, [2, 8], [/^8\. .*src\//]);
|
||||
}));
|
||||
|
||||
test('point 8 : un src/ qui ne porte que le module engendré ne compte aucune source', () =>
|
||||
surArbre((racine) => {
|
||||
unlinkSync(join(racine, 'src/main.js'));
|
||||
const echecs = exigerEchecs(racine, [8], [/^8\. le balayage du point 6 n'examine aucun fichier sous src\//]);
|
||||
assert.equal(echecs.length, 1, echecs.join('\n'));
|
||||
}));
|
||||
|
||||
test('point 8 : un balayage qui ne trouve aucun document échoue', () =>
|
||||
surArbre((racine) => {
|
||||
unlinkSync(join(racine, 'README.md'));
|
||||
unlinkSync(join(racine, 'CHANGELOG.md'));
|
||||
// Sans changelog, le point 3 tombe avec.
|
||||
exigerEchecs(racine, [3, 8], [/^8\. le balayage du point 7 n'examine aucun document/]);
|
||||
}));
|
||||
|
||||
test("modifier version.json seul fait tomber les points 1, 2 et 3, chacun nommant l'endroit resté en arrière", () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'version.json', '{ "version": "2026.10.05.02" }\n');
|
||||
const echecs = exigerEchecs(racine, [1, 2, 3], [
|
||||
/^1\. package\.json /,
|
||||
/^1\. package-lock\.json, champ « version » /,
|
||||
/^1\. package-lock\.json, champ « packages\[""\]\.version » /,
|
||||
/^2\. src\/version\.genere\.js, ligne 6 /,
|
||||
/^3\. CHANGELOG\.md, ligne 3 : /,
|
||||
]);
|
||||
assert.equal(echecs.length, 5, echecs.join('\n'));
|
||||
engendrer(racine);
|
||||
exigerEchecs(racine, [3], [/^3\. CHANGELOG\.md, ligne 3 : /]);
|
||||
const texte = lire(racine, 'CHANGELOG.md');
|
||||
ecrire(
|
||||
racine,
|
||||
'CHANGELOG.md',
|
||||
texte.replace(`${TITRE_CHANGELOG}\n\n`, `${TITRE_CHANGELOG}\n\n${section('2026.10.05.02 — 5 octobre 2026')}\n`),
|
||||
);
|
||||
assert.deepEqual(controler(racine, { dateCivile: DATE }), []);
|
||||
}));
|
||||
|
||||
test('refuse une date civile mal formée', () => {
|
||||
assert.throws(() => controler(RACINE, { dateCivile: '2026-10-5' }), TypeError);
|
||||
assert.throws(() => controler(RACINE, { dateCivile: '2026-02-30' }), /jour/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version : engendrement', () => {
|
||||
test("engendrerModule rend le module, l'en-tête de licence en tête", () => {
|
||||
assert.equal(engendrerModule(VERSION), MODULE);
|
||||
});
|
||||
|
||||
test('engendrer réécrit le module, package.json et les deux champs du verrou', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'version.json', '{ "version": "2026.10.06.02" }\n');
|
||||
const paquet = JSON.parse(lire(racine, 'package.json'));
|
||||
const verrou = JSON.parse(lire(racine, 'package-lock.json'));
|
||||
const reecrits = engendrer(racine);
|
||||
assert.deepEqual(reecrits, ['src/version.genere.js', 'package.json', 'package-lock.json']);
|
||||
assert.equal(lire(racine, 'src/version.genere.js'), engendrerModule('2026.10.06.02'));
|
||||
assert.equal(lire(racine, 'package.json'), enJson({ ...paquet, version: '2026.1006.2' }));
|
||||
verrou.version = '2026.1006.2';
|
||||
verrou.packages[''].version = '2026.1006.2';
|
||||
assert.equal(lire(racine, 'package-lock.json'), enJson(verrou));
|
||||
}));
|
||||
|
||||
test('sur un arbre à jour, engendrer ne réécrit rien', () =>
|
||||
surArbre((racine) => {
|
||||
const fichiers = ['src/version.genere.js', 'package.json', 'package-lock.json'];
|
||||
const avant = fichiers.map((chemin) => lire(racine, chemin));
|
||||
assert.deepEqual(engendrer(racine), []);
|
||||
assert.deepEqual(fichiers.map((chemin) => lire(racine, chemin)), avant);
|
||||
}));
|
||||
|
||||
test('engendrer lève quand le verrou manque, sans rien écrire', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'version.json', '{ "version": "2026.10.06.02" }\n');
|
||||
unlinkSync(join(racine, 'package-lock.json'));
|
||||
assert.throws(() => engendrer(racine), /package-lock\.json absent.*npm install/);
|
||||
assert.equal(lire(racine, 'src/version.genere.js'), MODULE);
|
||||
assert.equal(JSON.parse(lire(racine, 'package.json')).version, TECHNIQUE);
|
||||
}));
|
||||
|
||||
test('engendrer refuse le centième rang de la journée, sans rien écrire', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'version.json', '{ "version": "2026.10.05.100" }\n');
|
||||
assert.throws(() => engendrer(racine), /^Error: version\.json : .*99/);
|
||||
assert.equal(lire(racine, 'src/version.genere.js'), MODULE);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('version : date civile de la machine', () => {
|
||||
test('dateCivileLocale écrit la date locale sous la forme AAAA-MM-JJ', () => {
|
||||
assert.equal(dateCivileLocale(new Date(2026, 9, 5, 23, 59)), '2026-10-05');
|
||||
assert.equal(dateCivileLocale(new Date(2027, 0, 1, 0, 0)), '2027-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('version : ligne de commande', () => {
|
||||
// Le script copié dans l'arbre d'épreuve prend pour racine le parent de
|
||||
// son répertoire, comme dans le projet.
|
||||
function lancer(racine, ...arguments_) {
|
||||
const script = join(racine, 'scripts', 'version.js');
|
||||
mkdirSync(dirname(script), { recursive: true });
|
||||
copyFileSync(SCRIPT, script);
|
||||
return spawnSync(process.execPath, [script, ...arguments_], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
test('controler sort en 0 sur un arbre conforme, en 1 en imprimant les échecs sinon', () =>
|
||||
surArbre((racine) => {
|
||||
const conforme = lancer(racine, 'controler');
|
||||
assert.equal(conforme.status, 0, conforme.stderr);
|
||||
modifierJson(racine, 'package.json', (paquet) => {
|
||||
paquet.version = '2026.1006.1';
|
||||
});
|
||||
const fautif = lancer(racine, 'controler');
|
||||
assert.equal(fautif.status, 1, fautif.stdout);
|
||||
assert.match(fautif.stderr, /^1\. package\.json /m);
|
||||
}));
|
||||
|
||||
test('engendrer réécrit les recopies, et sort en 1 quand le verrou manque', () =>
|
||||
surArbre((racine) => {
|
||||
ecrire(racine, 'version.json', '{ "version": "2026.10.05.02" }\n');
|
||||
const reussi = lancer(racine, 'engendrer');
|
||||
assert.equal(reussi.status, 0, reussi.stderr);
|
||||
assert.equal(lire(racine, 'src/version.genere.js'), engendrerModule('2026.10.05.02'));
|
||||
unlinkSync(join(racine, 'package-lock.json'));
|
||||
const echoue = lancer(racine, 'engendrer');
|
||||
assert.equal(echoue.status, 1, echoue.stdout);
|
||||
assert.match(echoue.stderr, /package-lock\.json absent/);
|
||||
}));
|
||||
|
||||
test("une commande inconnue sort en 2 et rappelle l'usage", () =>
|
||||
surArbre((racine) => {
|
||||
const inconnue = lancer(racine, 'publier');
|
||||
assert.equal(inconnue.status, 2);
|
||||
assert.match(inconnue.stderr, /engendrer.*controler/);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('version : arbre réel', () => {
|
||||
test("l'arbre réel du projet est conforme, à la date civile de la machine", () => {
|
||||
assert.deepEqual(controler(RACINE, { dateCivile: dateCivileLocale() }), []);
|
||||
});
|
||||
});
|
||||
144
spec.md
144
spec.md
|
|
@ -157,8 +157,9 @@ quand la configuration en impose un. Sans ce dernier, le troisième chiffre de l
|
|||
page de qualité envoie relancer vers un zéro que la configuration interdit.
|
||||
|
||||
**9. Générer.** *Nécessaire pour obtenir un placement, reprenable sans perte.*
|
||||
La commande prend une **graine** et un **compte d'arrêt**, que chaque
|
||||
proposition conserve (§ 5.7, § 8.9). Le logiciel produit plusieurs propositions
|
||||
La commande prend une **graine** et un **compte d'arrêt** ; chaque proposition
|
||||
conserve sa graine dérivée, ce compte et la longueur d'historique qui l'ont
|
||||
produite (§ 5.7, § 8.9). Le logiciel produit plusieurs propositions
|
||||
comparables ; relancer **ajoute** des candidats, un classement unique les
|
||||
ordonne tous. Les réservations des étapes 6 et 7 sont honorées, le moteur place
|
||||
les autres autour (§ 5.2). Le logiciel nomme automatiquement l'instant qui
|
||||
|
|
@ -611,10 +612,11 @@ réglages ajoute des candidats plutôt que d'effacer les précédents, et un
|
|||
classement unique les ordonne tous. Une commande efface la liste en épargnant
|
||||
celui qui est retenu.
|
||||
|
||||
**La commande prend une graine entière et un compte d'arrêt**, que chaque
|
||||
proposition conserve (§ 8.9). C'est le support de « à graine et entrée égales,
|
||||
placement identique » ; sans lui, une proposition retenue cesse d'être
|
||||
régénérable.
|
||||
**La commande prend une graine entière et un compte d'arrêt**, et une longueur
|
||||
d'historique d'acceptation dont l'algorithme fixe la valeur par défaut. Chaque
|
||||
proposition conserve sa graine dérivée, ce compte et cette longueur (§ 8.9).
|
||||
C'est le support de « à graine et entrée égales, placement identique » ; sans
|
||||
eux, une proposition retenue cesse d'être régénérable.
|
||||
|
||||
**L'identifiant entier d'une proposition vient de sa place dans la suite de
|
||||
graines dérivées, jamais de son ordre d'achèvement.** Quand plusieurs
|
||||
|
|
@ -1264,7 +1266,7 @@ geste, jamais un minuteur » du § 8.2.
|
|||
### 8.6 Les fichiers
|
||||
|
||||
**Emplacement.** Ces règles sont des propriétés de l'implémentation
|
||||
**`electron`**, celle qui est livrée. Sous `browser`, « à côté de l'exécutable »
|
||||
**`electron`**, celle qui est livrée. Sous `web`, « à côté de l'exécutable »
|
||||
ne désigne rien, et la plateforme l'annonce déjà au démarrage (§ 8.8).
|
||||
|
||||
Le logiciel détermine son **dossier de travail une fois par séance**, par une
|
||||
|
|
@ -1408,7 +1410,7 @@ croit avoir supprimé les coordonnées d'une personne les a en réalité déplac
|
|||
### 8.8 Ce qui résiste à une panne
|
||||
|
||||
Ces garanties sont des propriétés de l'implémentation **`electron`**, celle qui
|
||||
est livrée. L'implémentation `browser`, de développement, n'offre ni renommage
|
||||
est livrée. L'implémentation `web`, de développement, n'offre ni renommage
|
||||
atomique ni verrou, et **le logiciel l'annonce au démarrage sur cette
|
||||
plateforme**.
|
||||
|
||||
|
|
@ -1463,7 +1465,7 @@ plateforme**.
|
|||
affaiblie** : le renommage par-dessus n'y a pas la même valeur que sur le
|
||||
volume système, et le support peut disparaître entre l'écriture et le
|
||||
renommage. Le logiciel l'annonce comme il annonce déjà les limites de la
|
||||
plateforme `browser`.
|
||||
plateforme `web`.
|
||||
|
||||
|
||||
### 8.9 La forme des placements dans le fichier
|
||||
|
|
@ -1505,11 +1507,22 @@ Chaque proposition déclare, **une fois** :
|
|||
- la **liste des capacités** correspondantes ;
|
||||
- le **nombre de tours** ;
|
||||
- l'**ensemble des identifiants de participants** qu'elle place ;
|
||||
- sa **graine** entière et le **compte d'arrêt** de la recherche qui l'a produite.
|
||||
- sa **graine dérivée** (§ 5.7), le **compte d'arrêt** et la **longueur de
|
||||
l'historique** d'acceptation de la recherche qui l'a produite : les trois
|
||||
réglages qui la déterminent.
|
||||
|
||||
Les deux derniers champs sont le support de « à graine et entrée égales, placement
|
||||
identique » (§ 5.10) ; sans eux, cette phrase n'existe que dans un scénario de
|
||||
pilotage, et une proposition retenue cesse d'être régénérable.
|
||||
Ces trois champs sont le support de « à graine et entrée égales, placement
|
||||
identique » (§ 5.10) : à configuration égale et pour une même version du moteur
|
||||
(`produit_version`, § 8.8), la régénération d'une proposition à partir d'eux
|
||||
seuls rend son plan, quel que soit son rang dans sa génération. Sans eux, cette
|
||||
phrase n'existe que dans un scénario de pilotage, et une proposition retenue
|
||||
cesse d'être régénérable. La longueur de l'historique a une valeur par défaut,
|
||||
mais la proposition porte celle qui l'a produite : régénérer sous une valeur
|
||||
supposée rendrait une autre proposition, sans le dire. Les trois champs ne
|
||||
déterminent le plan que pour l'algorithme de recherche qui l'a produit, dont
|
||||
l'ordre des tirages et les règles d'acceptation font partie du résultat : une
|
||||
proposition produite par une autre version du moteur n'est pas garantie
|
||||
régénérable.
|
||||
|
||||
Puis, **par tour** : la liste des tables, chacune étant la liste des
|
||||
**identifiants entiers** de ses occupants **dans l'ordre des sièges** ; et la
|
||||
|
|
@ -1525,7 +1538,7 @@ différents, et le § 8.8 est violé par la seule forme du stockage.
|
|||
# illustration, annotée ; le fichier livré est du JSON strict
|
||||
propositions: [
|
||||
{ id: 3,
|
||||
graine: 48271, arret: 20000,
|
||||
graine: 48271, arret: 20000, historique: 1000,
|
||||
tables: [7, 12, 2, ...], identifiants de table (§ 4)
|
||||
capacites: [8, 8, 7, ...],
|
||||
tours: 4,
|
||||
|
|
@ -1850,7 +1863,7 @@ coupés trop court sont refaits à la main pendant que la file s'allonge.
|
|||
> affirmer que « le logiciel ne lit pas les marges » serait faux sur la
|
||||
> plateforme livrée. Ce qui manque n'est pas la maîtrise, c'est **l'épreuve** —
|
||||
> elle passe par un rendu, donc par un moteur de navigateur que le cycle court
|
||||
> n'a pas. La plateforme `browser`, elle, n'offre que la boîte de dialogue du
|
||||
> n'a pas. La plateforme `web`, elle, n'offre que la boîte de dialogue du
|
||||
> navigateur et son « ajuster à la page ».
|
||||
|
||||
**Ce que cette réduction retire du chemin critique.** La bibliothèque n'ingère
|
||||
|
|
@ -2737,14 +2750,33 @@ nommant le critère appliqué à sa place.
|
|||
|
||||
### 13.1 Forme du projet
|
||||
|
||||
Une application **Cordova**, dont le code vit dans `www/` et ne dépend d'aucun
|
||||
Une application **Capacitor**, dont le code web vit dans `www/` — la sortie de
|
||||
Vite, que Capacitor désigne comme son répertoire web — et ne dépend d'aucun
|
||||
service.
|
||||
|
||||
| plateforme | rôle |
|
||||
|---|---|
|
||||
| `electron` | la livraison : un exécutable Windows |
|
||||
| `browser` | le développement et les tests sous Linux |
|
||||
| `android` | ouvert, non requis pour la première livraison |
|
||||
| plateforme | ce qui la porte | rôle |
|
||||
|---|---|---|
|
||||
| `electron` | une **coquille Electron propre au projet**, emballée par `electron-builder` | la livraison : un exécutable Windows |
|
||||
| `web` | la plateforme web de Capacitor, servie en local | le développement et les tests sous Linux |
|
||||
| `android` | la plateforme Android de Capacitor | ouverte, non requise pour la première livraison |
|
||||
|
||||
**Capacitor ne porte pas l'exécutable Windows, et c'est délibéré.** Il ne fournit
|
||||
de plateforme de bureau que par une extension communautaire, et une plateforme
|
||||
dont la mise à jour ne dépend pas du projet fige le moteur d'exécution embarqué :
|
||||
l'exécutable livré porterait un Chromium que plus personne ne corrige. La coquille
|
||||
du projet tient en deux fichiers — le processus principal et le script de
|
||||
préchargement — et fixe elle-même sa version d'Electron, que la construction met
|
||||
à jour comme n'importe quelle dépendance.
|
||||
|
||||
**La coquille est l'endroit où vivent les deux frontières de plateforme du § 13.4
|
||||
sous `electron`** : le système de fichiers — écriture atomique, verrou, sonde
|
||||
d'écriture, chemin publié par le lanceur portable (§ 8.6, § 8.8) — et
|
||||
l'impression (§ 11.7). Le processus principal les exécute ; le script de
|
||||
préchargement les expose à l'application par un **pont étroit et nommé**,
|
||||
l'isolation de contexte restant active et l'intégration de Node coupée dans la
|
||||
page. Une page qui atteindrait le système de fichiers directement ferait d'une
|
||||
faille d'affichage — un nom importé interprété comme du balisage — un accès
|
||||
complet au disque.
|
||||
|
||||
Le code que Vite compile vit sous **`src/`**, en modules nommés d'après les
|
||||
couches du § 13.4 : `src/moteur`, `src/geometrie`, `src/stockage`, `src/csv`,
|
||||
|
|
@ -2824,7 +2856,7 @@ message d'erreur**.
|
|||
stockage lecture et écriture de fichiers
|
||||
```
|
||||
|
||||
**Le moteur ne connaît ni le DOM ni Cordova.** Il reçoit des nombres et des
|
||||
**Le moteur ne connaît ni le DOM, ni Capacitor, ni Electron.** Il reçoit des nombres et des
|
||||
listes, il rend des placements. C'est ce qui le rend testable sous `node`, en
|
||||
quelques secondes — et c'est la condition pour qu'il soit éprouvé sérieusement.
|
||||
|
||||
|
|
@ -3462,6 +3494,17 @@ couverture :
|
|||
| relance sous surveillance, après une modification | **2 secondes** | le développeur commence à grouper ses modifications, et le test cesse de désigner laquelle a cassé |
|
||||
| série `node` surveillée complète, à froid | **10 secondes** | le développeur change de fenêtre ; le retour de contexte coûte plus que l'épreuve, et le niveau glisse vers « avant un commit » |
|
||||
|
||||
**La série surveillée s'adapte au nombre de cœurs, et le budget tient sur toute
|
||||
machine.** À partir de quatre cœurs, des processus complets portent la série
|
||||
entière. En deçà, le lanceur n'en porterait la série que dans un seul
|
||||
processus, et la relance qui suit un module de base — celui que presque toutes
|
||||
les épreuves importent — dépasserait deux secondes. La série s'y joue donc en
|
||||
threads sur tous les cœurs, et ses épreuves lourdes passent dans la série
|
||||
`node:long`. Aucune épreuve ne disparaît : elle change de série, et ce qu'elle
|
||||
éprouve ne dépend pas de la machine. Une garde refuse, dans ces séries, ce
|
||||
qu'un thread refuserait et qu'un processus accepte, et le lanceur annonce à
|
||||
chaque exécution le mode qu'il a retenu.
|
||||
|
||||
**Pourquoi ces deux valeurs.** En deçà de deux secondes, l'attention reste sur le
|
||||
code et le résultat se lit comme la suite du geste. Au-delà de dix, elle part
|
||||
ailleurs ; le niveau `node` cesse d'être lancé à chaque modification, et les
|
||||
|
|
@ -3636,9 +3679,16 @@ certitude fausse.
|
|||
gros groupe tient dans le nombre de tables » est nécessaire mais **ne suffit
|
||||
pas** : trois tables de 10, 1 et 1 ne peuvent séparer quatre groupes de 3, parce
|
||||
que la table de 10 exigerait 10 appartenances distinctes. Le logiciel vérifie
|
||||
**deux** inégalités — plus gros groupe ≤ nombre de tables, plus grande capacité
|
||||
≤ nombre de groupes — puis **construit un tour témoin**. Le générateur **refuse
|
||||
une graine dont aucun tour témoin ne se construit**.
|
||||
**deux** inégalités — plus gros groupe ≤ nombre de tables, plus grande
|
||||
capacité − places vides ≤ nombre de groupes — puis **construit un tour
|
||||
témoin**. Les places vides sont les sièges qui restent une fois chaque membre
|
||||
assis, zéro quand la salle n'en laisse pas : une table reçoit au moins sa
|
||||
capacité moins ces places, chacun de ses occupants d'une appartenance
|
||||
différente. Dans une salle exactement pleine, la seconde inégalité se lit
|
||||
« plus grande capacité ≤ nombre de groupes » ; ailleurs, cette lecture
|
||||
refuserait à tort deux tables de 3 pour deux membres d'appartenances
|
||||
différentes, qu'un tour sépare. Le générateur **refuse une graine dont aucun
|
||||
tour témoin ne se construit**.
|
||||
|
||||
### 15.3 La petite démonstration — 12 membres
|
||||
|
||||
|
|
@ -3766,8 +3816,16 @@ ne désigne nulle part une organisation existante.
|
|||
|
||||
## 16. Livraison
|
||||
|
||||
L'exécutable Windows se construit depuis Linux par la plateforme `electron` de
|
||||
Cordova, qui s'appuie sur `electron-builder`.
|
||||
L'exécutable Windows se construit depuis Linux par `electron-builder`, qui
|
||||
emballe la coquille Electron du projet et le même `www/` que servent les
|
||||
plateformes de Capacitor (§ 13.1). La construction s'exécute dans un conteneur,
|
||||
sous une image épinglée par empreinte, de sorte que la machine de développement
|
||||
n'a rien à installer en dehors du projet. La cible portable n'exige pas Wine :
|
||||
`electron-builder` édite lui-même les ressources de l'exécutable. L'image n'en
|
||||
porte donc pas, et une cible qui l'exigerait — un installateur — y échoue au lieu
|
||||
de produire le livrable que ce paragraphe exclut. Le conteneur ne reçoit aucune
|
||||
variable de l'hôte : un numéro de construction d'intégration continue y
|
||||
remplacerait le quatrième champ de la version Windows (§ 18.1).
|
||||
|
||||
**Cette capacité est à vérifier dès la première semaine**, sur un squelette vide,
|
||||
au même titre que la publication du dossier d'origine par le lanceur portable
|
||||
|
|
@ -3804,7 +3862,7 @@ qu'il se **garde** au lieu de se surveiller. La liste des écrans est celle du
|
|||
§ 19.3.
|
||||
|
||||
**`GUIDE-WINDOWS.md` sort de la boucle d'engendrement** (§ 19.8) : le pilotage
|
||||
s'exécute sur `browser` sous Linux, et les écrans que ce guide décrit appartiennent
|
||||
s'exécute sur `web` sous Linux, et les écrans que ce guide décrit appartiennent
|
||||
à `electron` sous Windows. Il **ne cite jamais un nom d'exécutable complet** : il
|
||||
cite le **gabarit** et désigne « le fichier dont le nom commence par
|
||||
`gestion_table_tournante_libre_v` » — un nom daté change à chaque livraison, et
|
||||
|
|
@ -3916,9 +3974,10 @@ construit puis jeté sans être envoyé n'a consommé aucun numéro.
|
|||
le champ détruirait la propriété de tri que toute la section achète, et cent
|
||||
livraisons dans une journée désignent un autre problème que le format du numéro.
|
||||
|
||||
**La forme technique.** Les fichiers de description du projet et de la plateforme
|
||||
exigent trois champs entiers **sans zéro de tête** : le format qu'ils valident
|
||||
refuse `0105`. Le logiciel dérive donc le mois et le jour en **un entier**,
|
||||
**La forme technique.** Le fichier de description du projet, `package.json`,
|
||||
exige trois champs entiers **sans zéro de tête** — le format de version qu'il
|
||||
valide refuse `0105` — et `electron-builder` en tire la ressource de version de
|
||||
l'exécutable Windows. Le logiciel dérive donc le mois et le jour en **un entier**,
|
||||
`MM × 100 + JJ`, écrit sans remplissage, et le rang en entier :
|
||||
|
||||
```
|
||||
|
|
@ -3935,6 +3994,11 @@ nombres tiennent dans les **16 bits** que le format de ressources Windows impose
|
|||
chacun de ses quatre champs : l'année, `MM × 100 + JJ` qui vaut au plus 1231, et
|
||||
le rang.
|
||||
|
||||
**Quand la plateforme `android` sera ajoutée**, son `versionCode`, un entier
|
||||
unique, dérivera de la même source sous la forme `AAMMJJNN` sur huit chiffres —
|
||||
`26100501` —, monotone et sous la borne de 2 100 000 000 qu'impose la
|
||||
plateforme, et rejoindra le contrôle du § 18.5.
|
||||
|
||||
**La longueur fixe ne vaut que pour la forme affichée et pour le nom de fichier**,
|
||||
qui sont les seules qu'un humain trie. La forme technique n'est jamais lue par
|
||||
l'opérateur et n'est jamais triée.
|
||||
|
|
@ -3977,8 +4041,8 @@ Tout le reste en dérive, par un script de construction :
|
|||
|
||||
| destination | forme | usage |
|
||||
|---|---|---|
|
||||
| les fichiers de description du projet et de la plateforme | technique | ce qu'exigent les outils de construction |
|
||||
| nom de l'exécutable | soulignés | ce que l'opérateur voit dans son dossier |
|
||||
| `package.json` | technique | ce qu'exigent npm et `electron-builder`, qui en tire la ressource de version Windows |
|
||||
| nom de l'exécutable, posé dans la configuration d'`electron-builder` | soulignés | ce que l'opérateur voit dans son dossier |
|
||||
| `src/version.genere.js` | affichée **et** technique | ce que l'application affiche |
|
||||
| titre de la section de tête du `CHANGELOG.md` | affichée | ce que l'opérateur lit avant de remplacer |
|
||||
|
||||
|
|
@ -4058,7 +4122,7 @@ est une livraison dont l'opérateur ne peut pas décider.
|
|||
Chaque endroit où un chiffre se recopie est une occasion de diverger. Un contrôle
|
||||
de la série `node` surveillée (§ 14.4) **échoue** quand :
|
||||
|
||||
1. la version technique des fichiers de description n'est pas celle que la
|
||||
1. la version technique de `package.json` n'est pas celle que la
|
||||
dérivation du § 18.1 produit depuis `version.json`, ou ne se redécompose pas en
|
||||
la version affichée ;
|
||||
2. le module engendré ne reproduit pas, caractère pour caractère, ce que le script
|
||||
|
|
@ -4073,7 +4137,7 @@ de la série `node` surveillée (§ 14.4) **échoue** quand :
|
|||
contrôle attrape est une année ou un mois tapé de travers, pas une minute ;
|
||||
6. une chaîne **de la forme affichée** apparaît ailleurs que dans `version.json`,
|
||||
le module engendré et le changelog, ou une chaîne **de la forme technique**
|
||||
ailleurs que dans les fichiers de description et le module engendré ;
|
||||
ailleurs que dans `package.json` et le module engendré ;
|
||||
7. un document construit porte le littéral `#_#`, ou cite un nom d'exécutable qui
|
||||
**ne se conforme pas au gabarit** du § 18.2. Un gabarit non substitué se lit
|
||||
comme un nom de fichier et envoie l'opérateur chercher un fichier qui n'existe
|
||||
|
|
@ -4130,7 +4194,7 @@ question. Le logiciel fait donc en sorte qu'on n'ait pas à la chercher.
|
|||
complément.** Un signalement arrive le plus souvent sous forme d'image, et une
|
||||
capture faite à la touche d'impression d'écran, ou cadrée sur le défaut, ne
|
||||
contient pas toujours la barre de titre du système — elle ne la contient jamais sur
|
||||
la plateforme `browser`, où le titre est celui d'un onglet. Un bandeau dessiné par
|
||||
la plateforme `web`, où le titre est celui d'un onglet. Un bandeau dessiné par
|
||||
l'application est dans l'image quel que soit le cadrage. Le § 8.4 impose en plus un
|
||||
cadre permanent **autour du plan** pour le mode : les deux coexistent, le cadre
|
||||
signalant le mode là où la main agit, le bandeau portant la version jusque sur la
|
||||
|
|
@ -4194,7 +4258,7 @@ documentation s'arrête en nommant l'étape, et **elle ne republie pas les image
|
|||
passage précédent** : une documentation partielle qui se complète avec d'anciennes
|
||||
images est exactement le défaut que la boucle existe pour supprimer.
|
||||
|
||||
Le pilotage s'exécute sur la plateforme `browser` (§ 13.1), servie en local. Le
|
||||
Le pilotage s'exécute sur la plateforme `web` (§ 13.1), servie en local. Le
|
||||
pilote, le navigateur et son pilote de protocole sont des **dépendances de
|
||||
développement** : l'exigence « aucune connexion requise » du § 2 porte sur le
|
||||
logiciel livré, jamais sur l'atelier qui le construit.
|
||||
|
|
@ -4216,7 +4280,7 @@ se juge sur elles, pas sur le nom :
|
|||
atteignable.
|
||||
2. **La capture d'écran d'un élément**, et non de la fenêtre. Elle **recadre sur la
|
||||
surface de l'application** : le cadre de fenêtre et la barre système — ce qui
|
||||
diffère entre `browser` sous Linux et `electron` sous Windows — sortent de
|
||||
diffère entre `web` sous Linux et `electron` sous Windows — sortent de
|
||||
l'image, et le guide d'usage reste vrai sur la plateforme livrée.
|
||||
3. **La maîtrise de la taille du cadre d'affichage.** Voir § 19.4 : la commande du
|
||||
standard dimensionne la **fenêtre**, pas le cadre d'affichage, et le logiciel ne
|
||||
|
|
@ -4491,7 +4555,7 @@ région engendrée fait échouer la construction au lieu d'être perdue au passa
|
|||
suivant (§ 14.3).
|
||||
|
||||
**`GUIDE-WINDOWS.md` sort de la boucle** (§ 16.1). Le pilotage s'exécute sur
|
||||
`browser` sous Linux ; les écrans que ce guide décrit — obtenir l'exécutable, le
|
||||
`web` sous Linux ; les écrans que ce guide décrit — obtenir l'exécutable, le
|
||||
premier lancement, le blocage par un antivirus, l'emplacement des fichiers dans
|
||||
l'explorateur — appartiennent à `electron` sous Windows et **aucun scénario ne les
|
||||
produit**. Le guide les décrit en toutes lettres, ou les illustre par des images
|
||||
|
|
@ -4648,7 +4712,7 @@ avec elle donne une couverture imaginaire.
|
|||
`touch-action`, qui ne se juge que sous un vrai doigt. Ce sont les pannes que le
|
||||
§ 7.3 nomme, et elles restent au niveau manuel.
|
||||
- **les garanties de panne du § 8.8.** Elles sont des propriétés de
|
||||
l'implémentation `electron` ; le pilotage s'exécute sur `browser`. **Aucune
|
||||
l'implémentation `electron` ; le pilotage s'exécute sur `web`. **Aucune
|
||||
capture d'écran n'établit quoi que ce soit sur la résistance à une panne.**
|
||||
- **la lisibilité.** Une image nette d'un plan illisible est une image nette.
|
||||
- **la justesse du français**, et le fait qu'une phrase du guide dise bien ce que
|
||||
|
|
|
|||
124
src/demo/appartenances.js
Normal file
124
src/demo/appartenances.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Appartenances d'une démonstration tirée (§ 15.2) : la loi des tailles de
|
||||
// groupe, et la faisabilité d'un tour qui sépare toutes les appartenances.
|
||||
// Les fonctions reçoivent des tailles de groupe et des capacités de table,
|
||||
// jamais des personnes : un membre se désigne par son rang dans la suite des
|
||||
// groupes, les membres du premier groupe d'abord, puis ceux du deuxième, et
|
||||
// ainsi de suite. Une table sépare les appartenances quand elle ne reçoit
|
||||
// jamais deux membres d'un même groupe. Aucune fonction ne modifie les listes
|
||||
// qu'elle reçoit ; tirerTailles fait avancer le générateur qu'on lui passe.
|
||||
|
||||
/** Poids des tailles de groupe 1 à 12 (§ 15.2) : POIDS_TAILLES[s − 1] pèse la taille s. */
|
||||
export const POIDS_TAILLES = Object.freeze([24, 20, 16, 8, 6, 5, 4, 3, 2, 2, 1, 1]);
|
||||
|
||||
/**
|
||||
* Somme des nombres d'une liste ; 0 pour une liste vide.
|
||||
* @param {number[]} liste
|
||||
* @returns {number}
|
||||
*/
|
||||
export const somme = (liste) => liste.reduce((a, b) => a + b, 0);
|
||||
const maximum = (liste) => liste.reduce((a, b) => Math.max(a, b), 0);
|
||||
|
||||
const SOMME_POIDS = somme(POIDS_TAILLES);
|
||||
|
||||
// Une taille tirée selon POIDS_TAILLES, par un seul borne(92) : le tirage u
|
||||
// donne la taille s quand la somme des s − 1 premiers poids est ≤ u et que
|
||||
// celle des s premiers le dépasse.
|
||||
function tirerTaille(rng) {
|
||||
let reste = rng.borne(SOMME_POIDS);
|
||||
let rang = 0;
|
||||
while (reste >= POIDS_TAILLES[rang]) {
|
||||
reste -= POIDS_TAILLES[rang];
|
||||
rang += 1;
|
||||
}
|
||||
return rang + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailles de groupe tirées pour total membres (§ 15.2) : une taille tirée par
|
||||
* groupe, tant que la somme des tailles reste sous total, puis le dernier
|
||||
* groupe tronqué au reste. La somme rendue vaut total ; total = 0 ne tire rien
|
||||
* et rend [].
|
||||
*
|
||||
* @param {{borne: function(number): number}} rng générateur de prng.js
|
||||
* @param {number} total membres à répartir, entier ≥ 0
|
||||
* @returns {number[]} tailles, de 1 à 12, dans l'ordre des tirages
|
||||
* @throws {RangeError} total n'est pas un entier ≥ 0
|
||||
*/
|
||||
export function tirerTailles(rng, total) {
|
||||
if (!Number.isInteger(total) || total < 0) {
|
||||
throw new RangeError(`total : entier ≥ 0 attendu, reçu ${String(total)}`);
|
||||
}
|
||||
const tailles = [];
|
||||
let couverts = 0;
|
||||
while (couverts < total) {
|
||||
const taille = Math.min(tirerTaille(rng), total - couverts);
|
||||
tailles.push(taille);
|
||||
couverts += taille;
|
||||
}
|
||||
return tailles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les deux inégalités qu'un tour séparant toutes les appartenances exige
|
||||
* (§ 15.2), contrôlées dans cet ordre ; la première rompue est nommée.
|
||||
* - PLUS_GROS_GROUPE : le plus gros groupe dépasse le nombre de tables, alors
|
||||
* que chacun de ses membres occupe une table différente.
|
||||
* - PLUS_GRANDE_CAPACITE : la plus grande capacité, diminuée des places
|
||||
* vides, dépasse le nombre de groupes. Les places vides sont les sièges qui
|
||||
* restent une fois chaque membre assis, zéro quand la salle n'en laisse
|
||||
* pas. Une table reçoit au moins sa capacité moins ces places, chacun de
|
||||
* ses membres d'un groupe différent. Dans une salle exactement pleine,
|
||||
* c'est « plus grande capacité ≤ nombre de groupes ».
|
||||
* Nécessaires, elles ne suffisent pas : seul tourTemoin conclut.
|
||||
*
|
||||
* @param {number[]} tailles taille de chaque groupe
|
||||
* @param {number[]} capacites sièges de chaque table
|
||||
* @returns {{ok: boolean, raison?: 'PLUS_GROS_GROUPE'|'PLUS_GRANDE_CAPACITE'}}
|
||||
*/
|
||||
export function faisabilite(tailles, capacites) {
|
||||
if (maximum(tailles) > capacites.length) return { ok: false, raison: 'PLUS_GROS_GROUPE' };
|
||||
const placesVides = Math.max(0, somme(capacites) - somme(tailles));
|
||||
if (maximum(capacites) - placesVides > tailles.length) {
|
||||
return { ok: false, raison: 'PLUS_GRANDE_CAPACITE' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Un tour témoin (§ 15.2) : chaque membre assis, aucune table au-delà de sa
|
||||
* capacité, aucun groupe deux fois à une même table.
|
||||
*
|
||||
* Les groupes se placent dans l'ordre reçu, chacun aux tables qui gardent le
|
||||
* plus de sièges libres, à égalité la table de plus petit index, ses membres
|
||||
* dans l'ordre de ces tables. Ce choix glouton ne manque aucun tour qui
|
||||
* existe. Soit un tour qui place le groupe à une table b plutôt qu'à une
|
||||
* table a du choix, laquelle gardait au moins autant de sièges libres : ou a
|
||||
* garde un siège dans ce tour, et le membre passe de b à a ; ou a est pleine,
|
||||
* elle reçoit alors plus de membres des groupes suivants que b, et l'un
|
||||
* d'eux, assis en a et non en b, échange sa place avec lui. Répété, l'échange
|
||||
* donne un tour qui place le groupe exactement comme le choix glouton : les
|
||||
* groupes suivants ont un tour sur les sièges que ce choix leur laisse.
|
||||
*
|
||||
* @param {number[]} tailles taille de chaque groupe
|
||||
* @param {number[]} capacites sièges de chaque table
|
||||
* @returns {number[]|null} index de table de chaque membre, dans l'ordre des
|
||||
* membres ; null quand aucun tour ne sépare tous les groupes
|
||||
*/
|
||||
export function tourTemoin(tailles, capacites) {
|
||||
const libres = [...capacites];
|
||||
const ordre = capacites.map((_, t) => t);
|
||||
const tour = [];
|
||||
for (const taille of tailles) {
|
||||
ordre.sort((a, b) => libres[b] - libres[a] || a - b);
|
||||
if (taille > ordre.length || libres[ordre[taille - 1]] === 0) return null;
|
||||
for (let m = 0; m < taille; m += 1) {
|
||||
const t = ordre[m];
|
||||
tour.push(t);
|
||||
libres[t] -= 1;
|
||||
}
|
||||
}
|
||||
return tour;
|
||||
}
|
||||
269
src/demo/appartenances.test.js
Normal file
269
src/demo/appartenances.test.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des appartenances tirées (§ 15.2) : la loi des tailles, la
|
||||
// troncature du dernier groupe, les deux inégalités et le tour témoin. Les
|
||||
// bornes de la loi sont calculées à la main depuis le tableau des poids du
|
||||
// § 15.2 ; le tour témoin se compare à une recherche exhaustive sur toutes les
|
||||
// petites instances d'un domaine fixé.
|
||||
import assert from 'node:assert/strict';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { POIDS_TAILLES, faisabilite, tirerTailles, tourTemoin } from './appartenances.js';
|
||||
import { FLUX, creerPcg32 } from './prng.js';
|
||||
|
||||
// Générateur scripté : borne(n) rend les valeurs données, dans l'ordre, et
|
||||
// note chaque n reçu. Un tirage de plus que prévu lève.
|
||||
function scripte(valeurs) {
|
||||
const recus = [];
|
||||
return {
|
||||
recus,
|
||||
borne(n) {
|
||||
recus.push(n);
|
||||
if (recus.length > valeurs.length) throw new Error(`tirage ${recus.length} non prévu`);
|
||||
return valeurs[recus.length - 1];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const somme = (liste) => liste.reduce((a, b) => a + b, 0);
|
||||
|
||||
// Écarts d'un tour à la définition du tour témoin, une ligne chacun : un index
|
||||
// de table par membre, les membres numérotés groupe après groupe ; aucune
|
||||
// table au-delà de sa capacité ; aucun groupe deux fois à une même table.
|
||||
function ecartsDuTour(tailles, capacites, tour) {
|
||||
if (!Array.isArray(tour) || tour.length !== somme(tailles)) {
|
||||
return [`${tour?.length} index de table pour ${somme(tailles)} membres`];
|
||||
}
|
||||
const ecarts = [];
|
||||
const occupation = capacites.map(() => 0);
|
||||
let membre = 0;
|
||||
for (let g = 0; g < tailles.length; g += 1) {
|
||||
const tablesDuGroupe = new Set();
|
||||
for (let m = 0; m < tailles[g]; m += 1, membre += 1) {
|
||||
const t = tour[membre];
|
||||
if (!Number.isInteger(t) || t < 0 || t >= capacites.length) {
|
||||
ecarts.push(`membre ${membre} : table ${t}`);
|
||||
continue;
|
||||
}
|
||||
if (tablesDuGroupe.has(t)) ecarts.push(`groupe ${g} : deux membres à la table ${t}`);
|
||||
tablesDuGroupe.add(t);
|
||||
occupation[t] += 1;
|
||||
}
|
||||
}
|
||||
for (let t = 0; t < capacites.length; t += 1) {
|
||||
if (occupation[t] > capacites[t]) {
|
||||
ecarts.push(`table ${t} : ${occupation[t]} membres pour ${capacites[t]} sièges`);
|
||||
}
|
||||
}
|
||||
return ecarts;
|
||||
}
|
||||
|
||||
// Vrai quand un tour sépare tous les groupes : chaque groupe essaie toutes les
|
||||
// combinaisons de tables distinctes qui ont encore un siège.
|
||||
function existeTour(tailles, capacites) {
|
||||
const libres = [...capacites];
|
||||
const placer = (g) => {
|
||||
if (g === tailles.length) return true;
|
||||
const choisir = (depuis, restants) => {
|
||||
if (restants === 0) return placer(g + 1);
|
||||
for (let t = depuis; t < libres.length; t += 1) {
|
||||
if (libres[t] === 0) continue;
|
||||
libres[t] -= 1;
|
||||
const trouve = choisir(t + 1, restants - 1);
|
||||
libres[t] += 1;
|
||||
if (trouve) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return choisir(0, tailles[g]);
|
||||
};
|
||||
return placer(0);
|
||||
}
|
||||
|
||||
// Suites de longueur 1 à longueurMax, de valeurs 1 à valeurMax.
|
||||
function suites(longueurMax, valeurMax) {
|
||||
const toutes = [];
|
||||
const etendre = (prefixe) => {
|
||||
if (prefixe.length > 0) toutes.push(prefixe);
|
||||
if (prefixe.length === longueurMax) return;
|
||||
for (let v = 1; v <= valeurMax; v += 1) etendre([...prefixe, v]);
|
||||
};
|
||||
etendre([]);
|
||||
return toutes;
|
||||
}
|
||||
|
||||
describe('appartenances : la loi des tailles', () => {
|
||||
test('les poids du § 15.2, tailles 1 à 12, somme 92', () => {
|
||||
assert.deepEqual(POIDS_TAILLES, [24, 20, 16, 8, 6, 5, 4, 3, 2, 2, 1, 1]);
|
||||
assert.ok(Object.isFrozen(POIDS_TAILLES));
|
||||
});
|
||||
|
||||
test('un borne(92) par groupe, lu sur les poids cumulés, à chaque frontière', () => {
|
||||
// Poids cumulés 24, 44, 60, 68, 74, 79, 83, 86, 88, 90, 91, 92 : la
|
||||
// taille s couvre les tirages de la somme des s − 1 premiers poids à celle
|
||||
// des s premiers, moins un. Chaque taille est tirée à ses deux bords.
|
||||
const valeurs = [0, 23, 24, 43, 44, 59, 60, 67, 68, 73, 74, 78, 79, 82, 83, 85, 86, 87, 88, 89, 90, 91];
|
||||
const tailles = [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 12];
|
||||
const rng = scripte(valeurs);
|
||||
assert.deepEqual(tirerTailles(rng, somme(tailles)), tailles);
|
||||
assert.deepEqual(rng.recus, valeurs.map(() => 92));
|
||||
});
|
||||
|
||||
test('le tirage s’arrête quand la somme atteint le total, le dernier groupe tronqué au reste', () => {
|
||||
// [valeurs scriptées, total, tailles attendues]
|
||||
const cas = [
|
||||
[[91, 91, 91], 30, [12, 12, 6]],
|
||||
[[91], 5, [5]],
|
||||
[[0], 1, [1]],
|
||||
[[24, 0], 3, [2, 1]],
|
||||
[[], 0, []],
|
||||
];
|
||||
const ecarts = cas
|
||||
.map(([valeurs, total, attendu]) => {
|
||||
const rng = scripte(valeurs);
|
||||
const obtenu = tirerTailles(rng, total);
|
||||
return { total, attendu, obtenu, tirages: rng.recus.length, prevus: valeurs.length };
|
||||
})
|
||||
.filter(({ attendu, obtenu, tirages, prevus }) => !isDeepStrictEqual(obtenu, attendu) || tirages !== prevus)
|
||||
.map(({ total, attendu, obtenu, tirages }) =>
|
||||
`total ${total} : ${JSON.stringify(obtenu)} en ${tirages} tirages, ${JSON.stringify(attendu)} attendu`);
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('sous PCG32, les tailles couvrent 260 membres, chacune de 1 à 12', () => {
|
||||
const ecarts = [];
|
||||
for (let graine = 0; graine < 100; graine += 1) {
|
||||
const tailles = tirerTailles(creerPcg32(graine, FLUX.DEMO), 260);
|
||||
if (somme(tailles) !== 260) ecarts.push(`graine ${graine} : somme ${somme(tailles)}`);
|
||||
if (tailles.some((s) => !Number.isInteger(s) || s < 1 || s > 12)) {
|
||||
ecarts.push(`graine ${graine} : ${JSON.stringify(tailles)}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('un total qui n’est pas un entier ≥ 0 lève RangeError', () => {
|
||||
for (const total of [-1, 2.5, Number.NaN, '260', undefined]) {
|
||||
assert.throws(() => tirerTailles(scripte([]), total), RangeError, String(total));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('appartenances : les deux inégalités', () => {
|
||||
// [tailles, capacités, verdict attendu, ce que le cas exerce]
|
||||
const CAS = [
|
||||
[[3, 3, 3, 3], [10, 1, 1], { ok: false, raison: 'PLUS_GRANDE_CAPACITE' },
|
||||
'§ 15.2 : la table de 10 exigerait 10 appartenances distinctes'],
|
||||
[[2, 2], [3, 1], { ok: false, raison: 'PLUS_GRANDE_CAPACITE' },
|
||||
'salle pleine : une table de 3 pour 2 groupes'],
|
||||
[[2, 2, 2], [5, 2], { ok: false, raison: 'PLUS_GRANDE_CAPACITE' },
|
||||
'une place vide : la table de 5 reçoit au moins 4 membres, pour 3 groupes'],
|
||||
[[4, 2], [3, 3, 3], { ok: false, raison: 'PLUS_GROS_GROUPE' }, 'un groupe de 4 pour 3 tables'],
|
||||
[[3, 2, 1], [3, 3], { ok: false, raison: 'PLUS_GROS_GROUPE' }, 'un groupe de 3 pour 2 tables'],
|
||||
[[3, 3, 3, 3], [10, 1], { ok: false, raison: 'PLUS_GROS_GROUPE' },
|
||||
'les deux rompues : la première est nommée'],
|
||||
[[2, 2, 1], [3, 2], { ok: true }, 'les deux inégalités à égalité'],
|
||||
[[2, 2, 2], [4, 3], { ok: true },
|
||||
'une place vide : la table de 4 reçoit au moins 3 membres, pour 3 groupes'],
|
||||
[[1, 1, 1], [8, 2], { ok: true }, 'sept places vides'],
|
||||
[[2, 2], [2, 1], { ok: true },
|
||||
'salle trop petite : aucune place vide, la table de 2 tient dans 2 groupes'],
|
||||
[[2, 2, 2], [4, 1], { ok: false, raison: 'PLUS_GRANDE_CAPACITE' },
|
||||
'salle trop petite : aucune place vide, la table de 4 dépasse 3 groupes'],
|
||||
[[3, 3, 3, 1, 1, 1], [6, 4, 2], { ok: true }, 'inégalités tenues, sans tour pourtant'],
|
||||
[[], [8], { ok: true }, 'aucun membre'],
|
||||
];
|
||||
|
||||
test('chaque cas reçoit son verdict', () => {
|
||||
const ecarts = CAS
|
||||
.map(([tailles, capacites, attendu, quoi]) => ({ quoi, attendu, obtenu: faisabilite(tailles, capacites) }))
|
||||
.filter(({ attendu, obtenu }) => !isDeepStrictEqual(obtenu, attendu))
|
||||
.map(({ quoi, attendu, obtenu }) =>
|
||||
`${quoi} : ${JSON.stringify(obtenu)}, ${JSON.stringify(attendu)} attendu`);
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appartenances : le tour témoin', () => {
|
||||
test('un tour qui sépare chaque groupe quand il en existe un', () => {
|
||||
// [tailles, capacités], chacun admettant un tour
|
||||
const cas = [
|
||||
[[2, 2, 1], [3, 2]],
|
||||
[[2, 2, 2], [4, 3]],
|
||||
[[1, 1, 1], [8, 2]],
|
||||
[[4, 4, 4], [3, 3, 3, 3]],
|
||||
[[5, 3, 1, 1], [3, 3, 2, 2, 2]],
|
||||
];
|
||||
const ecarts = cas.flatMap(([tailles, capacites]) => {
|
||||
const tour = tourTemoin(Object.freeze([...tailles]), Object.freeze([...capacites]));
|
||||
const quoi = `${JSON.stringify(tailles)} / ${JSON.stringify(capacites)}`;
|
||||
if (tour === null) return [`${quoi} : null`];
|
||||
return ecartsDuTour(tailles, capacites, tour).map((e) => `${quoi} : ${e}`);
|
||||
});
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('null quand aucun tour ne sépare tous les groupes', () => {
|
||||
const cas = [
|
||||
[[3, 3, 3, 3], [10, 1, 1]],
|
||||
[[3, 3, 3, 1, 1, 1], [6, 4, 2]],
|
||||
[[3, 3, 1, 1], [4, 3, 1]],
|
||||
[[1, 1], [1]],
|
||||
[[2], []],
|
||||
];
|
||||
const rendus = cas.filter(([tailles, capacites]) => tourTemoin(tailles, capacites) !== null);
|
||||
assert.deepEqual(rendus, []);
|
||||
});
|
||||
|
||||
test('aucun membre : un tour vide', () => {
|
||||
assert.deepEqual(tourTemoin([], [8]), []);
|
||||
});
|
||||
|
||||
test('chaque groupe prend les tables aux sièges libres les plus nombreux, à égalité la plus petite', () => {
|
||||
// Deux tables de 2 : le premier membre prend la table 0 ; le second, la
|
||||
// table 1, qui garde alors plus de sièges libres.
|
||||
assert.deepEqual(tourTemoin([1, 1], [2, 2]), [0, 1]);
|
||||
// Les tables 1 et 2 gardent 2 sièges, la table 0 un seul : les membres
|
||||
// du groupe suivent l'ordre des tables choisies.
|
||||
assert.deepEqual(tourTemoin([2], [1, 2, 2]), [1, 2]);
|
||||
});
|
||||
|
||||
// Sur chaque instance du domaine : tourTemoin rend un tour valide quand la
|
||||
// recherche exhaustive en trouve un, null sinon ; faisabilite n'en refuse
|
||||
// aucune qui a un tour.
|
||||
test('petites instances : un tour quand, et seulement quand, la recherche exhaustive en trouve un', () => {
|
||||
// Domaine : 1 à 4 groupes de 1 à 4 membres, 1 à 4 tables de 1 à 4 sièges,
|
||||
// soit 340 × 340 instances. Il contient des instances sans tour où les
|
||||
// deux inégalités tiennent, et des instances à places vides dont la
|
||||
// plus grande table dépasse le nombre de groupes et qui ont un tour.
|
||||
const ecarts = [];
|
||||
let avecTour = 0;
|
||||
let sansTourInegalitesTenues = 0;
|
||||
let avecTourTableAuDelaDesGroupes = 0;
|
||||
for (const tailles of suites(4, 4)) {
|
||||
for (const capacites of suites(4, 4)) {
|
||||
const quoi = () => `${JSON.stringify(tailles)} / ${JSON.stringify(capacites)}`;
|
||||
const existe = existeTour(tailles, capacites);
|
||||
const tour = tourTemoin(tailles, capacites);
|
||||
const verdict = faisabilite(tailles, capacites);
|
||||
if (existe) avecTour += 1;
|
||||
if (!existe && verdict.ok) sansTourInegalitesTenues += 1;
|
||||
if (existe && Math.max(...capacites) > tailles.length) avecTourTableAuDelaDesGroupes += 1;
|
||||
if ((tour !== null) !== existe) {
|
||||
const recherche = existe ? 'en trouve un' : 'n’en trouve aucun';
|
||||
ecarts.push(`${quoi()} : ${tour === null ? 'null' : 'un tour'}, la recherche ${recherche}`);
|
||||
} else if (tour !== null) {
|
||||
ecarts.push(...ecartsDuTour(tailles, capacites, tour).map((e) => `${quoi()} : ${e}`));
|
||||
}
|
||||
if (existe && !verdict.ok) {
|
||||
ecarts.push(`${quoi()} : faisabilite refuse (${verdict.raison}) une instance qui a un tour`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(ecarts.slice(0, 12), []);
|
||||
assert.ok(avecTour > 0, 'aucune instance avec tour');
|
||||
assert.ok(sansTourInegalitesTenues > 0, 'aucune instance où seul le tour témoin conclut');
|
||||
assert.ok(avecTourTableAuDelaDesGroupes > 0, 'aucune instance où les places vides comptent');
|
||||
});
|
||||
});
|
||||
269
src/demo/catalogue.js
Normal file
269
src/demo/catalogue.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les quatre démonstrations livrées (§ 15) et le générateur des démonstrations
|
||||
// tirées. Une démonstration est une configuration (types.js) tirée d'une
|
||||
// graine écrite dans sa définition (§ 15.5, point 2) : à graine égale,
|
||||
// construire() rend la même configuration, faite d'objets neufs à chaque
|
||||
// appel. Le catalogue n'essaie jamais d'autre graine que la sienne.
|
||||
//
|
||||
// Chaque construction tire d'un seul générateur, creerPcg32(graine,
|
||||
// FLUX.DEMO), dans un ordre qui fait partie du contrat (§ 15.5, point 3) :
|
||||
// insérer, retirer ou déplacer un tirage change tout ce qui le suit. Un
|
||||
// tirage sans remise de k éléments d'un réservoir de n est le mélange partiel
|
||||
// de Fisher et Yates sur une copie du réservoir : pour i de 0 à k − 1,
|
||||
// l'élément i échange sa place avec l'élément i + borne(n − i) ; les k
|
||||
// premiers sont rendus dans cet ordre.
|
||||
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||
import { faisabilite, somme, tirerTailles, tourTemoin } from './appartenances.js';
|
||||
import { NOMS_LONGS, PRENOMS, organisations, patronymes } from './noms.js';
|
||||
import { FLUX, creerPcg32 } from './prng.js';
|
||||
|
||||
// Graine de la grande démonstration, partagée par sa variante.
|
||||
const GRAINE_GRANDE = 1;
|
||||
// Graine de la petite démonstration, partagée par sa variante.
|
||||
const GRAINE_PETITE = 2;
|
||||
|
||||
// 33 tables : les 29 premières de 8 sièges, les tables 30 à 33 de 7 (§ 15.1).
|
||||
// La variante : 33 tables de 8. Les deux tirent 260 membres sur 4 tours.
|
||||
const CAPACITES_GRANDE = Object.freeze([...Array(29).fill(8), ...Array(4).fill(7)]);
|
||||
const CAPACITES_SANS_EXCEPTION = Object.freeze(Array(33).fill(8));
|
||||
const DEFINITION_GRANDE = Object.freeze({ effectif: 260, capacites: CAPACITES_GRANDE, tours: 4 });
|
||||
const DEFINITION_SANS_EXCEPTION = Object.freeze({
|
||||
effectif: 260,
|
||||
capacites: CAPACITES_SANS_EXCEPTION,
|
||||
tours: 4,
|
||||
});
|
||||
|
||||
// Appartenances A, B et C de la petite démonstration (§ 15.3), puis celles de
|
||||
// la variante, où la personne 10 passe de C à A.
|
||||
const GROUPES_PETITE = [[1, 5, 8, 12], [2, 4, 9, 11], [3, 6, 7, 10]];
|
||||
const GROUPES_PETITE_CONFLIT = [[1, 5, 8, 10, 12], [2, 4, 9, 11], [3, 6, 7]];
|
||||
const PETITE = { membres: 12, tables: 4, capacite: 3, tours: 4 };
|
||||
|
||||
// Les personnes qui portent NOMS_LONGS, par identifiant (§ 15.4).
|
||||
const NOM_LONG_DE = new Map([[7, NOMS_LONGS[0]], [77, NOMS_LONGS[1]], [177, NOMS_LONGS[2]]]);
|
||||
|
||||
// Identifiants de 1 à n, croissants.
|
||||
const idsJusqua = (n) => Array.from({ length: n }, (_, rang) => rang + 1);
|
||||
|
||||
// Les quatre contraintes, toutes actives : un objet neuf à chaque appel.
|
||||
function contraintesActives() {
|
||||
return {
|
||||
separerAppartenances: true,
|
||||
nouveauxVoisins: true,
|
||||
nouvelleTable: true,
|
||||
varierAppartenances: true,
|
||||
};
|
||||
}
|
||||
|
||||
// k éléments tirés sans remise, par le mélange partiel décrit en tête : un
|
||||
// borne() par élément tiré.
|
||||
function tirerSansRemise(rng, reservoir, k) {
|
||||
const pile = [...reservoir];
|
||||
for (let i = 0; i < k; i += 1) {
|
||||
const j = i + rng.borne(pile.length - i);
|
||||
const element = pile[j];
|
||||
pile[j] = pile[i];
|
||||
pile[i] = element;
|
||||
}
|
||||
return pile.slice(0, k);
|
||||
}
|
||||
|
||||
// Un prénom par personne, avec remise, dans l'ordre des ids : un borne()
|
||||
// chacun.
|
||||
function tirerPrenoms(rng, effectif) {
|
||||
return Array.from({ length: effectif }, () => PRENOMS[rng.borne(PRENOMS.length)]);
|
||||
}
|
||||
|
||||
// Participants d'ids 1 à N, à partir des listes tirées, indexées par
|
||||
// id − 1.
|
||||
function participantsDe(noms, prenoms, appartenances) {
|
||||
return noms.map((nom, rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom: prenoms[rang],
|
||||
appartenance: appartenances[rang],
|
||||
}));
|
||||
}
|
||||
|
||||
// Tables d'ids et de numéros 1 à T, dans l'ordre des capacités.
|
||||
function tablesDe(capacites) {
|
||||
return capacites.map((capacite, rang) => ({ id: rang + 1, numero: rang + 1, capacite }));
|
||||
}
|
||||
|
||||
function exigerReservoir(reservoir, demandes, disponibles) {
|
||||
if (demandes > disponibles) {
|
||||
throw new ErreurConfiguration('DEMO_RESERVOIR', { reservoir, demandes, disponibles });
|
||||
}
|
||||
}
|
||||
|
||||
// Refuse des tailles de groupe fixées dont l'une n'est pas un entier ≥ 1 ;
|
||||
// l'erreur nomme la première par son rang.
|
||||
function exigerTailles(tailles) {
|
||||
const rang = tailles.findIndex((taille) => !Number.isInteger(taille) || taille < 1);
|
||||
if (rang !== -1) {
|
||||
throw new RangeError(`tailles[${rang}] : entier ≥ 1 attendu, reçu ${String(tailles[rang])}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démonstration tirée (§ 15.1, § 15.2) : des groupes de taille tirée, une
|
||||
* personne par membre, et un animateur ancré à chaque table.
|
||||
*
|
||||
* Tirages, dans l'ordre du contrat :
|
||||
* 1. les tailles de groupe, tirerTailles(rng, effectif), sauf quand la
|
||||
* définition les fixe ;
|
||||
* 2. un patronyme par personne, sans remise ; les ids 7, 77 et 177 portent
|
||||
* ensuite NOMS_LONGS, dans cet ordre, à la place du leur, sans autre
|
||||
* tirage ;
|
||||
* 3. un prénom par personne, ids croissants, un borne() chacun ;
|
||||
* 4. un libellé d'organisation par groupe, sans remise, dans l'ordre des
|
||||
* groupes ;
|
||||
* 5. un mélange des ids 1 à N : ses tailles[0] premiers forment le premier
|
||||
* groupe, les suivants le deuxième, et ainsi de suite ;
|
||||
* 6. un mélange des ids 1 à N : ses T premiers sont les animateurs, le rang
|
||||
* i réservé à la table i + 1 pour tous les tours, sans égard à son
|
||||
* appartenance (§ 15.1).
|
||||
*
|
||||
* Après le tirage 1, et avant tout autre, la définition est refusée par
|
||||
* ErreurConfiguration, contrôlée dans cet ordre : DEMO_SANS_PARTICIPANT
|
||||
* (aucun membre), DEMO_SANS_TABLE (aucune table), DEMO_RESERVOIR (plus de
|
||||
* personnes que de patronymes, ou plus de groupes que d'organisations ;
|
||||
* détails reservoir, demandes, disponibles), DEMO_SANS_TOUR_TEMOIN (aucun
|
||||
* tour ne sépare toutes les appartenances ; détails graine et raison : la
|
||||
* première inégalité rompue de faisabilite, ou CONSTRUCTION quand les deux
|
||||
* tiennent et que tourTemoin ne trouve aucun tour). Le tour témoin porte sur
|
||||
* les tailles et les capacités ; les animateurs n'y entrent pas.
|
||||
*
|
||||
* @param {Object} definition
|
||||
* @param {number} definition.graine entier de 0 à 2^32 − 1
|
||||
* @param {number[]} definition.capacites sièges de chaque table ; la table
|
||||
* de rang i reçoit l'id et le numéro i + 1
|
||||
* @param {number} definition.tours
|
||||
* @param {number} [definition.effectif] membres, dont les groupes se tirent
|
||||
* @param {number[]} [definition.tailles] tailles de groupe fixées, entiers
|
||||
* ≥ 1, à la place du tirage 1 ; exclusives d'effectif
|
||||
* @returns {import('../moteur/types.js').Configuration}
|
||||
* @throws {TypeError} effectif et tailles donnés ensemble
|
||||
* @throws {RangeError} moins de membres que de tables, quand chaque table
|
||||
* reçoit un animateur ; effectif qui n'est pas un entier ≥ 0 ; taille fixée
|
||||
* qui n'est pas un entier ≥ 1 ; graine hors de son intervalle
|
||||
*/
|
||||
export function engendrer({ graine, capacites, tours, effectif, tailles: taillesFixees }) {
|
||||
if (effectif !== undefined && taillesFixees !== undefined) {
|
||||
throw new TypeError("définition : effectif et tailles s'excluent");
|
||||
}
|
||||
const rng = creerPcg32(graine, FLUX.DEMO);
|
||||
if (taillesFixees !== undefined) exigerTailles(taillesFixees);
|
||||
const tailles = taillesFixees ?? tirerTailles(rng, effectif);
|
||||
const N = somme(tailles);
|
||||
const T = capacites.length;
|
||||
if (N === 0) throw new ErreurConfiguration('DEMO_SANS_PARTICIPANT');
|
||||
if (T === 0) throw new ErreurConfiguration('DEMO_SANS_TABLE');
|
||||
if (N < T) throw new RangeError(`${N} membres pour ${T} tables : un animateur par table`);
|
||||
const reservoirNoms = patronymes();
|
||||
const reservoirOrganisations = organisations();
|
||||
exigerReservoir('patronymes', N, reservoirNoms.length);
|
||||
exigerReservoir('organisations', tailles.length, reservoirOrganisations.length);
|
||||
const verdict = faisabilite(tailles, capacites);
|
||||
if (!verdict.ok) {
|
||||
throw new ErreurConfiguration('DEMO_SANS_TOUR_TEMOIN', { graine, raison: verdict.raison });
|
||||
}
|
||||
if (tourTemoin(tailles, capacites) === null) {
|
||||
throw new ErreurConfiguration('DEMO_SANS_TOUR_TEMOIN', { graine, raison: 'CONSTRUCTION' });
|
||||
}
|
||||
|
||||
const noms = tirerSansRemise(rng, reservoirNoms, N)
|
||||
.map((nom, rang) => NOM_LONG_DE.get(rang + 1) ?? nom);
|
||||
const prenoms = tirerPrenoms(rng, N);
|
||||
const libelles = tirerSansRemise(rng, reservoirOrganisations, tailles.length);
|
||||
const ordre = rng.melanger(idsJusqua(N));
|
||||
const animateurs = rng.melanger(idsJusqua(N));
|
||||
|
||||
const appartenances = new Array(N);
|
||||
let rang = 0;
|
||||
for (let g = 0; g < tailles.length; g += 1) {
|
||||
for (let m = 0; m < tailles[g]; m += 1, rang += 1) appartenances[ordre[rang] - 1] = libelles[g];
|
||||
}
|
||||
return {
|
||||
participants: participantsDe(noms, prenoms, appartenances),
|
||||
tables: tablesDe(capacites),
|
||||
tours,
|
||||
reservations: capacites.map((_, t) => ({
|
||||
participant: animateurs[t],
|
||||
table: t + 1,
|
||||
portee: 'tous',
|
||||
})),
|
||||
contraintes: contraintesActives(),
|
||||
};
|
||||
}
|
||||
|
||||
// Petite démonstration (§ 15.3) : 12 personnes en appartenances fixées à la
|
||||
// main, 4 tables de 3, 4 tours, aucune réservation. Tirages, dans l'ordre :
|
||||
// un patronyme par personne, sans remise ; un prénom par personne, ids
|
||||
// croissants, un borne() chacun ; un libellé d'organisation par groupe, sans
|
||||
// remise, dans l'ordre des groupes.
|
||||
function construirePetite(graine, groupes) {
|
||||
const rng = creerPcg32(graine, FLUX.DEMO);
|
||||
const noms = tirerSansRemise(rng, patronymes(), PETITE.membres);
|
||||
const prenoms = tirerPrenoms(rng, PETITE.membres);
|
||||
const libelles = tirerSansRemise(rng, organisations(), groupes.length);
|
||||
const appartenances = new Array(PETITE.membres);
|
||||
groupes.forEach((ids, g) => {
|
||||
for (const id of ids) appartenances[id - 1] = libelles[g];
|
||||
});
|
||||
return {
|
||||
participants: participantsDe(noms, prenoms, appartenances),
|
||||
tables: tablesDe(Array(PETITE.tables).fill(PETITE.capacite)),
|
||||
tours: PETITE.tours,
|
||||
reservations: [],
|
||||
contraintes: contraintesActives(),
|
||||
};
|
||||
}
|
||||
|
||||
// Entrée figée du catalogue ; construire() tire avec la graine de l'entrée.
|
||||
function entree(cle, nom, graine, construire) {
|
||||
return Object.freeze({ cle, nom, graine, construire: () => construire(graine) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Les quatre démonstrations livrées (§ 15), dans l'ordre de présentation :
|
||||
* { cle, nom, graine, construire() }. Les quatre activent toutes les
|
||||
* contraintes. La variante sans exception reprend les tirages de la grande
|
||||
* avec 33 tables de 8 ; la variante conflit reprend ceux de la petite, la
|
||||
* personne 10 passée de C à A.
|
||||
*/
|
||||
export const CATALOGUE = Object.freeze([
|
||||
entree('grande', 'Grande démonstration', GRAINE_GRANDE,
|
||||
(graine) => engendrer({ graine, ...DEFINITION_GRANDE })),
|
||||
entree('grande-sans-exception', 'Grande démonstration sans exception', GRAINE_GRANDE,
|
||||
(graine) => engendrer({ graine, ...DEFINITION_SANS_EXCEPTION })),
|
||||
entree('petite', 'Petite démonstration', GRAINE_PETITE,
|
||||
(graine) => construirePetite(graine, GROUPES_PETITE)),
|
||||
entree('petite-conflit', 'Petite démonstration, conflit inévitable', GRAINE_PETITE,
|
||||
(graine) => construirePetite(graine, GROUPES_PETITE_CONFLIT)),
|
||||
]);
|
||||
|
||||
// Fige un tableau et, à toute profondeur, les tableaux qu'il contient.
|
||||
function figer(tableau) {
|
||||
for (const element of tableau) if (Array.isArray(element)) figer(element);
|
||||
return Object.freeze(tableau);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le plan parfait de la petite démonstration (§ 15.3), en identifiants
|
||||
* (§ 8.9) : à chaque tour, chaque table réunit un membre de A, de B et de C,
|
||||
* et aucune paire ne se retrouve. Figé à toute profondeur.
|
||||
* @type {import('../moteur/types.js').Plan}
|
||||
*/
|
||||
export const PLAN_PARFAIT_PETITE = Object.freeze({
|
||||
tables: figer([1, 2, 3, 4]),
|
||||
tours: figer([
|
||||
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
|
||||
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
|
||||
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
|
||||
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
|
||||
]),
|
||||
reserves: figer([[], [], [], []]),
|
||||
});
|
||||
235
src/demo/catalogue.long.test.js
Normal file
235
src/demo/catalogue.long.test.js
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Garde des noms forgés (§ 14.9, § 15.6) : aucun patronyme forgé complet, nom
|
||||
// long compris, ni aucune racine d'organisation n'apparaît en mot entier dans
|
||||
// un fichier texte du projet hors de src/demo/noms.js, qui les définit. Les
|
||||
// données d'épreuve de test/fixtures/ sont balayées comme le reste.
|
||||
//
|
||||
// Un mot entier est bordé, de chaque côté, par autre chose qu'une lettre, une
|
||||
// marque combinante ou un chiffre : le trait de soulignement et le trait
|
||||
// d'union séparent deux mots. La comparaison ignore la casse et se fait sur
|
||||
// le texte normalisé en NFC. Un fichier qui porte un octet nul dans ses
|
||||
// 8000 premiers octets est binaire et n'est pas examiné, comme le décide git.
|
||||
//
|
||||
// Le balayage s'éprouve sur un arbre témoin avant de s'appliquer au projet.
|
||||
// Chaque fichier de l'arbre exerce une règle : une sonde de test/fixtures/
|
||||
// porte chaque nom cherché ; d'autres placent un nom au-delà du premier Mio
|
||||
// du texte, en NFD, après un octet nul, dans une entrée cachée, dans un
|
||||
// noms.js qui n'est pas celui des listes, ou sous un répertoire exclu ; trois
|
||||
// derniers se lisent malgré un nom voisin d'une exclusion : src/distances/,
|
||||
// distinctes.js et un fichier .git. L'épreuve affirme les fichiers lus, le
|
||||
// compte exact des trouvailles, et que la garde de l'épreuve du projet
|
||||
// désigne exactement les fichiers placés sous un répertoire exclu.
|
||||
//
|
||||
// L'épreuve établit l'absence dans ce projet, rien sur le monde réel : aucune
|
||||
// vérification locale ne prouve qu'un nom forgé ne désigne nulle part une
|
||||
// personne ou une organisation existante.
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { NOMS_LONGS, RACINES, patronymes } from './noms.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
|
||||
const LISTES = join('src', 'demo', 'noms.js');
|
||||
|
||||
// Répertoires hors balayage, à toute profondeur : .git, les dépendances, les
|
||||
// caches des outils, et les sorties de construction, qui portent une copie
|
||||
// des listes mises en paquet (www, dist et chaque dist-…, plateformes de
|
||||
// Capacitor). Un répertoire s'exclut par son nom exact, ou par le préfixe
|
||||
// « dist- » : src/distances/ se balaie comme le reste. Un fichier ne
|
||||
// s'exclut jamais par son nom : un module distinctes.js se lit, comme le
|
||||
// fichier .git que porte un arbre de travail lié de git.
|
||||
const HORS_BALAYAGE = new Set([
|
||||
'.git', 'node_modules', 'www', 'dist', 'android', 'ios', 'coverage', '.vite', '.vitest',
|
||||
]);
|
||||
const horsBalayage = (nom) => HORS_BALAYAGE.has(nom) || nom.startsWith('dist-');
|
||||
|
||||
// Vrai quand un répertoire du chemin relatif est hors balayage ; le nom du
|
||||
// fichier, dernier composant, n'entre pas en compte.
|
||||
const sousUnRepertoireExclu = (chemin) => chemin.split(/[\\/]/).slice(0, -1).some(horsBalayage);
|
||||
|
||||
// Noms cherchés : chaque patronyme forgé, chaque nom long et chaque racine
|
||||
// d'organisation.
|
||||
const nomsCherches = () => [...patronymes(), ...NOMS_LONGS, ...RACINES];
|
||||
|
||||
// Fichiers de l'arbre de racine, hors des répertoires exclus, en chemins
|
||||
// relatifs à racine, triés.
|
||||
function fichiers(racine) {
|
||||
const chemins = [];
|
||||
const parcourir = (dossier) => {
|
||||
for (const entree of readdirSync(dossier, { withFileTypes: true })) {
|
||||
const chemin = join(dossier, entree.name);
|
||||
if (entree.isDirectory()) {
|
||||
if (!horsBalayage(entree.name)) parcourir(chemin);
|
||||
} else if (entree.isFile()) {
|
||||
chemins.push(relative(racine, chemin));
|
||||
}
|
||||
}
|
||||
};
|
||||
parcourir(racine);
|
||||
return chemins.sort();
|
||||
}
|
||||
|
||||
const estBinaire = (octets) => octets.subarray(0, 8000).includes(0);
|
||||
|
||||
const echapper = (texte) => texte.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
// Motif qui trouve chacun des noms en mot entier, sans égard à la casse.
|
||||
function motifDes(noms) {
|
||||
const alternatives = noms.map((nom) => echapper(nom.normalize('NFC'))).join('|');
|
||||
return new RegExp(`(?<![\\p{L}\\p{M}\\p{N}])(?:${alternatives})(?![\\p{L}\\p{M}\\p{N}])`, 'giu');
|
||||
}
|
||||
|
||||
// Occurrences du motif dans un texte, normalisé en NFC.
|
||||
const occurrences = (motif, texte) => [...texte.normalize('NFC').matchAll(motif)].map(([mot]) => mot);
|
||||
|
||||
// Balaie l'arbre de racine : chaque fichier texte hors des répertoires exclus
|
||||
// et des listes du générateur se lit en entier, décodé en UTF-8, et chaque
|
||||
// nom cherché s'y relève. Rend les chemins examinés, relatifs à racine et
|
||||
// triés, et les trouvailles { chemin, mot }, fichier après fichier, dans
|
||||
// l'ordre du texte.
|
||||
function balayer(racine) {
|
||||
const motif = motifDes(nomsCherches());
|
||||
const examines = [];
|
||||
const trouvailles = [];
|
||||
for (const chemin of fichiers(racine)) {
|
||||
if (chemin === LISTES) continue;
|
||||
const octets = readFileSync(join(racine, chemin));
|
||||
if (estBinaire(octets)) continue;
|
||||
examines.push(chemin);
|
||||
for (const mot of occurrences(motif, octets.toString('utf8'))) trouvailles.push({ chemin, mot });
|
||||
}
|
||||
return { examines, trouvailles };
|
||||
}
|
||||
|
||||
// Écrit dans un répertoire temporaire l'arbre donné, liste de paires [chemin
|
||||
// relatif, contenu], le passe à examiner, puis l'efface ; rend ce que rend
|
||||
// examiner.
|
||||
function avecArbre(arbre, examiner) {
|
||||
const racine = mkdtempSync(join(tmpdir(), 'noms-forges-'));
|
||||
try {
|
||||
for (const [chemin, contenu] of arbre) {
|
||||
mkdirSync(dirname(join(racine, chemin)), { recursive: true });
|
||||
writeFileSync(join(racine, chemin), contenu);
|
||||
}
|
||||
return examiner(racine);
|
||||
} finally {
|
||||
rmSync(racine, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Contenu dont l'octet d'indice indice est nul : des points jusqu'à lui, puis
|
||||
// le nom sur la ligne suivante.
|
||||
const nulA = (indice, nom) => Buffer.concat([Buffer.alloc(indice, '.'), Buffer.from(`\0\n${nom}\n`)]);
|
||||
|
||||
describe('noms forgés : balayage du projet (§ 15.6)', () => {
|
||||
test('le motif trouve un nom forgé en mot entier, quelles que soient la casse et la forme normale', () => {
|
||||
const [patronyme] = patronymes();
|
||||
const [racine] = RACINES;
|
||||
const long = NOMS_LONGS[1];
|
||||
const motif = motifDes([patronyme, racine, long]);
|
||||
// [texte, occurrences attendues]
|
||||
const cas = [
|
||||
[`« ${patronyme} »`, [patronyme]],
|
||||
[`${patronyme.toUpperCase()},`, [patronyme.toUpperCase()]],
|
||||
[`x_${racine}-y`, [racine]],
|
||||
[long.normalize('NFD'), [long]],
|
||||
[`x${patronyme}`, []],
|
||||
[`${racine}s`, []],
|
||||
[`${patronyme}2`, []],
|
||||
];
|
||||
const ecarts = cas
|
||||
.filter(([texte, attendu]) => occurrences(motif, texte).join('|') !== attendu.join('|'))
|
||||
.map(([texte]) => texte);
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('arbre témoin : chaque nom trouvé, tout fichier texte lu en entier, seules les exclusions sautées', () => {
|
||||
// Les noms attendus se calculent ici, sans nomsCherches : la sonde de
|
||||
// test/fixtures/ en porte un par ligne, et chaque ligne doit être trouvée.
|
||||
const noms = [...patronymes(), ...NOMS_LONGS, ...RACINES];
|
||||
assert.ok(
|
||||
patronymes().length > 0 && NOMS_LONGS.length > 0 && RACINES.length > 0,
|
||||
'aucun nom forgé à chercher',
|
||||
);
|
||||
const [patronyme] = patronymes();
|
||||
const enCapitales = RACINES[RACINES.length - 1].toUpperCase();
|
||||
const sonde = join('test', 'fixtures', 'sonde.csv');
|
||||
// [chemin, contenu, mots] : les fichiers lus, et les mots que le balayage
|
||||
// doit y relever, dans l'ordre du texte.
|
||||
const lus = [
|
||||
[sonde, `${noms.join('\n')}\n`, noms],
|
||||
// Au-delà du premier Mio : une lecture tronquée le manque.
|
||||
[join('docs', 'loin.md'), `${'.'.repeat(2 ** 20)}\n${enCapitales}\n`, [enCapitales]],
|
||||
['nfd.txt', `${NOMS_LONGS[1].normalize('NFD')}\n`, [NOMS_LONGS[1]]],
|
||||
['nul-dehors.dat', nulA(8000, patronyme), [patronyme]],
|
||||
// Une entrée cachée se lit, fichier ou répertoire.
|
||||
['.editorconfig', patronyme, [patronyme]],
|
||||
[join('.github', 'x.yml'), patronyme, [patronyme]],
|
||||
// Les listes s'excluent par leur chemin, non par leur nom de fichier.
|
||||
[join('autre', 'noms.js'), patronyme, [patronyme]],
|
||||
// Un répertoire dont le nom commence comme celui d'un exclu se balaie,
|
||||
// et un fichier ne s'exclut jamais par son nom : un arbre de travail lié
|
||||
// de git porte un fichier .git.
|
||||
[join('src', 'distances', 'a.js'), patronyme, [patronyme]],
|
||||
[join('src', 'distinctes.js'), 'paires distinctes\n', []],
|
||||
[join('sous', '.git'), 'gitdir: ../.git/worktrees/sous\n', []],
|
||||
];
|
||||
// [chemin, contenu] : les fichiers que le balayage saute — un octet nul
|
||||
// dans les 8000 premiers, les listes du générateur.
|
||||
const sautes = [
|
||||
['nul-dedans.dat', nulA(7999, patronyme)],
|
||||
[LISTES, patronyme],
|
||||
];
|
||||
// Un fichier sous chaque répertoire exclu, à la racine et plus profond ;
|
||||
// chacun porte un nom forgé.
|
||||
const exclus = [
|
||||
join('node_modules', 'p', 'i.js'),
|
||||
join('sous', 'node_modules', 'p', 'i.js'),
|
||||
join('www', 'i.js'),
|
||||
join('dist', 'i.js'),
|
||||
join('dist-x', 'i.js'),
|
||||
join('android', 'i.js'),
|
||||
join('ios', 'i.js'),
|
||||
join('coverage', 'i.html'),
|
||||
join('.git', 'i'),
|
||||
join('.vite', 'i.js'),
|
||||
join('.vitest', 'i.json'),
|
||||
];
|
||||
const arbre = [
|
||||
...lus.map(([chemin, contenu]) => [chemin, contenu]),
|
||||
...sautes,
|
||||
...exclus.map((chemin) => [chemin, patronyme]),
|
||||
];
|
||||
const { examines, trouvailles } = avecArbre(arbre, balayer);
|
||||
const attendus = [...lus].sort(([a], [b]) => (a < b ? -1 : 1));
|
||||
// Chaque fichier lu, et lui seul, avec le compte de ses trouvailles : un
|
||||
// écart s'affiche en chemins et en comptes.
|
||||
assert.deepEqual(
|
||||
examines.map((chemin) => [chemin, trouvailles.filter((t) => t.chemin === chemin).length]),
|
||||
attendus.map(([chemin, , mots]) => [chemin, mots.length]),
|
||||
);
|
||||
assert.equal(trouvailles.length, attendus.reduce((total, [, , mots]) => total + mots.length, 0));
|
||||
// Hors de la sonde, chaque mot tel que l'écrit le texte normalisé.
|
||||
assert.deepEqual(
|
||||
trouvailles.filter((t) => t.chemin !== sonde).map((t) => t.mot),
|
||||
attendus.filter(([chemin]) => chemin !== sonde).flatMap(([, , mots]) => mots),
|
||||
);
|
||||
// La garde de l'épreuve du projet désigne exactement les fichiers placés
|
||||
// sous un répertoire exclu.
|
||||
assert.deepEqual(arbre.map(([chemin]) => chemin).filter(sousUnRepertoireExclu), exclus);
|
||||
});
|
||||
|
||||
test('aucun patronyme forgé ni aucune racine hors de src/demo/noms.js', () => {
|
||||
assert.ok(nomsCherches().length > 0, 'aucun nom forgé à chercher');
|
||||
const { examines, trouvailles } = balayer(RACINE);
|
||||
assert.ok(examines.length > 0, 'balayage vide');
|
||||
assert.ok(examines.includes(join('src', 'demo', 'catalogue.js')), 'src/demo/catalogue.js non balayé');
|
||||
assert.deepEqual(examines.filter(sousUnRepertoireExclu), []);
|
||||
assert.deepEqual(trouvailles.map(({ chemin, mot }) => `${chemin} : ${mot}`), []);
|
||||
});
|
||||
});
|
||||
481
src/demo/catalogue.test.js
Normal file
481
src/demo/catalogue.test.js
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du catalogue de démonstrations (§ 15) : ses quatre entrées, le
|
||||
// déterminisme, les grandeurs de chacune, l'ordre contractuel des tirages
|
||||
// (§ 15.5, point 3), le plan parfait de la petite et les refus du générateur
|
||||
// (§ 15.4). Le profil d'appartenances de la grande n'est écrit nulle part ici
|
||||
// (§ 15.2) : il se recalcule en rejouant le tirage des tailles.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { indexerPlan, normaliser } from '../moteur/configuration.js';
|
||||
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||
import { tirerTailles, tourTemoin } from './appartenances.js';
|
||||
import { CATALOGUE, PLAN_PARFAIT_PETITE, engendrer } from './catalogue.js';
|
||||
import { NOMS_LONGS, PRENOMS, organisations, patronymes } from './noms.js';
|
||||
import { FLUX, creerPcg32 } from './prng.js';
|
||||
|
||||
const CLES = ['grande', 'grande-sans-exception', 'petite', 'petite-conflit'];
|
||||
const TOUTES_ACTIVES = {
|
||||
separerAppartenances: true,
|
||||
nouveauxVoisins: true,
|
||||
nouvelleTable: true,
|
||||
varierAppartenances: true,
|
||||
};
|
||||
|
||||
// Appartenances de la petite démonstration (§ 15.3), puis de sa variante,
|
||||
// où la personne 10 passe de C à A.
|
||||
const A = [1, 5, 8, 12];
|
||||
const B = [2, 4, 9, 11];
|
||||
const C = [3, 6, 7, 10];
|
||||
const A_CONFLIT = [1, 5, 8, 10, 12];
|
||||
const C_CONFLIT = [3, 6, 7];
|
||||
|
||||
// Le plan parfait de la petite démonstration, recopié du tableau du § 15.3 :
|
||||
// TABLEAU_15_3[r][i] = ids assis à la table i + 1 au tour r + 1.
|
||||
const TABLEAU_15_3 = [
|
||||
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
|
||||
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
|
||||
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
|
||||
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
|
||||
];
|
||||
|
||||
// Entiers de debut à fin, inclus.
|
||||
const suite = (debut, fin) => Array.from({ length: fin - debut + 1 }, (_, i) => debut + i);
|
||||
const somme = (liste) => liste.reduce((a, b) => a + b, 0);
|
||||
const croissant = (a, b) => a - b;
|
||||
const entree = (cle) => CATALOGUE.find((e) => e.cle === cle);
|
||||
|
||||
// Configuration d'une entrée, construite à la première demande puis gardée :
|
||||
// une entrée absente fait échouer l'épreuve qui la demande, non le chargement
|
||||
// du fichier. Aucune épreuve ne modifie ce qu'elle reçoit.
|
||||
const construites = new Map();
|
||||
function configurationDe(cle) {
|
||||
if (!construites.has(cle)) construites.set(cle, entree(cle).construire());
|
||||
return construites.get(cle);
|
||||
}
|
||||
|
||||
// Membres de chaque appartenance, en ids croissants, les groupes rangés par
|
||||
// leur plus petit id. La Map retrouve le groupe d'un libellé ; le tri fixe
|
||||
// l'ordre de sortie.
|
||||
function groupesDe({ participants }) {
|
||||
const membres = new Map();
|
||||
for (const { id, appartenance } of participants) {
|
||||
if (!membres.has(appartenance)) membres.set(appartenance, []);
|
||||
membres.get(appartenance).push(id);
|
||||
}
|
||||
return [...membres.values()].map((ids) => ids.sort(croissant)).sort((a, b) => a[0] - b[0]);
|
||||
}
|
||||
|
||||
// Objets d'une configuration : elle-même, ses listes, leurs éléments et ses
|
||||
// contraintes.
|
||||
const objetsDe = (c) => [
|
||||
c, c.participants, ...c.participants, c.tables, ...c.tables,
|
||||
c.reservations, ...c.reservations, c.contraintes,
|
||||
];
|
||||
|
||||
// Le tirage sans remise que décrit catalogue.js : mélange partiel de Fisher et
|
||||
// Yates, de la première case vers la dernière, un borne() par élément tiré.
|
||||
function tirerSansRemise(rng, reservoir, k) {
|
||||
const pile = [...reservoir];
|
||||
for (let i = 0; i < k; i += 1) {
|
||||
const j = i + rng.borne(pile.length - i);
|
||||
[pile[i], pile[j]] = [pile[j], pile[i]];
|
||||
}
|
||||
return pile.slice(0, k);
|
||||
}
|
||||
|
||||
// Tables de 8 sièges, assez nombreuses pour asseoir les membres donnés avec
|
||||
// une table de marge.
|
||||
const tablesPour = (membres) => Array(Math.ceil(membres / 8) + 1).fill(8);
|
||||
|
||||
// Code et détails de l'ErreurConfiguration que lève l'appel ; toute autre
|
||||
// issue est décrite en une ligne.
|
||||
function refus(appel) {
|
||||
try {
|
||||
appel();
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurConfiguration) return { code: erreur.code, details: erreur.details };
|
||||
return `${erreur.name} : ${erreur.message}`;
|
||||
}
|
||||
return 'aucune erreur';
|
||||
}
|
||||
|
||||
describe('catalogue : les entrées', () => {
|
||||
test('quatre entrées, dans cet ordre, chacune nommée, ensemencée et figée', () => {
|
||||
assert.deepEqual(CATALOGUE.map((e) => e.cle), CLES);
|
||||
assert.ok(Object.isFrozen(CATALOGUE));
|
||||
assert.ok(CATALOGUE.every((e) => Object.isFrozen(e)));
|
||||
const noms = CATALOGUE.map((e) => e.nom);
|
||||
assert.ok(noms.every((nom) => typeof nom === 'string' && nom.length > 0));
|
||||
assert.equal(new Set(noms).size, CLES.length);
|
||||
assert.ok(CATALOGUE.every((e) => Number.isInteger(e.graine) && e.graine >= 0 && e.graine < 2 ** 32));
|
||||
assert.ok(CATALOGUE.every((e) => typeof e.construire === 'function'));
|
||||
});
|
||||
|
||||
test('chaque variante tire avec la graine de sa démonstration', () => {
|
||||
assert.equal(entree('grande-sans-exception').graine, entree('grande').graine);
|
||||
assert.equal(entree('petite-conflit').graine, entree('petite').graine);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogue : déterminisme (§ 15.5)', () => {
|
||||
test('construire() deux fois rend deux configurations égales', () => {
|
||||
assert.equal(CATALOGUE.length, CLES.length);
|
||||
for (const e of CATALOGUE) assert.deepStrictEqual(e.construire(), e.construire(), e.cle);
|
||||
});
|
||||
|
||||
test('construire() rend des objets neufs, aucun partagé avec un appel précédent', () => {
|
||||
assert.equal(CATALOGUE.length, CLES.length);
|
||||
for (const e of CATALOGUE) {
|
||||
const premiers = new Set(objetsDe(e.construire()));
|
||||
assert.equal(objetsDe(e.construire()).filter((objet) => premiers.has(objet)).length, 0, e.cle);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogue : grande (§ 15.1)', () => {
|
||||
test('260 participants, ids 1 à 260, chacun nommé, prénommé et membre d’une organisation', () => {
|
||||
const { participants } = configurationDe('grande');
|
||||
assert.deepEqual(participants.map((p) => p.id), suite(1, 260));
|
||||
const prenoms = new Set(PRENOMS);
|
||||
const reservoir = new Set(organisations());
|
||||
const ecarts = participants
|
||||
.filter((p) => typeof p.nom !== 'string' || !prenoms.has(p.prenom) || !reservoir.has(p.appartenance)
|
||||
|| p.exclu !== undefined)
|
||||
.map((p) => p.id);
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('33 tables, ids et numéros 1 à 33, 8 sièges sauf 30 à 33 à 7 : Σ c_t = 260', () => {
|
||||
const { tables } = configurationDe('grande');
|
||||
assert.deepEqual(tables, suite(1, 33).map((id) => ({ id, numero: id, capacite: id >= 30 ? 7 : 8 })));
|
||||
assert.equal(somme(tables.map((t) => t.capacite)), 260);
|
||||
});
|
||||
|
||||
test('4 tours, les quatre contraintes actives', () => {
|
||||
const configuration = configurationDe('grande');
|
||||
assert.equal(configuration.tours, 4);
|
||||
assert.deepEqual(configuration.contraintes, TOUTES_ACTIVES);
|
||||
});
|
||||
|
||||
test('33 réservations « tous », une par table, animateurs distincts', () => {
|
||||
const { reservations } = configurationDe('grande');
|
||||
assert.equal(reservations.length, 33);
|
||||
assert.ok(reservations.every((r) => r.portee === 'tous' && !('tour' in r)));
|
||||
assert.deepEqual(reservations.map((r) => r.table).sort(croissant), suite(1, 33));
|
||||
const animateurs = reservations.map((r) => r.participant);
|
||||
assert.equal(new Set(animateurs).size, 33);
|
||||
assert.ok(animateurs.every((id) => Number.isInteger(id) && id >= 1 && id <= 260));
|
||||
});
|
||||
|
||||
test('les animateurs ne partagent pas tous une même appartenance', () => {
|
||||
const { participants, reservations } = configurationDe('grande');
|
||||
const appartenanceDe = new Map(participants.map((p) => [p.id, p.appartenance]));
|
||||
const appartenances = new Set(reservations.map((r) => appartenanceDe.get(r.participant)));
|
||||
assert.ok(appartenances.size > 1);
|
||||
});
|
||||
|
||||
test('tailles de groupe de 1 à 12, somme 260 : celles que rejoue le tirage des tailles', () => {
|
||||
const configuration = configurationDe('grande');
|
||||
const tailles = groupesDe(configuration).map((membres) => membres.length);
|
||||
assert.ok(tailles.every((taille) => taille >= 1 && taille <= 12), JSON.stringify(tailles));
|
||||
assert.equal(somme(tailles), 260);
|
||||
const tirees = tirerTailles(creerPcg32(entree('grande').graine, FLUX.DEMO), 260);
|
||||
assert.deepEqual([...tailles].sort(croissant), [...tirees].sort(croissant));
|
||||
assert.notEqual(tourTemoin(tirees, configuration.tables.map((t) => t.capacite)), null);
|
||||
});
|
||||
|
||||
test('260 patronymes distincts ; les ids 7, 77 et 177 portent les trois noms longs', () => {
|
||||
const { participants } = configurationDe('grande');
|
||||
const noms = participants.map((p) => p.nom);
|
||||
assert.equal(new Set(noms).size, 260);
|
||||
assert.deepEqual([noms[6], noms[76], noms[176]], [...NOMS_LONGS]);
|
||||
const reservoir = new Set(patronymes());
|
||||
const horsReservoir = participants.filter((p) => !reservoir.has(p.nom)).map((p) => p.id);
|
||||
assert.deepEqual(horsReservoir, [7, 77, 177]);
|
||||
});
|
||||
|
||||
test('exactement tendue : 33 ancrés, 227 mobiles, capacité libre 227', () => {
|
||||
const instance = normaliser(configurationDe('grande'));
|
||||
assert.deepEqual([instance.N, instance.T, instance.R, instance.k, instance.n], [260, 33, 4, 33, 227]);
|
||||
let libre = 0;
|
||||
for (let t = 0; t < instance.T; t += 1) libre += instance.capacite[t] - instance.ancresParTable[t];
|
||||
assert.equal(libre, instance.n);
|
||||
});
|
||||
|
||||
test('l’ordre des tirages suit le contrat (§ 15.5, point 3)', () => {
|
||||
const { participants, reservations } = configurationDe('grande');
|
||||
const N = 260;
|
||||
const rng = creerPcg32(entree('grande').graine, FLUX.DEMO);
|
||||
const tailles = tirerTailles(rng, N); // (1)
|
||||
const noms = tirerSansRemise(rng, patronymes(), N); // (2)
|
||||
const prenoms = suite(1, N).map(() => PRENOMS[rng.borne(PRENOMS.length)]); // (3)
|
||||
const libelles = tirerSansRemise(rng, organisations(), tailles.length); // (4)
|
||||
const ordre = rng.melanger(suite(1, N)); // (5)
|
||||
const animateurs = rng.melanger(suite(1, N)).slice(0, 33); // (6)
|
||||
const appartenance = [];
|
||||
let rang = 0;
|
||||
tailles.forEach((taille, g) => {
|
||||
for (let m = 0; m < taille; m += 1, rang += 1) appartenance[ordre[rang]] = libelles[g];
|
||||
});
|
||||
const longs = new Map([[7, NOMS_LONGS[0]], [77, NOMS_LONGS[1]], [177, NOMS_LONGS[2]]]);
|
||||
assert.deepEqual(
|
||||
participants,
|
||||
suite(1, N).map((id) => ({
|
||||
id,
|
||||
nom: longs.get(id) ?? noms[id - 1],
|
||||
prenom: prenoms[id - 1],
|
||||
appartenance: appartenance[id],
|
||||
})),
|
||||
);
|
||||
assert.deepEqual(
|
||||
reservations,
|
||||
animateurs.map((participant, t) => ({ participant, table: t + 1, portee: 'tous' })),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogue : grande sans exception (§ 15.1)', () => {
|
||||
test('mêmes participants, appartenances et animateurs que la grande', () => {
|
||||
const grande = configurationDe('grande');
|
||||
const variante = configurationDe('grande-sans-exception');
|
||||
assert.deepStrictEqual(variante.participants, grande.participants);
|
||||
assert.deepStrictEqual(variante.reservations, grande.reservations);
|
||||
assert.equal(variante.tours, grande.tours);
|
||||
assert.deepStrictEqual(variante.contraintes, grande.contraintes);
|
||||
});
|
||||
|
||||
test('33 tables de 8, ids et numéros 1 à 33 : 264 sièges', () => {
|
||||
assert.deepEqual(
|
||||
configurationDe('grande-sans-exception').tables,
|
||||
suite(1, 33).map((id) => ({ id, numero: id, capacite: 8 })),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogue : petite et conflit inévitable (§ 15.3)', () => {
|
||||
test('12 participants, 4 tables de 3, 4 tours, aucune réservation, contraintes actives', () => {
|
||||
for (const c of [configurationDe('petite'), configurationDe('petite-conflit')]) {
|
||||
assert.deepEqual(c.participants.map((p) => p.id), suite(1, 12));
|
||||
assert.deepEqual(c.tables, suite(1, 4).map((id) => ({ id, numero: id, capacite: 3 })));
|
||||
assert.equal(c.tours, 4);
|
||||
assert.deepEqual(c.reservations, []);
|
||||
assert.deepEqual(c.contraintes, TOUTES_ACTIVES);
|
||||
}
|
||||
});
|
||||
|
||||
test('appartenances A, B, C : 4, 4, 4 ; dans la variante 5, 4, 3', () => {
|
||||
const conflit = configurationDe('petite-conflit');
|
||||
assert.deepEqual(groupesDe(configurationDe('petite')), [A, B, C]);
|
||||
assert.deepEqual(groupesDe(conflit), [A_CONFLIT, B, C_CONFLIT]);
|
||||
assert.deepEqual(groupesDe(conflit).map((g) => g.length), [5, 4, 3]);
|
||||
});
|
||||
|
||||
test('trois libellés distincts tirés parmi les organisations ; noms et prénoms des réservoirs', () => {
|
||||
const configuration = configurationDe('petite');
|
||||
const libelles = new Set(configuration.participants.map((p) => p.appartenance));
|
||||
assert.equal(libelles.size, 3);
|
||||
const reservoir = new Set(organisations());
|
||||
assert.ok([...libelles].every((libelle) => reservoir.has(libelle)));
|
||||
const noms = configuration.participants.map((p) => p.nom);
|
||||
assert.equal(new Set(noms).size, 12);
|
||||
const patronymesConnus = new Set(patronymes());
|
||||
assert.ok(noms.every((nom) => patronymesConnus.has(nom)));
|
||||
assert.ok(configuration.participants.every((p) => PRENOMS.includes(p.prenom)));
|
||||
});
|
||||
|
||||
test('la variante ne change que l’appartenance de la personne 10', () => {
|
||||
const { participants } = configurationDe('petite');
|
||||
const conflit = configurationDe('petite-conflit');
|
||||
const libelleA = participants[0].appartenance;
|
||||
assert.deepEqual(
|
||||
conflit.participants,
|
||||
participants.map((p) => (p.id === 10 ? { ...p, appartenance: libelleA } : p)),
|
||||
);
|
||||
});
|
||||
|
||||
test('l’ordre des tirages : patronymes, prénoms, organisations', () => {
|
||||
const configuration = configurationDe('petite');
|
||||
const rng = creerPcg32(entree('petite').graine, FLUX.DEMO);
|
||||
const noms = tirerSansRemise(rng, patronymes(), 12);
|
||||
const prenoms = suite(1, 12).map(() => PRENOMS[rng.borne(PRENOMS.length)]);
|
||||
const [a, b, c] = tirerSansRemise(rng, organisations(), 3);
|
||||
const libelleDe = (id) => (A.includes(id) ? a : B.includes(id) ? b : c);
|
||||
assert.deepEqual(
|
||||
configuration.participants,
|
||||
suite(1, 12).map((id) => ({ id, nom: noms[id - 1], prenom: prenoms[id - 1], appartenance: libelleDe(id) })),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogue : plan parfait de la petite (§ 15.3)', () => {
|
||||
test('le tableau du § 15.3, en ids, sans réserve', () => {
|
||||
assert.deepEqual(PLAN_PARFAIT_PETITE, {
|
||||
tables: [1, 2, 3, 4],
|
||||
tours: TABLEAU_15_3,
|
||||
reserves: [[], [], [], []],
|
||||
});
|
||||
});
|
||||
|
||||
test('partition à chaque tour, aucune paire répétée, 4 tables et 8 rencontres chacun, aucune collision', () => {
|
||||
const groupeDe = (id) => (A.includes(id) ? 'A' : B.includes(id) ? 'B' : 'C');
|
||||
const ecarts = [];
|
||||
const paires = new Set();
|
||||
const rencontres = new Map(suite(1, 12).map((id) => [id, new Set()]));
|
||||
const visitees = new Map(suite(1, 12).map((id) => [id, new Set()]));
|
||||
PLAN_PARFAIT_PETITE.tours.forEach((listes, r) => {
|
||||
const assis = listes.flat().sort(croissant);
|
||||
if (assis.join() !== suite(1, 12).join()) ecarts.push(`tour ${r + 1} : pas une partition`);
|
||||
listes.forEach((liste, i) => {
|
||||
const table = PLAN_PARFAIT_PETITE.tables[i];
|
||||
if (liste.length !== 3) ecarts.push(`tour ${r + 1}, table ${table} : ${liste.length} assis`);
|
||||
if (new Set(liste.map(groupeDe)).size !== liste.length) {
|
||||
ecarts.push(`tour ${r + 1}, table ${table} : collision`);
|
||||
}
|
||||
for (const x of liste) {
|
||||
visitees.get(x).add(table);
|
||||
for (const y of liste) {
|
||||
if (x === y) continue;
|
||||
rencontres.get(x).add(y);
|
||||
if (x < y) {
|
||||
if (paires.has(`${x}-${y}`)) ecarts.push(`paire ${x}-${y} répétée`);
|
||||
paires.add(`${x}-${y}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
for (const id of suite(1, 12)) {
|
||||
if (visitees.get(id).size !== 4) ecarts.push(`${id} : ${visitees.get(id).size} tables`);
|
||||
if (rencontres.get(id).size !== 8) ecarts.push(`${id} : ${rencontres.get(id).size} rencontres`);
|
||||
}
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('il s’indexe sur l’instance de la petite démonstration', () => {
|
||||
const instance = normaliser(configurationDe('petite'));
|
||||
assert.equal(indexerPlan(instance, PLAN_PARFAIT_PETITE).length, 12 * 4);
|
||||
});
|
||||
|
||||
test('il est figé jusque dans ses listes', () => {
|
||||
const { tables, tours, reserves } = PLAN_PARFAIT_PETITE;
|
||||
const objets = [PLAN_PARFAIT_PETITE, tables, tours, ...tours, ...tours.flat(), reserves, ...reserves];
|
||||
assert.ok(objets.every((objet) => Object.isFrozen(objet)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('engendrer : refus (§ 15.4, § 15.6)', () => {
|
||||
// Les quatre refus du § 14.10 — zéro participant, zéro table, aucun tour
|
||||
// témoin, réservoir de noms dépassé —, chacun sur un ou plusieurs cas, une
|
||||
// épreuve par cas. Un cas qui rompt deux règles à la fois attend la
|
||||
// première dans l'ordre du contrôle : participants, tables, réservoirs,
|
||||
// tour témoin.
|
||||
const nombreOrganisations = organisations().length;
|
||||
const nombrePatronymes = patronymes().length;
|
||||
// Des groupes de deux, juste assez pour dépasser les patronymes et pas les
|
||||
// organisations ; des tables de 8 en nombre suffisant pour un tour.
|
||||
const paires = Math.floor(nombrePatronymes / 2) + 1;
|
||||
// Un groupe d'une personne de plus qu'il n'y a d'organisations.
|
||||
const tropDeGroupes = Object.freeze(Array(nombreOrganisations + 1).fill(1));
|
||||
const organisationsDepassees = {
|
||||
code: 'DEMO_RESERVOIR',
|
||||
details: { reservoir: 'organisations', demandes: nombreOrganisations + 1, disponibles: nombreOrganisations },
|
||||
};
|
||||
// [cas, définition, refus attendu]
|
||||
const cas = [
|
||||
['zéro participant', { graine: 1, effectif: 0, capacites: [8, 8], tours: 4 },
|
||||
{ code: 'DEMO_SANS_PARTICIPANT', details: {} }],
|
||||
['zéro participant, tailles fixées', { graine: 1, tailles: [], capacites: [8, 8], tours: 4 },
|
||||
{ code: 'DEMO_SANS_PARTICIPANT', details: {} }],
|
||||
['zéro participant et zéro table : les participants d’abord',
|
||||
{ graine: 1, tailles: [], capacites: [], tours: 4 },
|
||||
{ code: 'DEMO_SANS_PARTICIPANT', details: {} }],
|
||||
['zéro table', { graine: 1, effectif: 260, capacites: [], tours: 4 },
|
||||
{ code: 'DEMO_SANS_TABLE', details: {} }],
|
||||
['zéro table et plus de groupes que d’organisations : les tables d’abord',
|
||||
{ graine: 1, tailles: tropDeGroupes, capacites: [], tours: 4 },
|
||||
{ code: 'DEMO_SANS_TABLE', details: {} }],
|
||||
['§ 15.2 : tables de 10, 1 et 1 pour quatre groupes de 3',
|
||||
{ graine: 1, tailles: [3, 3, 3, 3], capacites: [10, 1, 1], tours: 4 },
|
||||
{ code: 'DEMO_SANS_TOUR_TEMOIN', details: { graine: 1, raison: 'PLUS_GRANDE_CAPACITE' } }],
|
||||
['un groupe de 4 pour 3 tables', { graine: 1, tailles: [4, 2], capacites: [3, 3, 3], tours: 4 },
|
||||
{ code: 'DEMO_SANS_TOUR_TEMOIN', details: { graine: 1, raison: 'PLUS_GROS_GROUPE' } }],
|
||||
['inégalités tenues, aucun tour : tables de 6, 4 et 2',
|
||||
{ graine: 1, tailles: [3, 3, 3, 1, 1, 1], capacites: [6, 4, 2], tours: 4 },
|
||||
{ code: 'DEMO_SANS_TOUR_TEMOIN', details: { graine: 1, raison: 'CONSTRUCTION' } }],
|
||||
['plus de groupes que d’organisations',
|
||||
{ graine: 1, tailles: tropDeGroupes, capacites: tablesPour(nombreOrganisations + 1), tours: 4 },
|
||||
organisationsDepassees],
|
||||
['plus de groupes que d’organisations, sur une seule table : les réservoirs avant le tour témoin',
|
||||
{ graine: 1, tailles: tropDeGroupes, capacites: [8], tours: 4 },
|
||||
organisationsDepassees],
|
||||
['plus de membres que de patronymes',
|
||||
{ graine: 1, tailles: Array(paires).fill(2), capacites: tablesPour(2 * paires), tours: 4 },
|
||||
{ code: 'DEMO_RESERVOIR',
|
||||
details: { reservoir: 'patronymes', demandes: 2 * paires, disponibles: nombrePatronymes } }],
|
||||
];
|
||||
|
||||
test('les réservoirs laissent à chaque cas une seule règle de réservoir rompue', () => {
|
||||
assert.ok(nombreOrganisations + 1 <= nombrePatronymes, 'trop peu de patronymes pour le cas des organisations');
|
||||
assert.ok(paires <= nombreOrganisations, 'trop peu d’organisations pour le cas des patronymes');
|
||||
});
|
||||
|
||||
for (const [quoi, definition, attendu] of cas) {
|
||||
test(`${quoi} : ${attendu.code}`, () => {
|
||||
assert.deepEqual(refus(() => engendrer(definition)), attendu);
|
||||
});
|
||||
}
|
||||
|
||||
test('un réservoir employé jusqu’à son dernier nom suffit', () => {
|
||||
const parOrganisation = engendrer({
|
||||
graine: 1,
|
||||
tailles: Array(nombreOrganisations).fill(1),
|
||||
capacites: tablesPour(nombreOrganisations),
|
||||
tours: 4,
|
||||
});
|
||||
assert.equal(new Set(parOrganisation.participants.map((p) => p.appartenance)).size, nombreOrganisations);
|
||||
const tailles = Array(Math.floor(nombrePatronymes / 2)).fill(2);
|
||||
if (nombrePatronymes % 2 === 1) tailles.push(1);
|
||||
const parPatronyme = engendrer({ graine: 1, tailles, capacites: tablesPour(nombrePatronymes), tours: 4 });
|
||||
assert.equal(new Set(parPatronyme.participants.map((p) => p.nom)).size, nombrePatronymes);
|
||||
});
|
||||
|
||||
test('une définition qui donne à la fois un effectif et des tailles lève TypeError', () => {
|
||||
assert.throws(
|
||||
() => engendrer({ graine: 1, effectif: 12, tailles: [4, 4, 4], capacites: [3, 3, 3, 3], tours: 4 }),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('une taille fixée qui n’est pas un entier ≥ 1 lève RangeError', () => {
|
||||
// Sur deux tables de 8, chacune de ces définitions rend, sans le
|
||||
// contrôle, une configuration aux groupes faussés ou un refus étranger à
|
||||
// la taille fautive.
|
||||
const ecarts = [[-2, 2, 2], [1.5, 1.5], [0, 2], ['2', 2]]
|
||||
.map((tailles) => [tailles, refus(() => engendrer({ graine: 1, tailles, capacites: [8, 8], tours: 4 }))])
|
||||
.filter(([, issue]) => typeof issue !== 'string' || !issue.startsWith('RangeError : tailles'))
|
||||
.map(([tailles, issue]) => `${JSON.stringify(tailles)} : ${JSON.stringify(issue)}`);
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('moins de membres que de tables lève RangeError : un animateur par table', () => {
|
||||
assert.throws(
|
||||
() => engendrer({ graine: 1, tailles: [1, 1], capacites: [8, 8, 8], tours: 4 }),
|
||||
RangeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('autant de membres que de tables : chacun anime une table, sur les tours de la définition', () => {
|
||||
const { participants, reservations, tours } = engendrer({
|
||||
graine: 1,
|
||||
tailles: [1, 1, 1],
|
||||
capacites: [8, 8, 8],
|
||||
tours: 3,
|
||||
});
|
||||
assert.equal(tours, 3);
|
||||
assert.deepEqual(participants.map((p) => p.id), [1, 2, 3]);
|
||||
assert.deepEqual(reservations.map((r) => r.table), [1, 2, 3]);
|
||||
assert.deepEqual(reservations.map((r) => r.participant).sort(croissant), [1, 2, 3]);
|
||||
});
|
||||
});
|
||||
81
src/demo/noms.js
Normal file
81
src/demo/noms.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Réservoirs de noms des démonstrations (§ 15.6) : des prénoms courants, des
|
||||
// patronymes forgés par produit d'un préfixe et d'une terminaison, des noms
|
||||
// d'organisation formés d'une tête, qui dit le genre d'organisation, et d'une
|
||||
// racine forgée. Les produits se tirent sans remise : chaque personne reçoit
|
||||
// un patronyme distinct, chaque appartenance un libellé distinct, sans boucle
|
||||
// de rattrapage. Les fragments sont forgés pour ne désigner personne ;
|
||||
// catalogue.long.test.js refuse qu'un patronyme complet ou une racine
|
||||
// apparaisse ailleurs dans le projet.
|
||||
//
|
||||
// L'ordre de chaque liste fait partie du contrat des tirages (§ 15.5,
|
||||
// point 3) : un tirage désigne un rang, et changer un ordre change les
|
||||
// démonstrations. Les listes sont figées et en NFC.
|
||||
|
||||
/** Prénoms courants, tirés avec remise, un par personne. */
|
||||
export const PRENOMS = Object.freeze([
|
||||
'Marie', 'Jean', 'Louise', 'Pierre', 'Camille', 'Luc', 'Julie', 'Marc',
|
||||
'Sophie', 'Paul', 'Claire', 'Michel', 'Anne', 'Louis', 'Isabelle', 'François',
|
||||
'Catherine', 'Nicolas', 'Nathalie', 'Philippe', 'Émilie', 'Antoine', 'Chantal', 'Simon',
|
||||
'Geneviève', 'Gabriel', 'Hélène', 'Olivier', 'Véronique', 'Alexandre', 'Sylvie', 'Guillaume',
|
||||
'Caroline', 'Étienne', 'Lucie', 'Félix', 'Manon', 'Samuel', 'Chloé', 'Raphaël',
|
||||
'Léa', 'Thomas', 'Zoé', 'Hugo', 'Amina', 'Karim', 'Fatima', 'Youssef',
|
||||
]);
|
||||
|
||||
/** Débuts de patronyme forgés, chacun terminé par une consonne. */
|
||||
export const PREFIXES = Object.freeze([
|
||||
'Abrev', 'Bralm', 'Clov', 'Dulv', 'Esk', 'Fralv', 'Gorz', 'Hulv', 'Ivr', 'Jusv', 'Korv',
|
||||
'Lusk', 'Mirv', 'Nesk', 'Obr', 'Pruv', 'Quilm', 'Rozv', 'Selv', 'Tolv', 'Urv', 'Vosk',
|
||||
]);
|
||||
|
||||
/** Fins de patronyme, chacune ouverte par une voyelle. */
|
||||
export const TERMINAISONS = Object.freeze([
|
||||
'oubre', 'ard', 'ière', 'ault', 'ignac', 'eron', 'ouze', 'ivet',
|
||||
'esson', 'ande', 'ichon', 'eval', 'otte', 'ançon', 'eux', 'umet',
|
||||
]);
|
||||
|
||||
/** Têtes de nom d'organisation : le mot qui dit qu'il s'agit d'une organisation. */
|
||||
export const TETES = Object.freeze([
|
||||
'Ateliers', 'Coopérative', 'Verrerie', 'Fonderie', 'Imprimerie', 'Brasserie',
|
||||
'Menuiserie', 'Papeterie', 'Filature', 'Tannerie', 'Comptoir', 'Fabrique',
|
||||
]);
|
||||
|
||||
/** Racines forgées des noms d'organisation. */
|
||||
export const RACINES = Object.freeze([
|
||||
'Aubrenc', 'Belvarre', 'Cassorgue', 'Dravenne', 'Estrolle', 'Falvigne', 'Gravelune',
|
||||
'Hostrenne', 'Ivoranche', 'Jolvane', 'Kalvenne', 'Lavorneau', 'Morvalle', 'Nervanche',
|
||||
'Olbrecq', 'Pravette', 'Quimvaux', 'Rossolde', 'Sorbagne', 'Tavrenne', 'Ulvardes',
|
||||
'Vaubrenne', 'Wassorel', 'Xandrelle', 'Yzorne', 'Zelvaine',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Trois patronymes forgés longs, hors du produit des fragments, qui éprouvent
|
||||
* la mise en page d'un nom (§ 11.5, § 15.4) : le premier en capitales larges,
|
||||
* M et W ; le deuxième chargé de diacritiques ; le troisième très long.
|
||||
*/
|
||||
export const NOMS_LONGS = Object.freeze([
|
||||
'WAMMERWOLM-MAWWEMMEL',
|
||||
'Bérêçàuvïlle-Dœûmôntëÿ',
|
||||
'Vandergrolcquesmirtavelle-Ostrovaubelindigues',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Patronymes forgés : le produit PREFIXES × TERMINAISONS, préfixe par
|
||||
* préfixe, chaque préfixe suivi de chaque terminaison dans l'ordre. Une liste
|
||||
* neuve à chaque appel.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function patronymes() {
|
||||
return PREFIXES.flatMap((prefixe) => TERMINAISONS.map((terminaison) => prefixe + terminaison));
|
||||
}
|
||||
|
||||
/**
|
||||
* Noms d'organisation : le produit TETES × RACINES, tête par tête, la tête et
|
||||
* la racine séparées par une espace. Une liste neuve à chaque appel.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function organisations() {
|
||||
return TETES.flatMap((tete) => RACINES.map((racine) => `${tete} ${racine}`));
|
||||
}
|
||||
156
src/demo/noms.test.js
Normal file
156
src/demo/noms.test.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des réservoirs de noms du générateur (§ 15.4, § 15.6) : tailles,
|
||||
// unicité, ordre des produits, forme des fragments, noms longs. Ce fichier
|
||||
// n'écrit aucun nom forgé : il les lit dans noms.js, seul fichier où le
|
||||
// balayage de catalogue.long.test.js les admet.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import {
|
||||
NOMS_LONGS,
|
||||
PREFIXES,
|
||||
PRENOMS,
|
||||
RACINES,
|
||||
TERMINAISONS,
|
||||
TETES,
|
||||
organisations,
|
||||
patronymes,
|
||||
} from './noms.js';
|
||||
|
||||
// [nom de la liste, liste, taille minimale du plan]
|
||||
const LISTES = [
|
||||
['PRENOMS', PRENOMS, 40],
|
||||
['PREFIXES', PREFIXES, 20],
|
||||
['TERMINAISONS', TERMINAISONS, 16],
|
||||
['TETES', TETES, 10],
|
||||
['RACINES', RACINES, 26],
|
||||
];
|
||||
|
||||
const distincts = (liste) => new Set(liste).size;
|
||||
|
||||
// Un mot : une capitale suivie de minuscules, sans espace, trait d'union ni
|
||||
// chiffre. Un nom de cette forme se cherche en mot entier sans ambiguïté.
|
||||
const MOT = /^\p{Lu}\p{Ll}+$/u;
|
||||
|
||||
// Caractères d'une chaîne, en points de code.
|
||||
const caracteres = (texte) => [...texte];
|
||||
|
||||
describe('noms : listes', () => {
|
||||
test('chaque liste atteint sa taille minimale, sans doublon, figée et en NFC', () => {
|
||||
const ecarts = [];
|
||||
for (const [nom, liste, minimum] of LISTES) {
|
||||
if (liste.length < minimum) ecarts.push(`${nom} : ${liste.length} éléments, minimum ${minimum}`);
|
||||
if (distincts(liste) !== liste.length) ecarts.push(`${nom} : doublon`);
|
||||
if (!Object.isFrozen(liste)) ecarts.push(`${nom} : modifiable`);
|
||||
if (liste.some((element) => element !== element.normalize('NFC'))) {
|
||||
ecarts.push(`${nom} : élément hors NFC`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('les fragments forgés et les têtes ont la forme de mots', () => {
|
||||
assert.ok([PREFIXES, TERMINAISONS, RACINES, TETES].every((liste) => liste.length > 0), 'liste vide');
|
||||
const ecarts = [
|
||||
...PREFIXES.filter((p) => !/^\p{Lu}\p{Ll}*$/u.test(p)).map((p) => `préfixe ${p}`),
|
||||
...TERMINAISONS.filter((t) => !/^\p{Ll}+$/u.test(t)).map((t) => `terminaison ${t}`),
|
||||
...RACINES.filter((r) => !MOT.test(r)).map((r) => `racine ${r}`),
|
||||
...TETES.filter((t) => !MOT.test(t)).map((t) => `tête ${t}`),
|
||||
];
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noms : patronymes', () => {
|
||||
test('au moins 320 patronymes distincts, chacun un mot', () => {
|
||||
const liste = patronymes();
|
||||
assert.ok(liste.length >= 320, `${liste.length} patronymes`);
|
||||
assert.equal(distincts(liste), liste.length);
|
||||
assert.deepEqual(liste.filter((p) => !MOT.test(p)).length, 0);
|
||||
});
|
||||
|
||||
test('le produit PREFIXES × TERMINAISONS, préfixe par préfixe', () => {
|
||||
assert.ok(PREFIXES.length > 0 && TERMINAISONS.length > 0, 'liste vide');
|
||||
assert.deepEqual(
|
||||
patronymes(),
|
||||
PREFIXES.flatMap((prefixe) => TERMINAISONS.map((terminaison) => prefixe + terminaison)),
|
||||
);
|
||||
});
|
||||
|
||||
test('chaque appel rend une liste neuve', () => {
|
||||
const premiere = patronymes();
|
||||
assert.ok(premiere.length > 0, 'aucun patronyme');
|
||||
premiere.length = 0;
|
||||
assert.equal(patronymes().length, PREFIXES.length * TERMINAISONS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noms : organisations', () => {
|
||||
test('au moins 260 organisations distinctes', () => {
|
||||
const liste = organisations();
|
||||
assert.ok(liste.length >= 260, `${liste.length} organisations`);
|
||||
assert.equal(distincts(liste), liste.length);
|
||||
});
|
||||
|
||||
test('le produit TETES × RACINES, tête par tête, séparées par une espace', () => {
|
||||
assert.ok(TETES.length > 0 && RACINES.length > 0, 'liste vide');
|
||||
assert.deepEqual(
|
||||
organisations(),
|
||||
TETES.flatMap((tete) => RACINES.map((racine) => `${tete} ${racine}`)),
|
||||
);
|
||||
});
|
||||
|
||||
test('chaque appel rend une liste neuve', () => {
|
||||
const premiere = organisations();
|
||||
assert.ok(premiere.length > 0, 'aucune organisation');
|
||||
premiere.length = 0;
|
||||
assert.equal(organisations().length, TETES.length * RACINES.length);
|
||||
});
|
||||
|
||||
test('aucune racine n’est aussi un patronyme', () => {
|
||||
const noms = new Set(patronymes());
|
||||
assert.ok(noms.size > 0 && RACINES.length > 0, 'liste vide');
|
||||
assert.deepEqual(RACINES.filter((racine) => noms.has(racine)).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noms : noms longs (§ 15.4)', () => {
|
||||
test('trois noms distincts, figés, en NFC, hors du réservoir des patronymes et des racines', () => {
|
||||
assert.equal(NOMS_LONGS.length, 3);
|
||||
assert.equal(distincts(NOMS_LONGS), 3);
|
||||
assert.ok(Object.isFrozen(NOMS_LONGS));
|
||||
const reservoir = new Set([...patronymes(), ...RACINES]);
|
||||
assert.deepEqual(
|
||||
NOMS_LONGS.filter((nom) => reservoir.has(nom) || nom !== nom.normalize('NFC')).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('chacun compte au moins 20 caractères, plus que tout patronyme du réservoir', () => {
|
||||
const plusLong = Math.max(...patronymes().map((nom) => caracteres(nom).length));
|
||||
assert.ok(Number.isInteger(plusLong), 'aucun patronyme');
|
||||
assert.ok(NOMS_LONGS.length > 0, 'aucun nom long');
|
||||
const seuil = Math.max(20, plusLong + 1);
|
||||
// [rang du nom long, caractères] de chaque nom sous le seuil.
|
||||
const courts = NOMS_LONGS.map((nom, rang) => [rang, caracteres(nom).length]).filter(([, n]) => n < seuil);
|
||||
assert.deepEqual(courts, []);
|
||||
});
|
||||
|
||||
test('le premier est fait de glyphes larges : M et W pour au moins la moitié de ses caractères', () => {
|
||||
const tous = caracteres(NOMS_LONGS[0]);
|
||||
const larges = tous.filter((c) => /[MWmw]/u.test(c));
|
||||
assert.ok(larges.length > 0 && larges.length * 2 >= tous.length, `${larges.length} sur ${tous.length}`);
|
||||
});
|
||||
|
||||
test('le deuxième porte au moins cinq lettres à diacritique', () => {
|
||||
const marques = [...NOMS_LONGS[1].normalize('NFD')].filter((c) => /\p{M}/u.test(c));
|
||||
assert.ok(marques.length >= 5, `${marques.length} diacritiques`);
|
||||
});
|
||||
|
||||
test('le troisième est le plus long, et compte au moins 40 caractères', () => {
|
||||
const [large, accentue, long] = NOMS_LONGS.map((nom) => caracteres(nom).length);
|
||||
assert.ok(long >= 40, `${long} caractères`);
|
||||
assert.ok(long > large && long > accentue);
|
||||
});
|
||||
});
|
||||
143
src/demo/prng.js
Normal file
143
src/demo/prng.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le générateur pseudo-aléatoire unique du projet (§ 15.5, point 1) : PCG32,
|
||||
// variante XSH RR 64/32 de pcg_basic.c. L'état et l'incrément sont des entiers
|
||||
// de 64 bits, portés chacun en deux mots non signés de 32 bits, haut et bas.
|
||||
// Un pas fait état ← état × 6364136223846793005 + incrément, modulo 2^64 ;
|
||||
// chaque tirage rend 32 bits de l'état d'avant le pas, mélangés par la
|
||||
// fonction de sortie XSH RR. Tout le calcul tient en entiers exacts sur des
|
||||
// nombres flottants, sans BigInt ni lecture de l'environnement : même graine,
|
||||
// même flux, même suite, sur tout moteur d'exécution. L'ordre des tirages
|
||||
// fait partie du contrat (§ 15.5, point 3) : suivant consomme un tirage ;
|
||||
// borne, un par essai, rejets compris ; melanger, ceux de ses n − 1 appels à
|
||||
// borne.
|
||||
|
||||
// 6364136223846793005, le multiplicateur de pcg_basic.c, en deux mots.
|
||||
const MULTIPLICATEUR_HAUT = 0x5851f42d;
|
||||
const MULTIPLICATEUR_BAS = 0x4c957f2d;
|
||||
const DEUX_32 = 0x100000000;
|
||||
const MOT_MAX = 0xffffffff;
|
||||
|
||||
/**
|
||||
* Flux (initseq de pcg32_srandom_r) réservés, un par usage : à graine égale,
|
||||
* deux usages tirent deux suites différentes.
|
||||
*/
|
||||
export const FLUX = Object.freeze({ GRAINES: 1, RECHERCHE: 2, DEMO: 3 });
|
||||
|
||||
// Produit des 64 bits bas de (ahi:alo) × (bhi:blo). Chaque colonne reste sous
|
||||
// 2^35, donc exacte en double ; la retenue se propage par division entière.
|
||||
function mul64(ahi, alo, bhi, blo) {
|
||||
const a0 = alo & 0xffff, a1 = alo >>> 16, a2 = ahi & 0xffff, a3 = ahi >>> 16;
|
||||
const b0 = blo & 0xffff, b1 = blo >>> 16, b2 = bhi & 0xffff, b3 = bhi >>> 16;
|
||||
let t = a0 * b0;
|
||||
const r0 = t % 65536; t = Math.floor(t / 65536) + a0 * b1 + a1 * b0;
|
||||
const r1 = t % 65536; t = Math.floor(t / 65536) + a0 * b2 + a1 * b1 + a2 * b0;
|
||||
const r2 = t % 65536; t = Math.floor(t / 65536) + a0 * b3 + a1 * b2 + a2 * b1 + a3 * b0;
|
||||
const r3 = t % 65536;
|
||||
return [(r3 * 65536 + r2) >>> 0, (r1 * 65536 + r0) >>> 0];
|
||||
}
|
||||
|
||||
// Écrit une valeur reçue dans un message d'erreur : un nombre tel quel, toute
|
||||
// autre valeur suivie de son type.
|
||||
function decrire(valeur) {
|
||||
return typeof valeur === 'number' ? String(valeur) : `${String(valeur)} (${typeof valeur})`;
|
||||
}
|
||||
|
||||
// Lève une RangeError nommant le paramètre quand la valeur n'est pas un
|
||||
// entier de min à max.
|
||||
function exigerEntier(nom, valeur, min, max) {
|
||||
if (!Number.isInteger(valeur) || valeur < min || valeur > max) {
|
||||
throw new RangeError(`${nom} : entier de ${min} à ${max} attendu, reçu ${decrire(valeur)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Générateur PCG32 (XSH RR 64/32), état 64 bits porté en deux mots de 32 bits.
|
||||
* Ensemencement identique à pcg32_srandom_r(initstate, initseq).
|
||||
*
|
||||
* L'objet rendu porte :
|
||||
* - suivant() : entier non signé de 32 bits, pcg32_random_r ;
|
||||
* - borne(n) : entier uniforme de [0, n), n entier de 1 à 2^32 − 1, par le
|
||||
* rejet de pcg32_boundedrand_r ;
|
||||
* - melanger(tab) : mélange tab en place et le rend, par la boucle de
|
||||
* pcg32-demo.c — pour i de n à 2, échange l'élément borne(i) et
|
||||
* l'élément i − 1.
|
||||
*
|
||||
* @param {number} graine initstate, entier de 0 à 2^32 − 1
|
||||
* @param {number} flux initseq, entier de 0 à 2^32 − 1 (voir FLUX)
|
||||
* @returns {{suivant: function(): number, borne: function(number): number,
|
||||
* melanger: function(Array|Int32Array): (Array|Int32Array)}}
|
||||
* @throws {RangeError} graine ou flux hors de cet intervalle ; borne(n) pour
|
||||
* n hors du sien
|
||||
*/
|
||||
export function creerPcg32(graine, flux) {
|
||||
exigerEntier('graine', graine, 0, MOT_MAX);
|
||||
exigerEntier('flux', flux, 0, MOT_MAX);
|
||||
|
||||
// Incrément impair (flux << 1) | 1 : le bit 31 du flux passe au mot haut.
|
||||
const incrementHaut = flux >>> 31;
|
||||
const incrementBas = ((flux << 1) | 1) >>> 0;
|
||||
let haut = 0;
|
||||
let bas = 0;
|
||||
|
||||
// état ← état + (h:b), modulo 2^64. La somme des mots bas, sous 2^33, est
|
||||
// exacte ; sa retenue passe au mot haut.
|
||||
function ajouter(h, b) {
|
||||
const somme = bas + b;
|
||||
bas = somme >>> 0;
|
||||
haut = (haut + h + (somme >= DEUX_32 ? 1 : 0)) >>> 0;
|
||||
}
|
||||
|
||||
function suivant() {
|
||||
const ancienHaut = haut;
|
||||
const ancienBas = bas;
|
||||
[haut, bas] = mul64(haut, bas, MULTIPLICATEUR_HAUT, MULTIPLICATEUR_BAS);
|
||||
ajouter(incrementHaut, incrementBas);
|
||||
// Sortie XSH RR de l'état d'avant le pas : les bits 27 à 58 de
|
||||
// état ^ (état >> 18), tournés à droite du nombre que forment les 5 bits
|
||||
// hauts de l'état.
|
||||
const xHaut = ancienHaut ^ (ancienHaut >>> 18);
|
||||
const xBas = ancienBas ^ ((ancienBas >>> 18) | (ancienHaut << 14));
|
||||
const melange = (xBas >>> 27) | (xHaut << 5);
|
||||
const rotation = ancienHaut >>> 27;
|
||||
return ((melange >>> rotation) | (melange << (-rotation & 31))) >>> 0;
|
||||
}
|
||||
|
||||
// Les tirages sous le seuil (2^32 − n) mod n sont rejetés : les
|
||||
// 2^32 − seuil valeurs restantes, multiple de n, se répartissent également
|
||||
// entre les n restes. La garde précède le seuil, qui vaudrait NaN pour
|
||||
// n = 0. La boucle tire tant que r < seuil : pour un seuil entier, elle
|
||||
// accepte les mêmes tirages que le « r >= seuil » de pcg32_boundedrand_r ;
|
||||
// pour un seuil NaN, elle rend après un tirage, là où cette forme ne
|
||||
// rendrait jamais. Une garde affaiblie fait ainsi échouer l'épreuve des
|
||||
// bornes invalides au lieu de la figer : une boucle synchrone sans fin
|
||||
// échappe au délai du lanceur.
|
||||
function borne(n) {
|
||||
exigerEntier('borne', n, 1, MOT_MAX);
|
||||
const seuil = (DEUX_32 - n) % n;
|
||||
let r;
|
||||
do {
|
||||
r = suivant();
|
||||
} while (r < seuil);
|
||||
return r % n;
|
||||
}
|
||||
|
||||
// Un tableau de moins de deux éléments ne consomme aucun tirage.
|
||||
function melanger(tab) {
|
||||
for (let i = tab.length; i > 1; i -= 1) {
|
||||
const j = borne(i);
|
||||
const element = tab[j];
|
||||
tab[j] = tab[i - 1];
|
||||
tab[i - 1] = element;
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
// pcg32_srandom_r : état nul, un pas, ajout de la graine, un pas.
|
||||
suivant();
|
||||
ajouter(0, graine);
|
||||
suivant();
|
||||
|
||||
return { suivant, borne, melanger };
|
||||
}
|
||||
283
src/demo/prng.test.js
Normal file
283
src/demo/prng.test.js
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le vecteur de PCG32 vient de l'implémentation de référence, hors de ce
|
||||
// dépôt : c'est un oracle, non un témoin (§ 14.11). Dépôt
|
||||
// https://github.com/imneme/pcg-c-basic, commit
|
||||
// bc39cd76ac3d541e618606bcc6e1e5ba5e5e6aa3 : pcg32-demo.c, compilé avec
|
||||
// pcg_basic.c et exécuté sans argument, ensemence par
|
||||
// pcg32_srandom_r(&rng, 42u, 54u) et imprime cinq tours de tirages.
|
||||
// scripts/oracle/pcg32_reference.sh refait ces gestes ; TOUR_1 recopie le
|
||||
// premier tour de sa sortie.
|
||||
import assert from 'node:assert/strict';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { FLUX, creerPcg32 } from './prng.js';
|
||||
|
||||
const DEUX_32 = 2 ** 32;
|
||||
|
||||
// Premier tour de la démo, ligne par ligne, tel qu'elle l'imprime. Ses tirages
|
||||
// se suivent dans cet ordre : six sorties de pcg32_random_r ; 65 pièces,
|
||||
// pcg32_boundedrand_r(&rng, 2), imprimées H pour 1 et T pour 0 ; 33 dés,
|
||||
// pcg32_boundedrand_r(&rng, 6) + 1 ; puis le mélange d'un paquet de 52 cartes
|
||||
// numérotées de 0 à 51. La carte c s'imprime par sa valeur, de rang c / 4 en
|
||||
// division entière dans « A23456789TJQK », puis sa couleur, de rang c % 4 dans
|
||||
// « hcds ». La démo coupe le paquet en lignes de 22 cartes, jointes ici par
|
||||
// une espace.
|
||||
const TOUR_1 = {
|
||||
sorties: '0xa15c02b7 0x7b47f409 0xba1d3330 0x83d2f293 0xbfa4784b 0xcbed606e',
|
||||
pieces: 'HHTTTHTHHHTHTTTHHHHHTTTHHHTHTHTHTTHTTTHHHHHHTTTTHHTTTTTHTTTTTTTHT',
|
||||
des: '3 4 1 1 2 2 3 2 4 3 2 4 3 3 5 2 3 1 3 1 5 1 4 1 5 6 4 6 6 2 6 3 3',
|
||||
cartes:
|
||||
'Qd Ks 6d 3s 3d 4c 3h Td Kc 5c Jh Kd Jd As 4s 4h Ad Th Ac Jc 7s Qs ' +
|
||||
'2s 7h Kh 2d 6c Ah 4d Qh 9h 6s 5s 2c 9c Ts 8d 9s 3c 8c Js 5d 2h 6h ' +
|
||||
'7d 8s 9d 5h 8h Qc 7c Tc',
|
||||
};
|
||||
|
||||
// Une sortie de 32 bits, écrite comme la démo l'imprime (« 0x%08x »).
|
||||
const hexa = (x) => `0x${x.toString(16).padStart(8, '0')}`;
|
||||
|
||||
// Une carte, écrite comme la démo l'imprime.
|
||||
const carte = (c) => 'A23456789TJQK'[Math.floor(c / 4)] + 'hcds'[c % 4];
|
||||
|
||||
// Tire le premier tour de la démo, dans son ordre, et l'écrit comme elle.
|
||||
function premierTour(rng) {
|
||||
const sorties = Array.from({ length: 6 }, () => hexa(rng.suivant())).join(' ');
|
||||
const pieces = Array.from(TOUR_1.pieces, () => (rng.borne(2) ? 'H' : 'T')).join('');
|
||||
const des = TOUR_1.des.split(' ').map(() => rng.borne(6) + 1).join(' ');
|
||||
const paquet = Array.from({ length: 52 }, (_, c) => c);
|
||||
rng.melanger(paquet);
|
||||
return { sorties, pieces, des, cartes: paquet.map(carte).join(' ') };
|
||||
}
|
||||
|
||||
// Transcription de pcg32_srandom_r et pcg32_random_r en BigInt, modulo 2^64 :
|
||||
// elle ne partage rien de l'arithmétique par mots de 32 bits de prng.js. Rend
|
||||
// la fonction de tirage, déjà ensemencée.
|
||||
const MASQUE_64 = (1n << 64n) - 1n;
|
||||
const MASQUE_32 = (1n << 32n) - 1n;
|
||||
function transcriptionBigInt(graine, flux) {
|
||||
const increment = ((BigInt(flux) << 1n) | 1n) & MASQUE_64;
|
||||
let etat = 0n;
|
||||
function tirer() {
|
||||
const ancien = etat;
|
||||
etat = (ancien * 6364136223846793005n + increment) & MASQUE_64;
|
||||
const melange = (((ancien >> 18n) ^ ancien) >> 27n) & MASQUE_32;
|
||||
const rotation = ancien >> 59n;
|
||||
return Number(((melange >> rotation) | (melange << ((32n - rotation) & 31n))) & MASQUE_32);
|
||||
}
|
||||
tirer();
|
||||
etat = (etat + BigInt(graine)) & MASQUE_64;
|
||||
tirer();
|
||||
return tirer;
|
||||
}
|
||||
|
||||
// Les valeurs que l'appel admet, ou refuse par une autre erreur qu'une
|
||||
// RangeError. Recueillies puis comparées à [], elles sont toutes nommées en un
|
||||
// seul échec.
|
||||
function admisesATort(valeurs, appel) {
|
||||
return valeurs.filter((valeur) => {
|
||||
try {
|
||||
appel(valeur);
|
||||
return true;
|
||||
} catch (erreur) {
|
||||
return !(erreur instanceof RangeError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('PCG32', () => {
|
||||
test('rejoue le premier tour de la démo de référence (graine 42, flux 54)', () => {
|
||||
assert.deepEqual(premierTour(creerPcg32(42, 54)), TOUR_1);
|
||||
});
|
||||
|
||||
// Le vecteur n'exerce ni le mot haut de l'incrément, nul pour le flux 54,
|
||||
// ni la retenue des additions sur 64 bits : celle de l'incrément 109 ne
|
||||
// survient que pour un mot bas de produit d'au moins 2^32 − 109, une fois
|
||||
// sur quarante millions de pas ; celle de la graine 42, ajoutée à l'état
|
||||
// 109, jamais. Un flux d'au moins 2^31 porte le mot haut. Le flux 2^32 − 1
|
||||
// déclenche la retenue de l'incrément à presque chaque pas, la graine
|
||||
// 2^32 − 1 sous le flux 0 celle de l'ensemencement seule, et les deux
|
||||
// ensemble les déclenchent toutes deux. Sur ces cas, le test confronte
|
||||
// prng.js à la transcription BigInt. C'est une contre-vérification de la
|
||||
// décomposition en mots de 32 bits, non un oracle (§ 14.11) : la
|
||||
// transcription ne s'ancre que sur les six sorties du vecteur, qui
|
||||
// n'exercent pas ces chemins, et elle suit la même lecture de pcg_basic.c
|
||||
// que prng.js. Une erreur de lecture commune aux deux, comme un incrément
|
||||
// calculé sur 32 bits, y resterait verte.
|
||||
test('concorde avec une transcription BigInt de pcg_basic.c aux graines et flux extrêmes', () => {
|
||||
const ancre = transcriptionBigInt(42, 54);
|
||||
assert.equal(Array.from({ length: 6 }, () => hexa(ancre())).join(' '), TOUR_1.sorties);
|
||||
|
||||
const cas = [
|
||||
[0, 0],
|
||||
[0xffffffff, 0],
|
||||
[0, 0x80000000],
|
||||
[0x80000000, 0x80000000],
|
||||
[0, 0xffffffff],
|
||||
[0xffffffff, 0xffffffff],
|
||||
[42, FLUX.GRAINES],
|
||||
[42, FLUX.RECHERCHE],
|
||||
[42, FLUX.DEMO],
|
||||
];
|
||||
const ecarts = [];
|
||||
for (const [graine, flux] of cas) {
|
||||
const rng = creerPcg32(graine, flux);
|
||||
const reference = transcriptionBigInt(graine, flux);
|
||||
for (let tirage = 0; tirage < 1000; tirage += 1) {
|
||||
const [obtenu, attendu] = [rng.suivant(), reference()];
|
||||
if (obtenu !== attendu) {
|
||||
ecarts.push(`graine ${graine}, flux ${flux}, tirage ${tirage} : ${obtenu} au lieu de ${attendu}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
// Chaque générateur porte son propre état et son propre incrément. Deux
|
||||
// générateurs vivants à la fois, sur deux flux de FLUX, rendent chacun la
|
||||
// suite qu'il rendrait seul. Les tirages alternent : un état ou un
|
||||
// incrément rangé hors de l'instance, donc partagé, décale alors les deux
|
||||
// suites. Les deux flux donnent deux suites distinctes, sans quoi un
|
||||
// incrément partagé ne changerait aucun tirage.
|
||||
test('deux générateurs de flux différents, tirés en alternance, gardent chacun sa suite', () => {
|
||||
const seul = (flux) => {
|
||||
const rng = creerPcg32(42, flux);
|
||||
return Array.from({ length: 50 }, () => rng.suivant());
|
||||
};
|
||||
const attendu = [seul(FLUX.GRAINES), seul(FLUX.RECHERCHE)];
|
||||
assert.notDeepEqual(attendu[0], attendu[1]);
|
||||
|
||||
const a = creerPcg32(42, FLUX.GRAINES);
|
||||
const b = creerPcg32(42, FLUX.RECHERCHE);
|
||||
const obtenu = [[], []];
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
obtenu[0].push(a.suivant());
|
||||
obtenu[1].push(b.suivant());
|
||||
}
|
||||
assert.deepEqual(obtenu, attendu);
|
||||
});
|
||||
|
||||
// Les bornes du vecteur, 2, 6 puis 52 à 2, ont un seuil sous 2^6 : leur
|
||||
// rejet ne survient pratiquement jamais. Le test prend une borne de chaque
|
||||
// côté de 2^31. Pour n = 2^31 + 1, 2^32 − n est sous n et le modulo n'agit
|
||||
// pas : le seuil vaut 2^31 − 1, et près d'un tirage sur deux est rejeté.
|
||||
// Pour n = 2^30 + 1, il agit : le seuil vaut 2^32 − 3n = 2^30 − 3, et un
|
||||
// tirage sur quatre environ est rejeté. Une négation sur 32 bits signés,
|
||||
// ~n + 1, ne vaut 2^32 − n qu'au-dessus de 2^31 : le seuil qu'elle donne
|
||||
// n'est faux que sous 2^31, et seule la seconde borne le voit. borne
|
||||
// consomme un tirage par rejet et un par résultat : les résultats attendus
|
||||
// sont les tirages acceptés d'un jumeau, réduits modulo n, dans l'ordre.
|
||||
test('borne rejette les tirages sous le seuil (2^32 − n) mod n, comme pcg32_boundedrand_r', () => {
|
||||
const jumeau = creerPcg32(42, 54);
|
||||
const tirages = Array.from({ length: 200 }, () => jumeau.suivant());
|
||||
for (const [n, seuil] of [
|
||||
[2 ** 31 + 1, 2 ** 31 - 1],
|
||||
[2 ** 30 + 1, 2 ** 30 - 3],
|
||||
]) {
|
||||
assert.equal((DEUX_32 - n) % n, seuil);
|
||||
const acceptes = tirages.filter((r) => r >= seuil);
|
||||
assert.ok(acceptes.length > 0, `n = ${n} : aucun tirage accepté`);
|
||||
assert.ok(acceptes.length < tirages.length, `n = ${n} : aucun tirage rejeté, le rejet reste inexercé`);
|
||||
|
||||
const rng = creerPcg32(42, 54);
|
||||
assert.deepEqual(
|
||||
{ n, resultats: acceptes.map(() => rng.borne(n)) },
|
||||
{ n, resultats: acceptes.map((r) => r % n) },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Le deuxième tirage du vecteur, 0x7b47f409, est exactement le seuil de
|
||||
// n = 2^32 − 0x7b47f409 : n dépasse 2^31, donc (2^32 − n) mod n = 2^32 − n.
|
||||
// Accepté, il revient tel quel, puisqu'il est sous n.
|
||||
test('borne accepte un tirage égal au seuil', () => {
|
||||
const rng = creerPcg32(42, 54);
|
||||
rng.suivant();
|
||||
assert.equal(rng.borne(DEUX_32 - 0x7b47f409), 0x7b47f409);
|
||||
});
|
||||
|
||||
// Pour n = 2^32 − 0x7b47f409 − 1, le seuil vaut de même 2^32 − n, soit
|
||||
// 0x7b47f40a, un de plus que le deuxième tirage du vecteur : borne rejette
|
||||
// celui-ci et rend le troisième, 0xba1d3330, réduit modulo n. Avec
|
||||
// « borne accepte un tirage égal au seuil », ce test fixe le seuil des deux
|
||||
// côtés : un seuil calculé sur 2^32 − 1 au lieu de 2^32 accepterait le
|
||||
// deuxième tirage et décalerait tous les suivants.
|
||||
test('borne rejette un tirage juste sous le seuil', () => {
|
||||
const rng = creerPcg32(42, 54);
|
||||
rng.suivant();
|
||||
const n = DEUX_32 - 0x7b47f409 - 1;
|
||||
assert.equal(rng.borne(n), 0xba1d3330 % n);
|
||||
});
|
||||
|
||||
test("borne(1) rend toujours 0, au prix d'un tirage comme pcg32_boundedrand_r", () => {
|
||||
const rng = creerPcg32(42, 54);
|
||||
const jumeau = creerPcg32(42, 54);
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
assert.equal(rng.borne(1), 0);
|
||||
jumeau.suivant();
|
||||
}
|
||||
assert.equal(rng.suivant(), jumeau.suivant());
|
||||
});
|
||||
|
||||
// 2^32 − 1 est la plus grande borne de pcg32_boundedrand_r, dont le
|
||||
// paramètre est un uint32_t. Son seuil vaut 1 : le premier tirage du
|
||||
// vecteur, 0xa15c02b7, passe et revient tel quel, puisqu'il est sous n.
|
||||
test('borne admet 2^32 − 1, la plus grande borne de pcg32_boundedrand_r', () => {
|
||||
assert.equal(creerPcg32(42, 54).borne(DEUX_32 - 1), 0xa15c02b7);
|
||||
});
|
||||
|
||||
test('borne refuse 0, et toute borne hors des entiers de 1 à 2^32 − 1', () => {
|
||||
const rng = creerPcg32(42, 54);
|
||||
const invalides = [0, -1, 1.5, DEUX_32, NaN, Infinity, '6', 6n, undefined];
|
||||
assert.deepEqual(admisesATort(invalides, (n) => rng.borne(n)), []);
|
||||
});
|
||||
|
||||
// La boucle de la démo tire borne(i) pour i de n à 2 : un tableau de moins
|
||||
// de deux éléments ne consomme aucun tirage. Un tirage de trop en fin de
|
||||
// mélange échappe au vecteur, dont le paquet clôt le premier tour ; le
|
||||
// jumeau, qui tire les mêmes bornes, le voit au tirage suivant.
|
||||
test('melanger suit la boucle de la démo, en place : borne(i) pour i de n à 2', () => {
|
||||
const rng = creerPcg32(42, 54);
|
||||
const jumeau = creerPcg32(42, 54);
|
||||
const tab = [10, 20, 30, 40, 50];
|
||||
assert.equal(rng.melanger(tab), tab);
|
||||
for (let i = tab.length; i > 1; i -= 1) jumeau.borne(i);
|
||||
rng.melanger([]);
|
||||
rng.melanger([7]);
|
||||
assert.equal(rng.suivant(), jumeau.suivant());
|
||||
assert.deepEqual([...tab].sort((a, b) => a - b), [10, 20, 30, 40, 50]);
|
||||
});
|
||||
|
||||
// Une graine hors de l'intervalle, tronquée en silence, désignerait une
|
||||
// autre graine ; un flux omis deviendrait le flux 0.
|
||||
test('creerPcg32 refuse une graine ou un flux hors des entiers de 0 à 2^32 − 1', () => {
|
||||
const invalides = [-1, 1.5, DEUX_32, NaN, Infinity, '42', 42n, undefined];
|
||||
assert.deepEqual(admisesATort(invalides, (graine) => creerPcg32(graine, 54)), []);
|
||||
assert.deepEqual(admisesATort(invalides, (flux) => creerPcg32(42, flux)), []);
|
||||
});
|
||||
|
||||
// Témoin, non oracle : changer un flux change chaque graine dérivée et
|
||||
// chaque démonstration. Le test fait de ce changement un geste conscient.
|
||||
test('FLUX attribue les flux 1, 2 et 3, figés', () => {
|
||||
assert.deepEqual(FLUX, { GRAINES: 1, RECHERCHE: 2, DEMO: 3 });
|
||||
assert.ok(Object.isFrozen(FLUX));
|
||||
});
|
||||
|
||||
// La durée est affichée, jamais affirmée : la cible est sous une seconde,
|
||||
// et une machine chargée la dépasse sans que le code soit en cause
|
||||
// (§ 14.14). L'assertion porte sur les tirages, entiers non signés de 32
|
||||
// bits ; x >>> 0 diffère de x pour toute autre valeur.
|
||||
test('1 000 000 tirages : durée affichée, cible sous une seconde', () => {
|
||||
const rng = creerPcg32(42, FLUX.DEMO);
|
||||
let horsMot = 0;
|
||||
const debut = performance.now();
|
||||
for (let i = 0; i < 1_000_000; i += 1) {
|
||||
const x = rng.suivant();
|
||||
if (x !== x >>> 0) horsMot += 1;
|
||||
}
|
||||
const duree = performance.now() - debut;
|
||||
console.log(`PCG32 : 1 000 000 tirages en ${duree.toFixed(1)} ms (cible : moins de 1 000 ms)`);
|
||||
assert.equal(horsMot, 0);
|
||||
});
|
||||
});
|
||||
242
src/interface/App.navigateur.test.js
Normal file
242
src/interface/App.navigateur.test.js
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuve de rendu du bandeau d'application (§ 8.4, § 18.6), dans un vrai
|
||||
// moteur de rendu. Le test interroge le rendu, jamais la présence d'une
|
||||
// classe (§ 14.2) : la visibilité de chaque élément, la boîte où la mise en
|
||||
// page le pose et l'élément qu'atteint le centre de cette boîte, le style
|
||||
// calculé, le texte, le titre de la fenêtre. Il ne lit aucun pixel : le
|
||||
// contraste se calcule sur les couleurs calculées, qui ne sont les couleurs
|
||||
// peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une
|
||||
// épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de
|
||||
// visibilité, de contraste et d'opacité passent par les deux thèmes : le
|
||||
// protocole de débogage de Chromium (CDP) émule prefers-color-scheme.
|
||||
//
|
||||
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
|
||||
// Vite le remplace par un module qui lève une erreur à tout accès. Il emploie
|
||||
// expect, et prend test et describe chez Vitest plutôt que dans
|
||||
// test/lanceur.js, dont le repli vers node:test ne vaut que pour la série node.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { cdp } from 'vitest/browser';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
|
||||
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
|
||||
const THEMES = ['light', 'dark'];
|
||||
|
||||
// Composantes [r, g, b, a] d'une couleur calculée. Chromium la rend sous la
|
||||
// forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » quand la source est une
|
||||
// couleur sRGB ; toute autre forme fait échouer l'épreuve plutôt que de la
|
||||
// laisser passer sans mesure.
|
||||
function composantes(couleur) {
|
||||
const m = /^rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)$/.exec(couleur);
|
||||
if (m === null) {
|
||||
throw new Error(`couleur calculée illisible : ${couleur}`);
|
||||
}
|
||||
const alpha = m[4] === undefined ? 1 : Number(m[4]);
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
|
||||
}
|
||||
|
||||
// Luminance relative d'une couleur sRGB, au sens des WCAG.
|
||||
function luminance([r, g, b]) {
|
||||
const lineaire = (c) => {
|
||||
const s = c / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * lineaire(r) + 0.7152 * lineaire(g) + 0.0722 * lineaire(b);
|
||||
}
|
||||
|
||||
function contraste(a, b) {
|
||||
const la = luminance(a);
|
||||
const lb = luminance(b);
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||
}
|
||||
|
||||
// L'élément, puis chacun de ses ancêtres jusqu'à la racine du document.
|
||||
function ascendance(element) {
|
||||
const chaine = [];
|
||||
for (let n = element; n !== null; n = n.parentElement) {
|
||||
chaine.push(n);
|
||||
}
|
||||
return chaine;
|
||||
}
|
||||
|
||||
// Fond peint sous un élément : le premier fond non transparent de son
|
||||
// ascendance, ou null.
|
||||
function fondPeint(element) {
|
||||
for (const n of ascendance(element)) {
|
||||
const fond = composantes(getComputedStyle(n).backgroundColor);
|
||||
if (fond[3] !== 0) {
|
||||
return fond;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Éléments du sous-arbre qui portent eux-mêmes un nœud de texte non vide.
|
||||
function porteursDeTexte(racine) {
|
||||
return [racine, ...racine.querySelectorAll('*')].filter((el) =>
|
||||
[...el.childNodes].some(
|
||||
(n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() !== '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Nom lisible d'un élément dans un message d'échec.
|
||||
function designation(element) {
|
||||
const classe = element.getAttribute('class');
|
||||
return classe ? `${element.localName}.${classe}` : element.localName;
|
||||
}
|
||||
|
||||
// Exécute mesure() sous le thème demandé et rend sa valeur. L'émulation est
|
||||
// levée ensuite, même quand mesure() échoue : le test suivant retrouve la
|
||||
// préférence propre du navigateur.
|
||||
async function sousTheme(theme, mesure) {
|
||||
await cdp().send('Emulation.setEmulatedMedia', {
|
||||
features: [{ name: 'prefers-color-scheme', value: theme }],
|
||||
});
|
||||
try {
|
||||
expect(matchMedia('(prefers-color-scheme: dark)').matches, theme).toBe(
|
||||
theme === 'dark',
|
||||
);
|
||||
return mesure();
|
||||
} finally {
|
||||
await cdp().send('Emulation.setEmulatedMedia', { features: [] });
|
||||
}
|
||||
}
|
||||
|
||||
let composant;
|
||||
|
||||
beforeEach(() => {
|
||||
composant = mount(App, { target: document.body });
|
||||
flushSync();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unmount(composant);
|
||||
});
|
||||
|
||||
function bandeau() {
|
||||
return document.body.querySelector('header.bandeau');
|
||||
}
|
||||
|
||||
// Le bandeau, puis chacun des éléments qui y portent un texte.
|
||||
function bandeauEtTextes() {
|
||||
return [...new Set([bandeau(), ...porteursDeTexte(bandeau())])];
|
||||
}
|
||||
|
||||
describe("bandeau d'application", () => {
|
||||
test('existe et porte la version affichée', () => {
|
||||
expect(bandeau()).not.toBeNull();
|
||||
expect(bandeau().textContent).toContain(VERSION.affichee);
|
||||
});
|
||||
|
||||
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre, dans les deux thèmes (§ 14.2, § 18.6)', async () => {
|
||||
// Le texte, le titre et le style calculé se relisent sur un élément qui
|
||||
// ne peint rien. L'épreuve interroge donc la mise en page : chaque élément
|
||||
// est visible pour checkVisibility (display, visibility, opacité nulle,
|
||||
// content-visibility), sa boîte est non vide et tient dans la fenêtre, et
|
||||
// le point central de cette boîte atteint l'élément ou l'un de ses
|
||||
// descendants — un élément recouvert, ou rogné par un ancêtre, laisse le
|
||||
// point à un autre. elementFromPoint suit l'atteinte du pointeur : un
|
||||
// élément en pointer-events: none n'y répond pas, et l'épreuve le refuse.
|
||||
for (const theme of THEMES) {
|
||||
await sousTheme(theme, () => {
|
||||
const elements = bandeauEtTextes();
|
||||
expect(elements.length).toBeGreaterThan(1);
|
||||
for (const element of elements) {
|
||||
const lieu = `${theme} : ${designation(element)}`;
|
||||
expect(
|
||||
element.checkVisibility({ opacityProperty: true, visibilityProperty: true }),
|
||||
`${lieu} : checkVisibility`,
|
||||
).toBe(true);
|
||||
const boite = element.getBoundingClientRect();
|
||||
expect(boite.width, `${lieu} : largeur`).toBeGreaterThan(0);
|
||||
expect(boite.height, `${lieu} : hauteur`).toBeGreaterThan(0);
|
||||
expect(boite.left, `${lieu} : bord gauche`).toBeGreaterThanOrEqual(0);
|
||||
expect(boite.top, `${lieu} : bord haut`).toBeGreaterThanOrEqual(0);
|
||||
expect(boite.right, `${lieu} : bord droit`).toBeLessThanOrEqual(innerWidth);
|
||||
expect(boite.bottom, `${lieu} : bord bas`).toBeLessThanOrEqual(innerHeight);
|
||||
const atteint = document.elementFromPoint(
|
||||
boite.left + boite.width / 2,
|
||||
boite.top + boite.height / 2,
|
||||
);
|
||||
expect(
|
||||
element.contains(atteint),
|
||||
`${lieu} : le centre de sa boîte atteint ${atteint === null ? 'aucun élément' : designation(atteint)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('porte la provenance de la construction', () => {
|
||||
expect(import.meta.env.MODE).toBe('test');
|
||||
expect(bandeau().textContent).toContain('épreuve');
|
||||
});
|
||||
|
||||
test('le titre de la fenêtre porte le nom et la version affichée', () => {
|
||||
expect(document.title).toBe(
|
||||
`Gestion table tournante Libre — ${VERSION.affichee}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('le texte et le fond du bandeau ont une couleur calculée', () => {
|
||||
const style = getComputedStyle(bandeau());
|
||||
expect(style.color).not.toBe(TRANSPARENT);
|
||||
expect(style.backgroundColor).not.toBe(TRANSPARENT);
|
||||
});
|
||||
|
||||
test('chaque texte du bandeau tient 4,5:1 sur un fond opaque, dans les deux thèmes (§ 14.5)', async () => {
|
||||
const couleursParTheme = [];
|
||||
for (const theme of THEMES) {
|
||||
const couleurs = await sousTheme(theme, () => {
|
||||
const textes = porteursDeTexte(bandeau());
|
||||
expect(textes.length).toBeGreaterThan(0);
|
||||
for (const element of textes) {
|
||||
const lieu = `${theme} : ${designation(element)}`;
|
||||
const texte = composantes(getComputedStyle(element).color);
|
||||
const fond = fondPeint(element);
|
||||
expect(fond, lieu).not.toBeNull();
|
||||
expect(texte[3], lieu).toBe(1);
|
||||
expect(fond[3], lieu).toBe(1);
|
||||
expect(contraste(texte, fond), lieu).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
return [document.body, bandeau()].map((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return `${style.color} sur ${style.backgroundColor}`;
|
||||
});
|
||||
});
|
||||
couleursParTheme.push(couleurs);
|
||||
}
|
||||
// Une émulation sans effet, ou un thème sombre qui ne redéfinit aucune
|
||||
// couleur, ferait mesurer deux fois le thème clair.
|
||||
expect(
|
||||
couleursParTheme[1],
|
||||
`mêmes couleurs dans les deux thèmes : ${couleursParTheme[0].join(' ; ')}`,
|
||||
).not.toEqual(couleursParTheme[0]);
|
||||
});
|
||||
|
||||
test("aucune opacité partielle, aucun filtre ni mode de fusion sous un texte, dans les deux thèmes (§ 14.5)", async () => {
|
||||
// opacity et filter ne s'héritent pas : posés sur un ancêtre, ils changent
|
||||
// le pixel peint sans changer le style calculé du texte. Chaque niveau de
|
||||
// l'ascendance est donc relu, jusqu'à la racine du document.
|
||||
for (const theme of THEMES) {
|
||||
await sousTheme(theme, () => {
|
||||
const textes = porteursDeTexte(bandeau());
|
||||
expect(textes.length).toBeGreaterThan(0);
|
||||
for (const element of textes) {
|
||||
for (const n of ascendance(element)) {
|
||||
const style = getComputedStyle(n);
|
||||
const lieu = `${theme} : ${designation(n)} sous ${designation(element)}`;
|
||||
expect(style.opacity, lieu).toBe('1');
|
||||
expect(style.filter, lieu).toBe('none');
|
||||
expect(style.backdropFilter, lieu).toBe('none');
|
||||
expect(style.mixBlendMode, lieu).toBe('normal');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
52
src/interface/App.svelte
Normal file
52
src/interface/App.svelte
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Racine de l'interface. Le bandeau d'application, présent sur tout écran,
|
||||
// porte le nom, la version affichée et la provenance de la construction ;
|
||||
// le nom suivi de la version affichée devient aussi le titre de la fenêtre
|
||||
// (§ 8.4, § 18.6). La provenance vient du mode de Vite, que pose la
|
||||
// commande de construction.
|
||||
import './jetons.css';
|
||||
import { VERSION } from '../version.genere.js';
|
||||
import { libelleProvenance, titreAvecVersion } from './libelles.js';
|
||||
|
||||
const titre = titreAvecVersion(VERSION.affichee);
|
||||
const provenance = libelleProvenance(import.meta.env.MODE);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{titre}</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="bandeau">
|
||||
<span class="titre">{titre}</span>
|
||||
<span class="provenance">{provenance}</span>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font-family: var(--police-interface);
|
||||
}
|
||||
|
||||
.bandeau {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-3);
|
||||
padding: var(--espace-2) var(--espace-4);
|
||||
border-bottom: 1px solid var(--couleur-bandeau-filet);
|
||||
background-color: var(--couleur-bandeau-fond);
|
||||
color: var(--couleur-bandeau-texte);
|
||||
font-size: var(--taille-texte-bandeau);
|
||||
}
|
||||
|
||||
.provenance {
|
||||
padding: 0 var(--espace-2);
|
||||
border: 1px solid var(--couleur-bandeau-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
}
|
||||
</style>
|
||||
42
src/interface/jetons.css
Normal file
42
src/interface/jetons.css
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/* © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) */
|
||||
|
||||
/* Jetons de l'interface : couleurs, typographie, espacements, rayons. Le thème
|
||||
suit prefers-color-scheme ; chaque thème redéfinit les mêmes jetons de
|
||||
couleur, et les composants ne lisent que les jetons (§ 13.3).
|
||||
|
||||
Chaque couleur de texte tient au moins 4,5:1 sur le fond où elle est peinte,
|
||||
et aucune n'emploie de canal alpha : la couleur relue dans le style calculé
|
||||
est alors la couleur peinte (§ 14.5). */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--couleur-page-fond: #f6f7f9;
|
||||
--couleur-page-texte: #1c2128;
|
||||
--couleur-bandeau-fond: #1f3a5f;
|
||||
--couleur-bandeau-texte: #ffffff;
|
||||
--couleur-bandeau-filet: #8fb3d9;
|
||||
|
||||
--police-interface: system-ui, sans-serif;
|
||||
--taille-texte-bandeau: 0.9375rem;
|
||||
|
||||
--espace-1: 0.25rem;
|
||||
--espace-2: 0.5rem;
|
||||
--espace-3: 0.75rem;
|
||||
--espace-4: 1rem;
|
||||
|
||||
--rayon-1: 0.25rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--couleur-page-fond: #121519;
|
||||
--couleur-page-texte: #e3e6ea;
|
||||
--couleur-bandeau-fond: #1d2c3f;
|
||||
--couleur-bandeau-texte: #f2f4f7;
|
||||
--couleur-bandeau-filet: #5b7fa6;
|
||||
}
|
||||
}
|
||||
37
src/interface/libelles.js
Normal file
37
src/interface/libelles.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Table des chaînes visibles de l'interface. Un composant n'écrit aucune
|
||||
// chaîne visible en dur : il les lit ici, seul endroit qu'une traduction
|
||||
// remplace (§ 14.6).
|
||||
|
||||
const NOM_APPLICATION = 'Gestion table tournante Libre';
|
||||
|
||||
// Mode de construction de Vite → provenance affichée dans le bandeau
|
||||
// (§ 18.6). Une Map ne répond qu'aux clés qu'on y a posées : un mode nommé
|
||||
// comme une propriété héritée d'Object ne rend pas une fonction.
|
||||
const PROVENANCES = new Map([
|
||||
['developpement', 'développement'],
|
||||
['livraison', 'livraison'],
|
||||
['documentation', 'documentation'],
|
||||
['test', 'épreuve'],
|
||||
]);
|
||||
|
||||
// Libellé de la provenance pour un mode de Vite. Un mode sans libellé, comme
|
||||
// celui d'un « vite build » lancé sans --mode, s'affiche sous son propre nom.
|
||||
export function libelleProvenance(mode) {
|
||||
return PROVENANCES.get(mode) ?? mode;
|
||||
}
|
||||
|
||||
// Vrai quand le mode a un libellé déclaré. Comparer libelleProvenance(mode) à
|
||||
// mode ne le dit pas : « livraison » et « documentation » ont un libellé
|
||||
// identique à leur nom.
|
||||
export function aUnLibelle(mode) {
|
||||
return PROVENANCES.has(mode);
|
||||
}
|
||||
|
||||
// Nom de l'application suivi de la version affichée : texte du bandeau et
|
||||
// titre de la fenêtre (§ 18.6).
|
||||
export function titreAvecVersion(version) {
|
||||
return `${NOM_APPLICATION} — ${version}`;
|
||||
}
|
||||
50
src/interface/libelles.test.js
Normal file
50
src/interface/libelles.test.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Chaque mode que pose un script npm (« --mode … » dans package.json) a son
|
||||
// libellé de provenance, comme le mode test que pose Vitest ; un mode sans
|
||||
// libellé s'affiche sous son propre nom plutôt que de laisser le bandeau muet
|
||||
// sur la provenance.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { aUnLibelle, libelleProvenance, titreAvecVersion } from './libelles.js';
|
||||
|
||||
// Modes nommés par les scripts de package.json, triés, sans doublon.
|
||||
function modesDesScripts() {
|
||||
const paquet = JSON.parse(
|
||||
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
const modes = new Set();
|
||||
for (const commande of Object.values(paquet.scripts)) {
|
||||
for (const [, mode] of commande.matchAll(/--mode[= ](\S+)/g)) {
|
||||
modes.add(mode);
|
||||
}
|
||||
}
|
||||
return [...modes].sort();
|
||||
}
|
||||
|
||||
describe('libellés', () => {
|
||||
test('libelleProvenance nomme les quatre modes de construction', () => {
|
||||
assert.equal(libelleProvenance('developpement'), 'développement');
|
||||
assert.equal(libelleProvenance('livraison'), 'livraison');
|
||||
assert.equal(libelleProvenance('documentation'), 'documentation');
|
||||
assert.equal(libelleProvenance('test'), 'épreuve');
|
||||
});
|
||||
|
||||
test('chaque mode que pose un script npm a un libellé de provenance', () => {
|
||||
const modes = modesDesScripts();
|
||||
assert.ok(modes.length > 0, 'aucun « --mode » dans les scripts de package.json');
|
||||
assert.deepEqual(modes.filter((mode) => !aUnLibelle(mode)), []);
|
||||
});
|
||||
|
||||
test('libelleProvenance rend un mode sans libellé sous son propre nom', () => {
|
||||
assert.equal(libelleProvenance('production'), 'production');
|
||||
assert.equal(libelleProvenance('constructor'), 'constructor');
|
||||
assert.equal(aUnLibelle('constructor'), false);
|
||||
});
|
||||
|
||||
test('titreAvecVersion joint le nom et la version par un tiret cadratin', () => {
|
||||
assert.equal(titreAvecVersion('X'), 'Gestion table tournante Libre — X');
|
||||
});
|
||||
});
|
||||
8
src/main.js
Normal file
8
src/main.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Point d'entrée de la page : monte l'interface dans le corps du document.
|
||||
import { mount } from 'svelte';
|
||||
import App from './interface/App.svelte';
|
||||
|
||||
mount(App, { target: document.body });
|
||||
153
src/moteur/classement.js
Normal file
153
src/moteur/classement.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Classement des propositions (§ 5.7) : un ordre explicite, celui que
|
||||
// l'interface écrit, et non le scalaire que descend la recherche. Cinq
|
||||
// critères, chacun à minimiser, s'appliquent dans l'ordre de CRITERES ; deux
|
||||
// propositions égales sur tous se départagent par identifiant croissant
|
||||
// (§ 15.5, point 4), jamais par l'ordre reçu.
|
||||
//
|
||||
// L'excédent de collisions précède les collisions cumulées : un plan qui
|
||||
// répartit six collisions sur six paires passe devant un plan qui en
|
||||
// concentre quatre sur une seule (§ 5.4). Les paires distinctes ne sont pas
|
||||
// une clé ; elles valent les cumulées moins l'excédent.
|
||||
import { ecartsAuPlafondAPriori } from './manque.js';
|
||||
|
||||
/**
|
||||
* Les critères du classement, dans leur ordre d'application :
|
||||
* - ecartAuPlafondAPrioriMax : le plus grand écart au plafond a priori,
|
||||
* plafond a priori − rencontres, sur toutes les personnes, sous le nom
|
||||
* que lui donne le glossaire du § 5.5 ;
|
||||
* - excedentCollisions et collisionsCumulees, tels que mesurer les rend ;
|
||||
* - rencontresRepetees : les rencontres répétées que le moteur a choisies ;
|
||||
* celles qu'imposent les réservations sont les mêmes pour toute
|
||||
* proposition, et ne départagent rien (§ 5.4) ;
|
||||
* - redondance : Σ_p r(p), la redondance d'appartenance sommée sur les
|
||||
* personnes, que mesurer rend sous le nom totalRedondance.
|
||||
*/
|
||||
export const CRITERES = Object.freeze([
|
||||
'ecartAuPlafondAPrioriMax',
|
||||
'excedentCollisions',
|
||||
'collisionsCumulees',
|
||||
'rencontresRepetees',
|
||||
'redondance',
|
||||
]);
|
||||
|
||||
// Le critère qui lit le plafond a priori par personne : sauté quand il est
|
||||
// inconnu (§ 17, point 4).
|
||||
const CRITERE_A_PRIORI = 'ecartAuPlafondAPrioriMax';
|
||||
|
||||
// Le plus grand élément d'une liste ; null pour une liste vide.
|
||||
function plusGrand(valeurs) {
|
||||
let plus = null;
|
||||
for (const valeur of valeurs) if (plus === null || valeur > plus) plus = valeur;
|
||||
return plus;
|
||||
}
|
||||
|
||||
// Valeur de chaque critère pour une proposition lue : { mesures, ecarts },
|
||||
// ecarts étant ses écarts au plafond a priori. Chaque grandeur se lit dans
|
||||
// la mesure ou dans manque.js, aucune ne se refait ici. La Map ne sert qu'à
|
||||
// retrouver un critère par son nom ; CRITERES en fixe l'ordre.
|
||||
const VALEUR = new Map([
|
||||
['ecartAuPlafondAPrioriMax', ({ ecarts }) => plusGrand(ecarts)],
|
||||
['excedentCollisions', ({ mesures }) => mesures.excedentCollisions],
|
||||
['collisionsCumulees', ({ mesures }) => mesures.collisionsCumulees],
|
||||
['rencontresRepetees', ({ mesures }) => mesures.rencontresRepetees.choisies],
|
||||
['redondance', ({ mesures }) => mesures.totalRedondance],
|
||||
]);
|
||||
|
||||
// Lève TypeError pour un identifiant qui n'est pas un entier, et RangeError
|
||||
// pour un identifiant répété : deux entrées de même identifiant ne se
|
||||
// départagent pas. L'ensemble ne sert qu'à retrouver un identifiant déjà vu.
|
||||
function exigerIdentifiants(entrees) {
|
||||
const vus = new Set();
|
||||
for (const { id } of entrees) {
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new TypeError(`proposition d'identifiant ${String(id)} : entier attendu`);
|
||||
}
|
||||
if (vus.has(id)) throw new RangeError(`proposition ${id} : identifiant répété`);
|
||||
vus.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Lève RangeError quand deux entrées mesurent des populations de tailles
|
||||
// différentes : leurs chiffres ne se comparent pas. Les mesures ne portent
|
||||
// aucun identifiant de personne : deux populations de même taille mais de
|
||||
// personnes différentes passent, et l'appelant en répond (voir classer).
|
||||
function exigerMemeTaille(entrees) {
|
||||
const [premiere] = entrees;
|
||||
for (const { id, mesures } of entrees) {
|
||||
const taille = mesures.rencontres.length;
|
||||
const attendue = premiere.mesures.rencontres.length;
|
||||
if (taille !== attendue) {
|
||||
throw new RangeError(
|
||||
`proposition ${id} : ${taille} personnes mesurées, `
|
||||
+ `${attendue} pour la proposition ${premiere.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ordre lexicographique de deux clés de même longueur. Sur une population
|
||||
// vide, le plus grand écart au plafond a priori vaut null pour chaque entrée,
|
||||
// toutes de même taille : deux null sont égaux.
|
||||
function comparerCles(a, b) {
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordre des propositions (§ 5.7), la meilleure d'abord : critère par critère
|
||||
* dans l'ordre de CRITERES, chacun croissant, puis identifiant croissant.
|
||||
*
|
||||
* Chaque entrée porte id, l'identifiant entier de la proposition ; mesures,
|
||||
* telles que mesurer les rend ; plafondsAPriori, la liste de plafond.js, ou
|
||||
* null quand le plafond a priori est inconnu (§ 17, point 4). Une seule
|
||||
* entrée à plafond a priori inconnu fait sauter le premier critère pour
|
||||
* toutes : critereSaute le nomme, et criteresAppliques s'ouvre sur le critère
|
||||
* appliqué à sa place (§ 5.7). Sinon critereSaute vaut null et
|
||||
* criteresAppliques reprend CRITERES.
|
||||
*
|
||||
* Toutes les entrées sont mesurées sur une même population : leurs chiffres
|
||||
* ne se comparent qu'à cette condition. classer n'en contrôle que la taille,
|
||||
* les mesures ne portant aucun identifiant de personne ; deux populations de
|
||||
* même taille mais de personnes différentes, après une exclusion et un ajout
|
||||
* (§ 9, § 12.6), passent sans bruit. Les identifiants sont uniques parmi les
|
||||
* entrées, et rechercher numérote les propositions de chaque génération de 1
|
||||
* à nombre : l'appelant qui accumule des générations (§ 5.7) les partitionne
|
||||
* par population, et donne à chaque proposition un identifiant unique à
|
||||
* travers les générations.
|
||||
*
|
||||
* Lève TypeError pour un identifiant qui n'est pas un entier, ou un
|
||||
* plafondsAPriori qui n'est ni une liste ni null ; RangeError pour un
|
||||
* identifiant répété, pour des mesures de populations de tailles
|
||||
* différentes, ou pour un plafond a priori qui n'a pas une case par
|
||||
* personne mesurée. Ne modifie pas ce qu'il reçoit.
|
||||
*
|
||||
* @param {Array<{id: number, mesures: import('./indicateurs.js').Mesures,
|
||||
* plafondsAPriori: ArrayLike<number>|null}>} entrees
|
||||
* @returns {{ordre: number[], criteresAppliques: string[], critereSaute: string|null}}
|
||||
*/
|
||||
export function classer(entrees) {
|
||||
exigerIdentifiants(entrees);
|
||||
exigerMemeTaille(entrees);
|
||||
const lues = entrees.map(({ id, mesures, plafondsAPriori }) => ({
|
||||
id,
|
||||
mesures,
|
||||
ecarts: ecartsAuPlafondAPriori(mesures, plafondsAPriori),
|
||||
}));
|
||||
const saute = lues.some(({ ecarts }) => ecarts === null);
|
||||
const criteresAppliques = CRITERES.filter((critere) => !saute || critere !== CRITERE_A_PRIORI);
|
||||
const classees = lues.map((lue) => ({
|
||||
id: lue.id,
|
||||
cle: criteresAppliques.map((critere) => VALEUR.get(critere)(lue)),
|
||||
}));
|
||||
classees.sort((a, b) => comparerCles(a.cle, b.cle) || a.id - b.id);
|
||||
return {
|
||||
ordre: classees.map(({ id }) => id),
|
||||
criteresAppliques,
|
||||
critereSaute: saute ? CRITERE_A_PRIORI : null,
|
||||
};
|
||||
}
|
||||
250
src/moteur/classement.test.js
Normal file
250
src/moteur/classement.test.js
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du classement des propositions (§ 5.7). Une proposition
|
||||
// synthétique ne porte que les champs que classer lit, pour trois personnes
|
||||
// au plafond a priori de 8. Chaque ordre attendu est écrit en clair. Hors des
|
||||
// épreuves où seul l'identifiant départage, chaque épreuve qui attend un
|
||||
// ordre non vide en attend au moins un qui diffère de l'ordre des
|
||||
// identifiants : un classement qui ne lirait aucun critère, et rangerait par
|
||||
// identifiant, y échoue.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
|
||||
import { CRITERES, classer } from './classement.js';
|
||||
import { indexerPlan, normaliser } from './configuration.js';
|
||||
import { mesurer } from './indicateurs.js';
|
||||
|
||||
// Les critères appliqués quand le premier est sauté.
|
||||
const SANS_A_PRIORI = ['excedentCollisions', 'collisionsCumulees', 'rencontresRepetees', 'redondance'];
|
||||
|
||||
// Fige une valeur et, à toute profondeur, les objets et les tableaux qu'elle
|
||||
// contient : une écriture du module éprouvé y lève TypeError.
|
||||
function figer(valeur) {
|
||||
if (typeof valeur !== 'object' || valeur === null || ArrayBuffer.isView(valeur)) return valeur;
|
||||
for (const element of Object.values(valeur)) figer(element);
|
||||
return Object.freeze(valeur);
|
||||
}
|
||||
|
||||
// Proposition synthétique de trois personnes. rencontres fixe l'écart au
|
||||
// plafond a priori de chacun, 8 − rencontres ; collisions est le triplet
|
||||
// (cumulées, distinctes, excédent) du § 5.4 ; repetees et imposees comptent
|
||||
// les rencontres répétées que le moteur a choisies et celles qu'imposent les
|
||||
// réservations ; redondance est Σ_p r(p), le total que mesurer rend sous le
|
||||
// nom totalRedondance. La liste des r(p) n'y figure pas : classer ne la lit
|
||||
// pas.
|
||||
function proposition(
|
||||
id,
|
||||
{
|
||||
rencontres = [8, 8, 8],
|
||||
collisions = [0, 0, 0],
|
||||
repetees = 0,
|
||||
imposees = 0,
|
||||
redondance = 0,
|
||||
} = {},
|
||||
) {
|
||||
const [collisionsCumulees, pairesDistinctes, excedentCollisions] = collisions;
|
||||
return {
|
||||
id,
|
||||
mesures: {
|
||||
rencontres,
|
||||
collisionsCumulees,
|
||||
pairesDistinctes,
|
||||
excedentCollisions,
|
||||
rencontresRepetees: { choisies: repetees, imposees },
|
||||
totalRedondance: redondance,
|
||||
},
|
||||
plafondsAPriori: [8, 8, 8],
|
||||
};
|
||||
}
|
||||
|
||||
// La même proposition, son plafond a priori inconnu.
|
||||
const inconnu = (entree) => ({ ...entree, plafondsAPriori: null });
|
||||
|
||||
// Les six ordres d'une liste de trois éléments.
|
||||
const permutations = ([a, b, c]) => [[a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a]];
|
||||
|
||||
describe('classer : les critères et leur ordre (§ 5.7)', () => {
|
||||
test('CRITERES nomme les cinq critères dans leur ordre, et ne se modifie pas', () => {
|
||||
assert.deepEqual(CRITERES, [
|
||||
'ecartAuPlafondAPrioriMax',
|
||||
'excedentCollisions',
|
||||
'collisionsCumulees',
|
||||
'rencontresRepetees',
|
||||
'redondance',
|
||||
]);
|
||||
assert.ok(Object.isFrozen(CRITERES));
|
||||
});
|
||||
|
||||
test("l'ordre de l'opérateur (§ 5.4) : l'excédent d'abord, le volume ensuite", () => {
|
||||
// Les trois plans du § 5.4, au même écart au plafond a priori, en
|
||||
// (cumulées, distinctes, excédent) : un collègue retrouvé quatre fois,
|
||||
// deux collègues retrouvés deux fois chacun, six collègues retrouvés une
|
||||
// fois chacun. Les rencontres répétées suivent : une paire réunie quatre
|
||||
// fois, deux paires réunies deux fois, aucune.
|
||||
const concentre = proposition(1, { collisions: [4, 1, 3], repetees: 1 });
|
||||
const deuxFois = proposition(2, { collisions: [4, 2, 2], repetees: 2 });
|
||||
const reparti = proposition(3, { collisions: [6, 6, 0] });
|
||||
const entrees = [concentre, deuxFois, reparti];
|
||||
for (const ordreRecu of permutations(entrees)) {
|
||||
assert.deepEqual(classer(ordreRecu).ordre, [3, 2, 1]);
|
||||
}
|
||||
// La raison de la clé : un tri sur les seules collisions cumulées, ou sur
|
||||
// les seules paires distinctes, met le plan réparti en dernier.
|
||||
const triSur = (champ) =>
|
||||
[...entrees].sort((a, b) => a.mesures[champ] - b.mesures[champ] || a.id - b.id).map(({ id }) => id);
|
||||
assert.equal(triSur('collisionsCumulees').at(-1), 3);
|
||||
assert.equal(triSur('pairesDistinctes').at(-1), 3);
|
||||
});
|
||||
|
||||
test('chaque critère départage à son rang, avant tous les suivants', () => {
|
||||
// Chaque proposition perd sur un seul critère et gagne sur tous les
|
||||
// autres ; la proposition qui la suit perd sur le critère suivant. Seul
|
||||
// l'ordre des critères les range, à l'envers de leurs identifiants.
|
||||
const entrees = [
|
||||
proposition(1, { rencontres: [7, 8, 8] }), // écart au plafond a priori 1
|
||||
proposition(2, { collisions: [2, 1, 1], repetees: 1 }), // excédent 1
|
||||
proposition(3, { collisions: [3, 3, 0] }), // 3 cumulées, excédent 0
|
||||
proposition(4, { repetees: 1 }), // une rencontre répétée
|
||||
proposition(5, { redondance: 1 }), // redondance 1
|
||||
proposition(6),
|
||||
];
|
||||
assert.deepEqual(classer(figer(entrees)), {
|
||||
ordre: [6, 5, 4, 3, 2, 1],
|
||||
criteresAppliques: [...CRITERES],
|
||||
critereSaute: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('le premier critère est le plus grand écart au plafond a priori, ni sa somme ni son minimum', () => {
|
||||
// Écarts au plafond a priori, personne par personne :
|
||||
// 1 : 2, 0, 0 ; le plus grand 2, la somme 2, le minimum 0.
|
||||
// 2 : 1, 1, 1 ; le plus grand 1, la somme 3, le minimum 1.
|
||||
const entrees = [proposition(1, { rencontres: [6, 8, 8] }), proposition(2, { rencontres: [7, 7, 7] })];
|
||||
assert.deepEqual(classer(entrees).ordre, [2, 1]);
|
||||
});
|
||||
|
||||
test('seules les rencontres répétées que le moteur a choisies comptent (§ 5.4)', () => {
|
||||
// 1 : une répétition choisie ; 2 : cinq qu'imposent les réservations.
|
||||
const entrees = [proposition(1, { repetees: 1 }), proposition(2, { imposees: 5 })];
|
||||
assert.deepEqual(classer(entrees).ordre, [2, 1]);
|
||||
});
|
||||
|
||||
test('la redondance se lit sur totalRedondance, la somme que mesurer rend', () => {
|
||||
// 1 : r(p) vaut 1, 1, 1, somme 3 et plus grand 1 ; 2 : r(p) vaut 2, 0,
|
||||
// 0, somme 2 et plus grand 2. La somme range 2 devant 1, le plus grand
|
||||
// ferait l'inverse.
|
||||
const entrees = [proposition(1, { redondance: 3 }), proposition(2, { redondance: 2 })];
|
||||
assert.deepEqual(classer(entrees).ordre, [2, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classer : le critère sauté (§ 5.7, § 12.6)', () => {
|
||||
test('un plafond a priori inconnu saute le premier critère pour toutes, et classer le nomme', () => {
|
||||
// 1 : écart au plafond a priori 0, excédent 2.
|
||||
// 2 : écart au plafond a priori 1, excédent 0.
|
||||
const premiere = proposition(1, { collisions: [4, 2, 2], repetees: 2 });
|
||||
const seconde = proposition(2, { rencontres: [7, 8, 8] });
|
||||
assert.deepEqual(classer([premiere, seconde]), {
|
||||
ordre: [1, 2],
|
||||
criteresAppliques: [...CRITERES],
|
||||
critereSaute: null,
|
||||
});
|
||||
// Un seul a priori inconnu suffit : l'excédent ouvre alors le classement
|
||||
// de toutes, y compris de celle dont l'a priori est connu.
|
||||
const attendu = { ordre: [2, 1], criteresAppliques: SANS_A_PRIORI, critereSaute: 'ecartAuPlafondAPrioriMax' };
|
||||
assert.deepEqual(classer([premiere, inconnu(seconde)]), attendu);
|
||||
assert.deepEqual(classer([inconnu(premiere), seconde]), attendu);
|
||||
assert.deepEqual(classer([inconnu(premiere), inconnu(seconde)]), attendu);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classer : le départage par identifiant (§ 15.5, point 4)', () => {
|
||||
test("à égalité sur les cinq critères, l'identifiant croissant, quel que soit l'ordre reçu", () => {
|
||||
const egales = [7, 3, 5].map((id) =>
|
||||
proposition(id, { rencontres: [7, 8, 8], collisions: [2, 1, 1], repetees: 1, redondance: 1 }),
|
||||
);
|
||||
for (const ordreRecu of permutations(egales)) {
|
||||
assert.deepEqual(classer(ordreRecu).ordre, [3, 5, 7]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Un plan de la variante « conflit inévitable » (§ 15.3), où A = {1, 5, 8,
|
||||
// 10, 12}, B = {2, 4, 9, 11} et C = {3, 6, 7}. 1 et 5 restent ensemble aux
|
||||
// quatre tours, avec un membre de B ; chaque autre table réunit un membre de
|
||||
// A, de B et de C. Quatre collisions cumulées sur une seule paire.
|
||||
const CONCENTRE = {
|
||||
tables: [1, 2, 3, 4],
|
||||
tours: [
|
||||
[[1, 2, 5], [3, 4, 8], [6, 9, 10], [7, 11, 12]],
|
||||
[[1, 4, 5], [6, 8, 9], [7, 10, 11], [2, 3, 12]],
|
||||
[[1, 5, 9], [7, 8, 11], [2, 3, 10], [4, 6, 12]],
|
||||
[[1, 5, 11], [2, 6, 8], [3, 4, 10], [7, 9, 12]],
|
||||
],
|
||||
reserves: [[], [], [], []],
|
||||
};
|
||||
|
||||
describe('classer : petite démonstration, conflit inévitable (§ 14.10, § 15.3)', () => {
|
||||
test('le plan qui répartit ses quatre collisions passe devant celui qui les concentre', () => {
|
||||
const instance = normaliser(CATALOGUE.find(({ cle }) => cle === 'petite-conflit').construire());
|
||||
const mesures = (plan) => mesurer(instance, indexerPlan(instance, plan));
|
||||
const collisions = (m) => [m.collisionsCumulees, m.pairesDistinctes, m.excedentCollisions];
|
||||
// Le plan parfait de la petite démonstration répartit les siennes : 10
|
||||
// rencontre 12 au tour 1, 5 au tour 2, 8 au tour 3 et 1 au tour 4.
|
||||
const concentre = mesures(CONCENTRE);
|
||||
const reparti = mesures(PLAN_PARFAIT_PETITE);
|
||||
assert.deepEqual(collisions(concentre), [4, 1, 3]);
|
||||
assert.deepEqual(collisions(reparti), [4, 4, 0]);
|
||||
// Nul ne rencontre plus de 8 personnes sur 11 (§ 15.3).
|
||||
const huit = new Array(12).fill(8);
|
||||
const entrees = [
|
||||
{ id: 1, mesures: concentre, plafondsAPriori: huit },
|
||||
{ id: 2, mesures: reparti, plafondsAPriori: huit },
|
||||
];
|
||||
assert.deepEqual(classer(entrees).ordre, [2, 1]);
|
||||
// Le plafond a priori inconnu, l'excédent décide seul : 0 contre 3.
|
||||
assert.deepEqual(classer(entrees.map(inconnu)), {
|
||||
ordre: [2, 1],
|
||||
criteresAppliques: SANS_A_PRIORI,
|
||||
critereSaute: 'ecartAuPlafondAPrioriMax',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('classer : les entrées reçues', () => {
|
||||
test("une liste vide rend un ordre vide, tous les critères appliqués", () => {
|
||||
assert.deepEqual(classer([]), { ordre: [], criteresAppliques: [...CRITERES], critereSaute: null });
|
||||
});
|
||||
|
||||
test("population vide, plafond a priori connu : le premier critère s'applique, sans rien départager", () => {
|
||||
// Une population vide n'a pas de plus grand écart au plafond a priori ;
|
||||
// ce n'est pas un plafond a priori inconnu, et rien n'est sauté.
|
||||
const vide = (id) => ({ ...proposition(id, { rencontres: [] }), plafondsAPriori: [] });
|
||||
assert.deepEqual(classer([vide(2), vide(1)]), {
|
||||
ordre: [1, 2],
|
||||
criteresAppliques: [...CRITERES],
|
||||
critereSaute: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('un identifiant répété lève RangeError ; un identifiant non entier, TypeError', () => {
|
||||
assert.throws(() => classer([proposition(1), proposition(2), proposition(1)]), RangeError);
|
||||
assert.throws(() => classer([proposition('1')]), TypeError);
|
||||
assert.throws(() => classer([proposition(1.5)]), TypeError);
|
||||
});
|
||||
|
||||
test("un plafond a priori undefined lève TypeError ; d'une autre longueur que les rencontres, RangeError", () => {
|
||||
const sansAPriori = { ...proposition(2), plafondsAPriori: undefined };
|
||||
assert.throws(() => classer([proposition(1), sansAPriori]), TypeError);
|
||||
assert.throws(() => classer([{ ...proposition(1), plafondsAPriori: [8, 8] }]), RangeError);
|
||||
});
|
||||
|
||||
test('des mesures de populations de tailles différentes lèvent RangeError', () => {
|
||||
const quatre = {
|
||||
...proposition(2, { rencontres: [8, 8, 8, 8] }),
|
||||
plafondsAPriori: [8, 8, 8, 8],
|
||||
};
|
||||
assert.throws(() => classer([proposition(1), quatre]), RangeError);
|
||||
});
|
||||
});
|
||||
558
src/moteur/configuration.js
Normal file
558
src/moteur/configuration.js
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Passage de la configuration, forme par identifiants, à l'instance indexée
|
||||
// que consomme le moteur (§ 4, § 5.2), et conversion d'un plan entre ses deux
|
||||
// formes (§ 8.9). Les formes sont décrites dans types.js.
|
||||
//
|
||||
// Ce module définit aussi, une seule fois pour tout le moteur (§ 13.2), ce
|
||||
// que les autres modules lisent de ces formes : les valeurs du contrat —
|
||||
// STATUT, LIBRE, RESERVE, SANS_GROUPE —, la garde de forme d'un plan indexé,
|
||||
// le nombre de places d'un tour, et la répartition des personnes entre les
|
||||
// trois populations du § 5.4. Les autres modules et leurs épreuves les
|
||||
// importent, sans en redéfinir aucun.
|
||||
//
|
||||
// Trois familles de refus :
|
||||
// - ErreurConfiguration, avec un code, pour ce qu'une saisie, un import ou un
|
||||
// plan périmé peut porter : une donnée incohérente avec le reste de la
|
||||
// configuration, ou une capacité, un nombre de tours, un tour désigné hors
|
||||
// de son domaine ;
|
||||
// - TypeError, quand la configuration sort de l'une des formes que garantit
|
||||
// le code qui la bâtit — stockage, import, interface — et qu'aucune saisie
|
||||
// ne produit : un identifiant de participant ou de table qui n'est pas un
|
||||
// entier ≥ 1, une appartenance ni chaîne ni null, un exclu présent et non
|
||||
// booléen, une portée ni « tous » ni « tour », des contraintes qui ne sont
|
||||
// pas un objet de quatre booléens, une liste qui n'est pas un tableau. Ces
|
||||
// formes sont les seules que normaliser contrôle par TypeError ; une valeur
|
||||
// qui en sort est une faute de l'appelant, qu'aucune valeur par défaut ne
|
||||
// masque ;
|
||||
// - RangeError, pour un plan indexé qui n'a pas la forme de l'instance : seul
|
||||
// le moteur produit cette forme, et l'écart y est une faute de code.
|
||||
//
|
||||
// Les champs que normaliser ne lit pas — nom, prénom, numéro de table — ne
|
||||
// sont pas examinés. Aucune fonction ne modifie ce qu'elle reçoit.
|
||||
import { ErreurConfiguration } from './erreurs.js';
|
||||
|
||||
/**
|
||||
* Statuts d'un participant (§ 4.2), valeurs de instance.statut. normaliser
|
||||
* les dérive des réservations ; aucun n'est déclaré. Un partiellement fixé
|
||||
* compte parmi les mobiles dans les populations du § 5.4.
|
||||
*/
|
||||
export const STATUT = Object.freeze({ MOBILE: 0, PARTIELLEMENT_FIXE: 1, ANCRE: 2 });
|
||||
|
||||
/** Case de instance.fixe sans table imposée. */
|
||||
export const LIBRE = -1;
|
||||
|
||||
/**
|
||||
* Case d'un plan indexé : la réserve. Elle vaut LIBRE, et une rangée de
|
||||
* instance.fixe se lit ainsi comme le plan qui assied chacun à ses seuls
|
||||
* tours fixés et le laisse en réserve aux autres : le diagnostic mesure ce
|
||||
* plan, le plafond a priori part de cet itinéraire.
|
||||
*/
|
||||
export const RESERVE = -1;
|
||||
|
||||
/** instance.groupe d'un participant sans appartenance. */
|
||||
export const SANS_GROUPE = -1;
|
||||
|
||||
// Case d'un plan indexé qu'indexerPlan n'a pas encore remplie ; jamais rendue.
|
||||
const NON_PLACE = -2;
|
||||
|
||||
// Plus grand entier que représente un Int32Array : une capacité au-delà s'y
|
||||
// tronquerait sans bruit.
|
||||
const ENTIER_32_MAX = 2 ** 31 - 1;
|
||||
|
||||
const NOMS_CONTRAINTES = [
|
||||
'separerAppartenances',
|
||||
'nouveauxVoisins',
|
||||
'nouvelleTable',
|
||||
'varierAppartenances',
|
||||
];
|
||||
|
||||
// Valeur citée dans un message : une chaîne s'écrit entre guillemets, pour
|
||||
// ne pas se lire comme le nombre qu'elle contient.
|
||||
// Valeur citée dans un message : une chaîne entre guillemets, pour qu'elle
|
||||
// ne se lise pas comme le nombre qu'elle contient ; toute autre valeur telle
|
||||
// quelle.
|
||||
export const decrire = (valeur) => (typeof valeur === 'string' ? JSON.stringify(valeur) : String(valeur));
|
||||
|
||||
function exigerListe(valeur, nom) {
|
||||
if (!Array.isArray(valeur)) throw new TypeError(`configuration.${nom} : liste attendue`);
|
||||
}
|
||||
|
||||
function exigerIdentifiant(id, quoi) {
|
||||
if (!Number.isInteger(id) || id < 1) {
|
||||
throw new TypeError(`${quoi} d'identifiant ${decrire(id)} : entier ≥ 1 attendu`);
|
||||
}
|
||||
}
|
||||
|
||||
function exigerAppartenance({ id, appartenance }) {
|
||||
if (appartenance !== null && typeof appartenance !== 'string') {
|
||||
throw new TypeError(
|
||||
`participant ${id} : appartenance chaîne ou null attendue, reçu ${decrire(appartenance)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Vrai pour un participant exclu ; un champ exclu absent vaut false.
|
||||
function estExclu({ id, exclu }) {
|
||||
if (exclu === undefined) return false;
|
||||
if (typeof exclu !== 'boolean') {
|
||||
throw new TypeError(`participant ${id} : exclu booléen attendu, reçu ${decrire(exclu)}`);
|
||||
}
|
||||
return exclu;
|
||||
}
|
||||
|
||||
// Copie des quatre contraintes : une modification ultérieure de la
|
||||
// configuration n'atteint pas l'instance.
|
||||
function copierContraintes(contraintes) {
|
||||
if (typeof contraintes !== 'object' || contraintes === null) {
|
||||
throw new TypeError('configuration.contraintes : objet attendu');
|
||||
}
|
||||
const copie = {};
|
||||
for (const nom of NOMS_CONTRAINTES) {
|
||||
if (typeof contraintes[nom] !== 'boolean') {
|
||||
throw new TypeError(`contrainte ${nom} : booléen attendu, reçu ${decrire(contraintes[nom])}`);
|
||||
}
|
||||
copie[nom] = contraintes[nom];
|
||||
}
|
||||
return copie;
|
||||
}
|
||||
|
||||
// Groupe de chaque participant présent, présents triés par identifiant
|
||||
// croissant : un libellé reçoit le numéro de sa première apparition,
|
||||
// SANS_GROUPE marque l'absence d'appartenance. L'égalité de chaînes fait le
|
||||
// groupe. La Map ne sert qu'à retrouver un libellé déjà numéroté.
|
||||
function numeroterGroupes(presents) {
|
||||
const groupe = new Int32Array(presents.length);
|
||||
const groupes = [];
|
||||
const numeroDe = new Map();
|
||||
for (let p = 0; p < presents.length; p += 1) {
|
||||
const { appartenance } = presents[p];
|
||||
if (appartenance === null) {
|
||||
groupe[p] = SANS_GROUPE;
|
||||
continue;
|
||||
}
|
||||
if (!numeroDe.has(appartenance)) {
|
||||
numeroDe.set(appartenance, groupes.length);
|
||||
groupes.push(appartenance);
|
||||
}
|
||||
groupe[p] = numeroDe.get(appartenance);
|
||||
}
|
||||
return { groupe, groupes };
|
||||
}
|
||||
|
||||
// Tables imposées par les réservations : fixe[p * R + r] = index de table,
|
||||
// LIBRE sinon. Chaque réservation est vérifiée dans sa forme — participant et
|
||||
// table connus, tour dans 1..R —, exclus compris ; celle d'un exclu s'arrête
|
||||
// là, suspendue, sans effet (§ 4.4). Une réservation qui répète la table déjà
|
||||
// imposée au même tour ne change rien ; une autre table au même tour lève
|
||||
// RESERVATION_CONFLIT.
|
||||
function fixerReservations(reservations, contexte) {
|
||||
const { connus, indexDe, indexTableDe, idsTables, N, R } = contexte;
|
||||
const fixe = new Int32Array(N * R).fill(LIBRE);
|
||||
for (let i = 0; i < reservations.length; i += 1) {
|
||||
const { participant: id, table: idTable, portee, tour } = reservations[i];
|
||||
if (!connus.has(id)) {
|
||||
throw new ErreurConfiguration('RESERVATION_INCONNUE', { reservation: i, participant: id });
|
||||
}
|
||||
const t = indexTableDe.get(idTable);
|
||||
if (t === undefined) {
|
||||
throw new ErreurConfiguration('RESERVATION_INCONNUE', { reservation: i, table: idTable });
|
||||
}
|
||||
// Tours couverts, numérotés à partir de 0 : chacun des R tours pour la
|
||||
// portée « tous », le tour désigné pour la portée « tour ».
|
||||
let premier;
|
||||
let dernier;
|
||||
if (portee === 'tous') {
|
||||
premier = 0;
|
||||
dernier = R - 1;
|
||||
} else if (portee === 'tour') {
|
||||
if (!Number.isInteger(tour) || tour < 1 || tour > R) {
|
||||
throw new ErreurConfiguration('RESERVATION_TOUR', { reservation: i, tour });
|
||||
}
|
||||
premier = tour - 1;
|
||||
dernier = tour - 1;
|
||||
} else {
|
||||
throw new TypeError(
|
||||
`réservation ${i} : portée ${decrire(portee)}, « tous » ou « tour » attendue`,
|
||||
);
|
||||
}
|
||||
const p = indexDe.get(id);
|
||||
if (p === undefined) continue;
|
||||
for (let r = premier; r <= dernier; r += 1) {
|
||||
const imposee = fixe[p * R + r];
|
||||
if (imposee === LIBRE) {
|
||||
fixe[p * R + r] = t;
|
||||
} else if (imposee !== t) {
|
||||
throw new ErreurConfiguration('RESERVATION_CONFLIT', {
|
||||
participant: id,
|
||||
tour: r + 1,
|
||||
tables: [idsTables[imposee], idTable],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return fixe;
|
||||
}
|
||||
|
||||
// Lève SURRESERVATION quand, à un tour, une table reçoit plus de personnes
|
||||
// fixées que de sièges (§ 5.9). Le compte porte sur les personnes, lues dans
|
||||
// fixe : une réservation répétée ne compte qu'une fois. Les tables se
|
||||
// parcourent par index, chacune par tours croissants.
|
||||
function verifierSurreservation(fixe, { N, T, R, capacite, idsTables }) {
|
||||
const fixees = new Int32Array(T * R);
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const t = fixe[p * R + r];
|
||||
if (t !== LIBRE) fixees[t * R + r] += 1;
|
||||
}
|
||||
}
|
||||
for (let t = 0; t < T; t += 1) {
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
if (fixees[t * R + r] > capacite[t]) {
|
||||
throw new ErreurConfiguration('SURRESERVATION', {
|
||||
table: idsTables[t],
|
||||
tour: r + 1,
|
||||
reservees: fixees[t * R + r],
|
||||
capacite: capacite[t],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Statut de chaque participant, dérivé de fixe et non de la forme des
|
||||
// réservations (§ 4.2) : ancré quand la même table est imposée à chacun des R
|
||||
// tours, mobile quand aucune ne l'est, partiellement fixé sinon. Un ancré
|
||||
// compte dans k et dans ancresParTable ; un partiellement fixé compte parmi
|
||||
// les mobiles.
|
||||
function deriverStatuts(fixe, N, T, R) {
|
||||
const statut = new Uint8Array(N);
|
||||
const ancresParTable = new Int32Array(T);
|
||||
let k = 0;
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
const premiere = fixe[p * R];
|
||||
let memeTable = true;
|
||||
let aucune = true;
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const t = fixe[p * R + r];
|
||||
if (t !== premiere) memeTable = false;
|
||||
if (t !== LIBRE) aucune = false;
|
||||
}
|
||||
if (aucune) {
|
||||
statut[p] = STATUT.MOBILE;
|
||||
} else if (memeTable) {
|
||||
statut[p] = STATUT.ANCRE;
|
||||
ancresParTable[premiere] += 1;
|
||||
k += 1;
|
||||
} else {
|
||||
statut[p] = STATUT.PARTIELLEMENT_FIXE;
|
||||
}
|
||||
}
|
||||
return { statut, ancresParTable, k };
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance indexée d'une configuration (§ 4, § 5.2). Les participants exclus
|
||||
* en sont absents (§ 4.4) : ni index ni groupe, et leurs réservations,
|
||||
* suspendues, ne fixent rien et ne prennent aucun siège.
|
||||
*
|
||||
* Lève ErreurConfiguration sur la première règle enfreinte — participants,
|
||||
* puis tables, nombre de tours, réservations une à une, surréservation :
|
||||
* PARTICIPANT_DOUBLON, TABLE_DOUBLON, CAPACITE (non entière, < 2, ou au-delà
|
||||
* d'un Int32Array), TOURS (R non entier ou < 1), RESERVATION_INCONNUE
|
||||
* (participant ou table), RESERVATION_TOUR (tour absent ou hors de 1..R),
|
||||
* RESERVATION_CONFLIT (une personne à deux tables d'un même tour),
|
||||
* SURRESERVATION (plus de personnes fixées que de sièges à une table, à un
|
||||
* tour). Une salle trop petite n'est pas refusée : nombrePlacesManquantes la
|
||||
* chiffre, et la recherche la refuse (§ 5.9). Les détails désignent
|
||||
* participants et tables par identifiant, les tours par leur numéro à partir
|
||||
* de 1, et la réservation fautive par son rang dans
|
||||
* configuration.reservations, à partir de 0.
|
||||
*
|
||||
* Lève TypeError sur les seules formes qu'énumère l'en-tête du module :
|
||||
* identifiants de participant et de table, appartenance, exclu, portée,
|
||||
* contraintes, listes. Une capacité, un nombre de tours ou un tour désigné
|
||||
* hors de leur forme lèvent CAPACITE, TOURS ou RESERVATION_TOUR ; une
|
||||
* réservation qui désigne un participant ou une table absents de la
|
||||
* configuration lève RESERVATION_INCONNUE, quelle que soit la forme de la
|
||||
* valeur qui les désigne.
|
||||
*
|
||||
* @param {import('./types.js').Configuration} configuration
|
||||
* @returns {import('./types.js').Instance}
|
||||
*/
|
||||
export function normaliser(configuration) {
|
||||
const { participants, tables, tours: R, reservations } = configuration;
|
||||
exigerListe(participants, 'participants');
|
||||
exigerListe(tables, 'tables');
|
||||
exigerListe(reservations, 'reservations');
|
||||
const contraintes = copierContraintes(configuration.contraintes);
|
||||
|
||||
// connus porte tous les identifiants, exclus compris : un doublon reste un
|
||||
// doublon, et la réservation d'un exclu n'est pas inconnue.
|
||||
const connus = new Set();
|
||||
const presents = [];
|
||||
const exclus = [];
|
||||
for (const participant of participants) {
|
||||
exigerIdentifiant(participant.id, 'participant');
|
||||
if (connus.has(participant.id)) {
|
||||
throw new ErreurConfiguration('PARTICIPANT_DOUBLON', { participant: participant.id });
|
||||
}
|
||||
connus.add(participant.id);
|
||||
exigerAppartenance(participant);
|
||||
if (estExclu(participant)) exclus.push(participant.id);
|
||||
else presents.push(participant);
|
||||
}
|
||||
presents.sort((a, b) => a.id - b.id);
|
||||
exclus.sort((a, b) => a - b);
|
||||
|
||||
const T = tables.length;
|
||||
const idsTables = [];
|
||||
const indexTableDe = new Map();
|
||||
const capacite = new Int32Array(T);
|
||||
for (let t = 0; t < T; t += 1) {
|
||||
const { id, capacite: places } = tables[t];
|
||||
exigerIdentifiant(id, 'table');
|
||||
if (indexTableDe.has(id)) throw new ErreurConfiguration('TABLE_DOUBLON', { table: id });
|
||||
if (!Number.isInteger(places) || places < 2 || places > ENTIER_32_MAX) {
|
||||
throw new ErreurConfiguration('CAPACITE', { table: id, capacite: places });
|
||||
}
|
||||
idsTables.push(id);
|
||||
indexTableDe.set(id, t);
|
||||
capacite[t] = places;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(R) || R < 1) throw new ErreurConfiguration('TOURS', { tours: R });
|
||||
|
||||
const N = presents.length;
|
||||
const ids = presents.map((participant) => participant.id);
|
||||
const indexDe = new Map(ids.map((id, p) => [id, p]));
|
||||
const { groupe, groupes } = numeroterGroupes(presents);
|
||||
const fixe = fixerReservations(reservations, { connus, indexDe, indexTableDe, idsTables, N, R });
|
||||
verifierSurreservation(fixe, { N, T, R, capacite, idsTables });
|
||||
const { statut, ancresParTable, k } = deriverStatuts(fixe, N, T, R);
|
||||
|
||||
return {
|
||||
N,
|
||||
T,
|
||||
R,
|
||||
ids,
|
||||
indexDe,
|
||||
exclus: new Set(exclus),
|
||||
idsTables,
|
||||
indexTableDe,
|
||||
capacite,
|
||||
groupe,
|
||||
groupes,
|
||||
fixe,
|
||||
statut,
|
||||
ancresParTable,
|
||||
k,
|
||||
n: N - k,
|
||||
contraintes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan indexé d'un plan par identifiants : tableDe[p * R + r] = index de
|
||||
* table, −1 pour la réserve. plan.tables déclare chaque table de l'instance
|
||||
* une fois, et chaque liste se rattache à sa table par l'identifiant déclaré
|
||||
* à la même position ; l'ordre des tables et celui des ids dans une liste
|
||||
* sont indifférents. Un plan accepté revient donc de planDepuisIndex à
|
||||
* l'identique, à ces deux ordres près.
|
||||
*
|
||||
* La forme indexée n'a pas de case pour un participant absent d'un tour,
|
||||
* placé deux fois ou étranger à l'instance, ni pour une table que le plan ne
|
||||
* déclare pas : au lieu de perdre l'écart, indexerPlan lève
|
||||
* ErreurConfiguration — PLAN_TOURS (tours ou réserves en nombre ≠ R),
|
||||
* PLAN_TABLE_INCONNUE, PLAN_TABLE_DOUBLON, PLAN_TABLE_ABSENTE (une table de
|
||||
* l'instance que plan.tables ne déclare pas), PLAN_LISTES (un tour sans
|
||||
* exactement une liste par table déclarée), PLAN_INCONNU, PLAN_EXCLU_PLACE
|
||||
* (participant exclu de l'instance), PLAN_DOUBLE_PLACE (deux fois dans un
|
||||
* tour, réserve comprise), PLAN_NON_ASSIS (ni à une table ni en réserve à un
|
||||
* tour). Les capacités et les réservations ne sont pas examinées.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @param {import('./types.js').Plan} plan
|
||||
* @returns {Int32Array}
|
||||
*/
|
||||
export function indexerPlan(instance, plan) {
|
||||
const { N, T, R, ids, idsTables, indexDe, indexTableDe, exclus } = instance;
|
||||
if (plan.tours.length !== R || plan.reserves.length !== R) {
|
||||
throw new ErreurConfiguration('PLAN_TOURS', {
|
||||
attendu: R,
|
||||
tours: plan.tours.length,
|
||||
reserves: plan.reserves.length,
|
||||
});
|
||||
}
|
||||
// Index de table de chaque liste, par position dans plan.tables.
|
||||
const tableDeListe = [];
|
||||
const declarees = new Set();
|
||||
for (const id of plan.tables) {
|
||||
const t = indexTableDe.get(id);
|
||||
if (t === undefined) throw new ErreurConfiguration('PLAN_TABLE_INCONNUE', { table: id });
|
||||
if (declarees.has(id)) throw new ErreurConfiguration('PLAN_TABLE_DOUBLON', { table: id });
|
||||
declarees.add(id);
|
||||
tableDeListe.push(t);
|
||||
}
|
||||
// Sans id inconnu ni doublon, une table manque exactement quand le plan en
|
||||
// déclare moins que l'instance ; la première absente, dans l'ordre de
|
||||
// l'instance, est nommée.
|
||||
if (tableDeListe.length !== T) {
|
||||
const absente = idsTables.find((id) => !declarees.has(id));
|
||||
throw new ErreurConfiguration('PLAN_TABLE_ABSENTE', { table: absente });
|
||||
}
|
||||
|
||||
const tableDe = new Int32Array(N * R).fill(NON_PLACE);
|
||||
const placer = (id, r, t) => {
|
||||
const p = indexDe.get(id);
|
||||
if (p === undefined) {
|
||||
const code = exclus.has(id) ? 'PLAN_EXCLU_PLACE' : 'PLAN_INCONNU';
|
||||
throw new ErreurConfiguration(code, { participant: id, tour: r + 1 });
|
||||
}
|
||||
if (tableDe[p * R + r] !== NON_PLACE) {
|
||||
throw new ErreurConfiguration('PLAN_DOUBLE_PLACE', { participant: id, tour: r + 1 });
|
||||
}
|
||||
tableDe[p * R + r] = t;
|
||||
};
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const listes = plan.tours[r];
|
||||
if (listes.length !== tableDeListe.length) {
|
||||
throw new ErreurConfiguration('PLAN_LISTES', {
|
||||
tour: r + 1,
|
||||
listes: listes.length,
|
||||
tables: tableDeListe.length,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < listes.length; i += 1) {
|
||||
for (const id of listes[i]) placer(id, r, tableDeListe[i]);
|
||||
}
|
||||
for (const id of plan.reserves[r]) placer(id, r, RESERVE);
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
if (tableDe[p * R + r] === NON_PLACE) {
|
||||
throw new ErreurConfiguration('PLAN_NON_ASSIS', { participant: ids[p], tour: r + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return tableDe;
|
||||
}
|
||||
|
||||
/**
|
||||
* La garde de forme d'un plan indexé, la seule du moteur : planDepuisIndex,
|
||||
* mesurer et plafondsRealises l'appellent avant de lire une case. Lève
|
||||
* RangeError quand tableDe n'a pas N × R cases, ou qu'une case n'est ni
|
||||
* RESERVE ni un index de table entier de 0 à T − 1. Le message nomme alors
|
||||
* le participant, par identifiant, le tour et la valeur de la première case
|
||||
* fautive dans l'ordre des cases, p × R + r croissant ; une chaîne s'y cite
|
||||
* entre guillemets, pour ne pas se lire comme le nombre qu'elle contient.
|
||||
*
|
||||
* Sans elle, une case de trop serait ignorée, et une chaîne numérique comme
|
||||
* "0" assiérait la personne à la table qu'elle désigne là où une
|
||||
* comparaison stricte à instance.fixe n'y reconnaîtrait pas sa réservation.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @param {ArrayLike<number>} tableDe
|
||||
*/
|
||||
export function exigerPlanIndexe({ N, T, R, ids }, tableDe) {
|
||||
if (tableDe.length !== N * R) {
|
||||
throw new RangeError(`plan indexé de ${tableDe.length} cases, N × R = ${N * R} attendues`);
|
||||
}
|
||||
for (let i = 0; i < tableDe.length; i += 1) {
|
||||
const t = tableDe[i];
|
||||
if (t !== RESERVE && !(Number.isInteger(t) && t >= 0 && t < T)) {
|
||||
throw new RangeError(
|
||||
`plan indexé : participant ${ids[Math.floor(i / R)]}, tour ${(i % R) + 1}, `
|
||||
+ `index de table ${decrire(t)} hors de −1..${T - 1}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan par identifiants d'un plan indexé : les tables dans l'ordre de
|
||||
* l'instance, chaque liste de table et chaque réserve en ids croissants
|
||||
* (§ 8.9). Lève ce que lève exigerPlanIndexe.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @param {ArrayLike<number>} tableDe
|
||||
* @returns {import('./types.js').Plan}
|
||||
*/
|
||||
export function planDepuisIndex(instance, tableDe) {
|
||||
exigerPlanIndexe(instance, tableDe);
|
||||
const { N, T, R, ids, idsTables } = instance;
|
||||
const tours = [];
|
||||
const reserves = [];
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const listes = Array.from({ length: T }, () => []);
|
||||
const reserve = [];
|
||||
// Les ids croissent avec l'index : parcourir les participants par index
|
||||
// range chaque liste en ids croissants, sans tri.
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
const t = tableDe[p * R + r];
|
||||
if (t === RESERVE) reserve.push(ids[p]);
|
||||
else listes[t].push(ids[p]);
|
||||
}
|
||||
tours.push(listes);
|
||||
reserves.push(reserve);
|
||||
}
|
||||
return { tables: [...idsTables], tours, reserves };
|
||||
}
|
||||
|
||||
/**
|
||||
* Places qu'offre chaque tour : Σ c_t, la somme des capacités. Ni les
|
||||
* participants ni les réservations n'y entrent. Le nombre de places
|
||||
* manquantes et le diagnostic d'une salle tendue la lisent ici.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance T et capacite sont lus
|
||||
* @returns {number}
|
||||
*/
|
||||
export function nombrePlaces({ T, capacite }) {
|
||||
let places = 0;
|
||||
for (let t = 0; t < T; t += 1) places += capacite[t];
|
||||
return places;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places qui manquent à chaque tour pour asseoir tous les participants :
|
||||
* max(0, N − Σ c_t). La quantité égale max(0, n − Σ (c_t − a_t)) et ne
|
||||
* dépend d'aucune réservation (§ 5.9) : un ancré retire une personne des
|
||||
* mobiles et un siège de la capacité libre, un partiellement fixé reste
|
||||
* parmi les mobiles et ne retire rien.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @returns {number}
|
||||
*/
|
||||
export function nombrePlacesManquantes(instance) {
|
||||
return Math.max(0, instance.N - nombrePlaces(instance));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replie valeurs[p] sur les trois populations du § 5.4, en un passage, dans
|
||||
* l'ordre canonique : chacun compte dans « tous », puis parmi les ancrés, ou
|
||||
* parmi les mobiles, partiellement fixés compris. C'est le seul endroit qui
|
||||
* range une personne dans sa population : les agrégats des indicateurs et
|
||||
* les chiffres du manque le lisent tous deux.
|
||||
*
|
||||
* replier(cumul, valeur) rend le cumul suivant sans modifier celui qu'il
|
||||
* reçoit : les trois populations partent du même initial, et une population
|
||||
* sans membre le garde.
|
||||
*
|
||||
* @template C
|
||||
* @param {ArrayLike<*>} valeurs une par personne, dans l'ordre de instance.ids
|
||||
* @param {ArrayLike<number>} statut instance.statut
|
||||
* @param {C} initial
|
||||
* @param {function(C, *): C} replier
|
||||
* @returns {{tous: C, mobiles: C, ancres: C}}
|
||||
*/
|
||||
export function replierParPopulation(valeurs, statut, initial, replier) {
|
||||
let tous = initial;
|
||||
let mobiles = initial;
|
||||
let ancres = initial;
|
||||
for (let p = 0; p < valeurs.length; p += 1) {
|
||||
const valeur = valeurs[p];
|
||||
tous = replier(tous, valeur);
|
||||
if (statut[p] === STATUT.ANCRE) ancres = replier(ancres, valeur);
|
||||
else mobiles = replier(mobiles, valeur);
|
||||
}
|
||||
return { tous, mobiles, ancres };
|
||||
}
|
||||
895
src/moteur/configuration.test.js
Normal file
895
src/moteur/configuration.test.js
Normal file
|
|
@ -0,0 +1,895 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves de la configuration (§ 4, § 5.2, § 5.9) : les valeurs du contrat
|
||||
// de données, l'instance indexée que rend normaliser et ses refus, le nombre
|
||||
// de places et de places manquantes, la conversion d'un plan entre la forme
|
||||
// par identifiants et la forme indexée, la garde de forme d'un plan indexé
|
||||
// et la répartition des personnes entre les trois populations du § 5.4. Les
|
||||
// identifiants diffèrent des index qu'ils reçoivent et des tables arrivent
|
||||
// hors de l'ordre de leurs identifiants : confondre un identifiant et un index
|
||||
// fait échouer une épreuve au lieu de passer inaperçu.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { ErreurAnnulee, ErreurConfiguration } from './erreurs.js';
|
||||
import {
|
||||
LIBRE,
|
||||
RESERVE,
|
||||
SANS_GROUPE,
|
||||
STATUT,
|
||||
exigerPlanIndexe,
|
||||
indexerPlan,
|
||||
nombrePlaces,
|
||||
nombrePlacesManquantes,
|
||||
normaliser,
|
||||
planDepuisIndex,
|
||||
replierParPopulation,
|
||||
} from './configuration.js';
|
||||
import { mesurer } from './indicateurs.js';
|
||||
import { plafondsRealises } from './plafond.js';
|
||||
|
||||
const { MOBILE, PARTIELLEMENT_FIXE, ANCRE } = STATUT;
|
||||
|
||||
const SANS_CONTRAINTE = {
|
||||
separerAppartenances: false,
|
||||
nouveauxVoisins: false,
|
||||
nouvelleTable: false,
|
||||
varierAppartenances: false,
|
||||
};
|
||||
|
||||
// Entiers de debut à fin, inclus.
|
||||
const suite = (debut, fin) => Array.from({ length: fin - debut + 1 }, (_, i) => debut + i);
|
||||
|
||||
// Participants des identifiants donnés, sans appartenance.
|
||||
const personnes = (ids) => ids.map((id) => ({ id, nom: `P${id}`, appartenance: null }));
|
||||
const exclu = (id) => ({ id, nom: `P${id}`, appartenance: null, exclu: true });
|
||||
|
||||
// Tables des identifiants donnés, toutes de la même capacité, numérotées dans
|
||||
// l'ordre fourni.
|
||||
const tablesDe = (ids, capacite) => ids.map((id, i) => ({ id, numero: i + 1, capacite }));
|
||||
|
||||
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
|
||||
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
|
||||
|
||||
// Libellé d'une valeur de cas : une chaîne se distingue du nombre qu'elle écrit.
|
||||
const libelleDe = (valeur) => (typeof valeur === 'string' ? `« ${valeur} » en texte` : String(valeur));
|
||||
|
||||
const configuration = ({
|
||||
participants,
|
||||
tables,
|
||||
tours,
|
||||
reservations = [],
|
||||
contraintes = SANS_CONTRAINTE,
|
||||
}) => ({ participants, tables, tours, reservations, contraintes });
|
||||
|
||||
// Gèle une valeur et tout ce qu'elle contient : une écriture y lève
|
||||
// TypeError, le module s'exécutant en mode strict.
|
||||
function geler(valeur) {
|
||||
if (typeof valeur === 'object' && valeur !== null) {
|
||||
for (const enfant of Object.values(valeur)) geler(enfant);
|
||||
Object.freeze(valeur);
|
||||
}
|
||||
return valeur;
|
||||
}
|
||||
|
||||
// Écart entre ce que lève fonction et une ErreurConfiguration du code et des
|
||||
// détails attendus ; null quand ils concordent.
|
||||
function ecartDeRefus(fonction, code, details) {
|
||||
try {
|
||||
fonction();
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurConfiguration)) {
|
||||
return `${erreur?.name} au lieu d'une ErreurConfiguration : ${erreur?.message}`;
|
||||
}
|
||||
if (erreur.code !== code) return `code ${erreur.code} au lieu de ${code}`;
|
||||
try {
|
||||
assert.deepEqual(erreur.details, details);
|
||||
} catch {
|
||||
return `détails ${JSON.stringify(erreur.details)} au lieu de ${JSON.stringify(details)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return 'aucun refus';
|
||||
}
|
||||
|
||||
// Chaque cas [libellé, fonction, code, détails] dont le refus s'écarte de
|
||||
// l'attendu, en une ligne lisible.
|
||||
function ecartsDeRefus(cas) {
|
||||
assert.ok(cas.length > 0, 'aucun cas examiné');
|
||||
return cas
|
||||
.map(([libelle, fonction, code, details]) => [libelle, ecartDeRefus(fonction, code, details)])
|
||||
.filter(([, ecart]) => ecart !== null)
|
||||
.map(([libelle, ecart]) => `${libelle} : ${ecart}`);
|
||||
}
|
||||
|
||||
describe('valeurs du contrat de données (types.js)', () => {
|
||||
test('STATUT, LIBRE, RESERVE et SANS_GROUPE valent ce que types.js écrit, et STATUT ne se modifie pas', () => {
|
||||
// Seule épreuve qui écrit ces valeurs en clair : les autres les
|
||||
// importent. LIBRE et RESERVE sont égales : une rangée de instance.fixe se
|
||||
// lit comme le plan qui laisse en réserve chaque tour libre.
|
||||
assert.deepEqual(STATUT, { MOBILE: 0, PARTIELLEMENT_FIXE: 1, ANCRE: 2 });
|
||||
assert.ok(Object.isFrozen(STATUT));
|
||||
assert.deepEqual([LIBRE, RESERVE, SANS_GROUPE], [-1, -1, -1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliser : statuts (§ 4.2, § 4.3)', () => {
|
||||
test('le statut se dérive du fait : ancré, partiellement fixé ou mobile', () => {
|
||||
const instance = normaliser(configuration({
|
||||
participants: personnes(suite(1, 9)),
|
||||
tables: tablesDe([11, 12, 13, 14], 4),
|
||||
tours: 3,
|
||||
reservations: [
|
||||
tous(1, 11),
|
||||
auTour(2, 12, 3), auTour(2, 12, 1), auTour(2, 12, 2),
|
||||
auTour(3, 13, 2),
|
||||
auTour(4, 11, 1), auTour(4, 12, 2),
|
||||
auTour(5, 13, 1), auTour(5, 13, 2), auTour(5, 14, 3),
|
||||
auTour(7, 14, 1),
|
||||
auTour(8, 14, 3),
|
||||
auTour(9, 12, 1), auTour(9, 13, 2), auTour(9, 12, 3),
|
||||
],
|
||||
}));
|
||||
// 1 : portée « tous » ; 2 : R tours désignés, même table ; 3 : le seul
|
||||
// tour du milieu ; 4 : deux tours, deux tables ; 5 : chaque tour, deux
|
||||
// tables ; 6 : aucune réservation ; 7 : le seul premier tour ; 8 : le
|
||||
// seul dernier tour ; 9 : chaque tour, la même table au premier et au
|
||||
// dernier, une autre entre les deux. Seuls 1 et 2 comptent dans k : un
|
||||
// partiellement fixé compte parmi les mobiles.
|
||||
assert.deepEqual(instance.statut, Uint8Array.from([
|
||||
ANCRE, ANCRE,
|
||||
PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE,
|
||||
MOBILE,
|
||||
PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE,
|
||||
]));
|
||||
assert.deepEqual(instance.fixe, Int32Array.from([
|
||||
0, 0, 0,
|
||||
1, 1, 1,
|
||||
LIBRE, 2, LIBRE,
|
||||
0, 1, LIBRE,
|
||||
2, 2, 3,
|
||||
LIBRE, LIBRE, LIBRE,
|
||||
3, LIBRE, LIBRE,
|
||||
LIBRE, LIBRE, 3,
|
||||
1, 2, 1,
|
||||
]));
|
||||
assert.equal(instance.k, 2);
|
||||
assert.equal(instance.n, 7);
|
||||
assert.deepEqual(instance.ancresParTable, Int32Array.from([1, 1, 0, 0]));
|
||||
|
||||
// À R = 1, une réservation de tour désigné fixe chacun des R tours : ancré.
|
||||
const unTour = normaliser(configuration({
|
||||
participants: personnes([1, 2]),
|
||||
tables: tablesDe([11, 12], 2),
|
||||
tours: 1,
|
||||
reservations: [auTour(1, 11, 1)],
|
||||
}));
|
||||
assert.deepEqual(unTour.statut, Uint8Array.from([ANCRE, MOBILE]));
|
||||
assert.deepEqual(unTour.fixe, Int32Array.from([0, LIBRE]));
|
||||
assert.equal(unTour.k, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliser : ancrés et instance réduite (§ 5.2)', () => {
|
||||
test('k, n = N − k et ancresParTable sur quatre tables de 5 à un ancré chacune', () => {
|
||||
const instance = normaliser(configuration({
|
||||
participants: personnes(suite(1, 20)),
|
||||
tables: tablesDe([11, 12, 13, 14], 5),
|
||||
tours: 5,
|
||||
reservations: [
|
||||
tous(1, 11),
|
||||
tous(6, 12),
|
||||
...suite(1, 5).map((tour) => auTour(11, 13, tour)),
|
||||
tous(16, 14),
|
||||
// Partiellement fixé : compté parmi les mobiles.
|
||||
auTour(2, 12, 3),
|
||||
],
|
||||
}));
|
||||
assert.equal(instance.N, 20);
|
||||
assert.equal(instance.T, 4);
|
||||
assert.equal(instance.R, 5);
|
||||
assert.equal(instance.k, 4);
|
||||
assert.equal(instance.n, 16);
|
||||
assert.deepEqual(instance.ancresParTable, Int32Array.from([1, 1, 1, 1]));
|
||||
assert.equal(instance.statut[instance.indexDe.get(2)], PARTIELLEMENT_FIXE);
|
||||
});
|
||||
|
||||
test('ancresParTable suit les index de table, dans l’ordre de configuration.tables', () => {
|
||||
const instance = normaliser(configuration({
|
||||
participants: personnes(suite(1, 8)),
|
||||
tables: tablesDe([13, 11, 14, 12], 5),
|
||||
tours: 2,
|
||||
reservations: [tous(1, 12), tous(2, 13), tous(3, 12), tous(4, 14)],
|
||||
}));
|
||||
assert.deepEqual(instance.idsTables, [13, 11, 14, 12]);
|
||||
assert.equal(instance.indexTableDe.get(12), 3);
|
||||
assert.deepEqual(instance.capacite, Int32Array.from([5, 5, 5, 5]));
|
||||
assert.deepEqual(instance.ancresParTable, Int32Array.from([1, 0, 1, 2]));
|
||||
assert.equal(instance.k, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliser : exclusion (§ 4.4)', () => {
|
||||
test('un exclu disparaît de ids, figure dans exclus, et sa réservation est suspendue', () => {
|
||||
const instance = normaliser(configuration({
|
||||
participants: [...personnes([1, 2]), exclu(3), ...personnes([4])],
|
||||
tables: tablesDe([11, 12], 2),
|
||||
tours: 2,
|
||||
reservations: [tous(3, 12)],
|
||||
}));
|
||||
assert.equal(instance.N, 3);
|
||||
assert.deepEqual(instance.ids, [1, 2, 4]);
|
||||
assert.equal(instance.indexDe.has(3), false);
|
||||
assert.deepEqual([...instance.exclus], [3]);
|
||||
assert.equal(instance.k, 0);
|
||||
assert.equal(instance.n, 3);
|
||||
assert.deepEqual(instance.ancresParTable, Int32Array.from([0, 0]));
|
||||
assert.deepEqual(instance.fixe, new Int32Array(6).fill(LIBRE));
|
||||
assert.deepEqual(instance.statut, new Uint8Array(3).fill(MOBILE));
|
||||
});
|
||||
|
||||
test('la réservation d’un exclu rend sa place : elle n’entre pas dans la surréservation', () => {
|
||||
const participants = [...personnes([1, 2]), exclu(3)];
|
||||
const avec = (liste) => configuration({
|
||||
participants: liste,
|
||||
tables: tablesDe([11, 12], 2),
|
||||
tours: 2,
|
||||
reservations: [tous(1, 11), tous(2, 11), tous(3, 11)],
|
||||
});
|
||||
const instance = normaliser(avec(participants));
|
||||
assert.equal(instance.k, 2);
|
||||
assert.deepEqual(instance.ancresParTable, Int32Array.from([2, 0]));
|
||||
// Réintégré, le même participant fait déborder la table.
|
||||
const reintegres = participants.map((participant) => ({ ...participant, exclu: false }));
|
||||
assert.deepEqual(
|
||||
ecartsDeRefus([[
|
||||
'réintégration',
|
||||
() => normaliser(avec(reintegres)),
|
||||
'SURRESERVATION',
|
||||
{ table: 11, tour: 1, reservees: 3, capacite: 2 },
|
||||
]]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('la réservation suspendue d’un exclu n’interrompt pas la lecture : celles qui la suivent s’appliquent', () => {
|
||||
// En tête de liste, la réservation de l'exclu précède celles des présents :
|
||||
// un saut qui sortirait de la boucle au lieu de passer à la suivante les
|
||||
// perdrait toutes, sans bruit.
|
||||
const instance = normaliser(configuration({
|
||||
participants: [...personnes([1]), exclu(2)],
|
||||
tables: tablesDe([11, 12], 2),
|
||||
tours: 2,
|
||||
reservations: [tous(2, 11), tous(1, 12)],
|
||||
}));
|
||||
assert.equal(instance.k, 1);
|
||||
assert.deepEqual(instance.statut, Uint8Array.from([ANCRE]));
|
||||
assert.deepEqual(instance.fixe, Int32Array.from([1, 1]));
|
||||
assert.deepEqual(
|
||||
ecartsDeRefus([[
|
||||
'trois présents réservés à une table de 2, après l’exclu',
|
||||
() => normaliser(configuration({
|
||||
participants: [...personnes([1, 2, 3]), exclu(4)],
|
||||
tables: tablesDe([11, 12], 2),
|
||||
tours: 1,
|
||||
reservations: [tous(4, 11), tous(1, 11), tous(2, 11), tous(3, 11)],
|
||||
})),
|
||||
'SURRESERVATION',
|
||||
{ table: 11, tour: 1, reservees: 3, capacite: 2 },
|
||||
]]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('une réservation suspendue reste vérifiée dans sa forme, et n’a aucun effet', () => {
|
||||
const avec = (reservations) => configuration({
|
||||
participants: [...personnes([1, 2]), exclu(3)],
|
||||
tables: tablesDe([11, 12], 2),
|
||||
tours: 2,
|
||||
reservations,
|
||||
});
|
||||
// Deux tables au même tour : sans effet, donc sans conflit.
|
||||
assert.equal(normaliser(avec([tous(3, 11), auTour(3, 12, 1)])).k, 0);
|
||||
assert.deepEqual(
|
||||
ecartsDeRefus([
|
||||
['table inconnue', () => normaliser(avec([tous(3, 99)])),
|
||||
'RESERVATION_INCONNUE', { reservation: 0, table: 99 }],
|
||||
['tour hors de 1..R', () => normaliser(avec([auTour(3, 11, 3)])),
|
||||
'RESERVATION_TOUR', { reservation: 0, tour: 3 }],
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliser : appartenances', () => {
|
||||
test('les groupes se numérotent par première apparition en ids croissants, quel que soit l’ordre fourni', () => {
|
||||
const fournis = [
|
||||
{ id: 9, nom: 'P9', appartenance: 'Y' },
|
||||
{ id: 2, nom: 'P2', appartenance: 'Z' },
|
||||
{ id: 5, nom: 'P5', appartenance: 'X' },
|
||||
{ id: 7, nom: 'P7', appartenance: null },
|
||||
{ id: 3, nom: 'P3', appartenance: 'W', exclu: true },
|
||||
{ id: 4, nom: 'P4', appartenance: 'Z' },
|
||||
{ id: 1, nom: 'P1', appartenance: 'Y' },
|
||||
];
|
||||
const ordres = [fournis, [...fournis].reverse(), [3, 6, 0, 5, 1, 4, 2].map((i) => fournis[i])];
|
||||
assert.ok(ordres.length > 0, 'aucun ordre examiné');
|
||||
for (const participants of ordres) {
|
||||
const instance = normaliser(configuration({ participants, tables: tablesDe([11], 8), tours: 1 }));
|
||||
assert.deepEqual(instance.ids, [1, 2, 4, 5, 7, 9]);
|
||||
assert.deepEqual(instance.groupes, ['Y', 'Z', 'X']);
|
||||
assert.deepEqual(instance.groupe, Int32Array.from([0, 1, 1, 2, SANS_GROUPE, 0]));
|
||||
}
|
||||
});
|
||||
|
||||
test('l’égalité de chaînes fait le groupe : normaliser ne réconcilie rien', () => {
|
||||
// La chaîne vide est une appartenance comme une autre : seul null marque
|
||||
// l'absence d'appartenance.
|
||||
const participants = [
|
||||
{ id: 1, nom: 'P1', appartenance: 'X' },
|
||||
{ id: 2, nom: 'P2', appartenance: 'x' },
|
||||
{ id: 3, nom: 'P3', appartenance: 'X ' },
|
||||
{ id: 4, nom: 'P4', appartenance: 'X' },
|
||||
{ id: 5, nom: 'P5', appartenance: '' },
|
||||
{ id: 6, nom: 'P6', appartenance: null },
|
||||
{ id: 7, nom: 'P7', appartenance: '' },
|
||||
];
|
||||
const instance = normaliser(configuration({ participants, tables: tablesDe([11], 8), tours: 1 }));
|
||||
assert.deepEqual(instance.groupes, ['X', 'x', 'X ', '']);
|
||||
assert.deepEqual(instance.groupe, Int32Array.from([0, 1, 2, 0, 3, SANS_GROUPE, 3]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliser : la configuration reçue', () => {
|
||||
test('normaliser ne modifie pas la configuration reçue', () => {
|
||||
const recue = geler(configuration({
|
||||
participants: [...personnes([9, 2]), exclu(5), { id: 4, nom: 'P4', appartenance: 'X' }],
|
||||
tables: tablesDe([30, 10], 4),
|
||||
tours: 2,
|
||||
reservations: [tous(9, 30), auTour(2, 10, 2)],
|
||||
}));
|
||||
const instance = normaliser(recue);
|
||||
assert.deepEqual(instance.ids, [2, 4, 9]);
|
||||
assert.deepEqual(recue.participants.map((participant) => participant.id), [9, 2, 5, 4]);
|
||||
});
|
||||
|
||||
test('l’instance copie les contraintes au lieu de les partager', () => {
|
||||
const contraintes = { ...SANS_CONTRAINTE, separerAppartenances: true };
|
||||
const instance = normaliser(configuration({
|
||||
participants: personnes([1, 2]),
|
||||
tables: tablesDe([11], 2),
|
||||
tours: 1,
|
||||
contraintes,
|
||||
}));
|
||||
contraintes.separerAppartenances = false;
|
||||
assert.deepEqual(instance.contraintes, { ...SANS_CONTRAINTE, separerAppartenances: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliser : refus (§ 5.9, § 6.2)', () => {
|
||||
// Trois participants, deux tables de 2, deux tours : chaque cas n'enfreint
|
||||
// qu'une règle.
|
||||
const base = configuration({
|
||||
participants: personnes([1, 2, 3]),
|
||||
tables: tablesDe([11, 12], 2),
|
||||
tours: 2,
|
||||
});
|
||||
const avec = (modification) => () => normaliser({ ...base, ...modification });
|
||||
const capacite12 = (capacite) => ({ tables: [...tablesDe([11], 2), { id: 12, numero: 2, capacite }] });
|
||||
|
||||
test('chaque code d’erreur, sur un cas', () => {
|
||||
const cas = [
|
||||
['un participant en double', avec({ participants: personnes([1, 2, 2]) }),
|
||||
'PARTICIPANT_DOUBLON', { participant: 2 }],
|
||||
['un participant en double, dont un exclu', avec({ participants: [...personnes([1, 2]), exclu(2)] }),
|
||||
'PARTICIPANT_DOUBLON', { participant: 2 }],
|
||||
['une table en double', avec({ tables: tablesDe([11, 12, 11], 2) }),
|
||||
'TABLE_DOUBLON', { table: 11 }],
|
||||
...[1, 0, -3, 2.5, '8', Number.NaN, 2 ** 31].map((capacite) => [
|
||||
`une capacité ${libelleDe(capacite)}`, avec(capacite12(capacite)),
|
||||
'CAPACITE', { table: 12, capacite },
|
||||
]),
|
||||
...[0, -1, 1.5, '2', Number.NaN].map((tours) => [
|
||||
`${libelleDe(tours)} tours`, avec({ tours }), 'TOURS', { tours },
|
||||
]),
|
||||
// Le détail reservation est le rang dans configuration.reservations, à
|
||||
// partir de 0 : la réservation fautive se place au rang 0, puis au rang 1.
|
||||
// Un participant ou une table désignés par une valeur qui n'est pas un
|
||||
// identifiant de la configuration sont inconnus, quelle qu'en soit la
|
||||
// forme.
|
||||
['un participant inconnu', avec({ reservations: [tous(9, 11)] }),
|
||||
'RESERVATION_INCONNUE', { reservation: 0, participant: 9 }],
|
||||
['un participant inconnu au rang 1', avec({ reservations: [tous(1, 11), tous(9, 11)] }),
|
||||
'RESERVATION_INCONNUE', { reservation: 1, participant: 9 }],
|
||||
['un participant désigné en texte', avec({ reservations: [tous('1', 11)] }),
|
||||
'RESERVATION_INCONNUE', { reservation: 0, participant: '1' }],
|
||||
['une table inconnue', avec({ reservations: [tous(1, 11), auTour(2, 99, 1)] }),
|
||||
'RESERVATION_INCONNUE', { reservation: 1, table: 99 }],
|
||||
['une table désignée en texte', avec({ reservations: [tous(1, '11')] }),
|
||||
'RESERVATION_INCONNUE', { reservation: 0, table: '11' }],
|
||||
['un tour désigné absent', avec({ reservations: [{ participant: 1, table: 11, portee: 'tour' }] }),
|
||||
'RESERVATION_TOUR', { reservation: 0, tour: undefined }],
|
||||
...[0, 3, 1.5, '1'].map((tour) => [
|
||||
`le tour désigné ${libelleDe(tour)}`, avec({ reservations: [auTour(1, 11, tour)] }),
|
||||
'RESERVATION_TOUR', { reservation: 0, tour },
|
||||
]),
|
||||
['le tour désigné 3 au rang 1', avec({ reservations: [tous(1, 11), auTour(2, 12, 3)] }),
|
||||
'RESERVATION_TOUR', { reservation: 1, tour: 3 }],
|
||||
['deux tables au même tour désigné', avec({ reservations: [auTour(1, 11, 1), auTour(1, 12, 1)] }),
|
||||
'RESERVATION_CONFLIT', { participant: 1, tour: 1, tables: [11, 12] }],
|
||||
['la portée « tous » et un tour désigné ailleurs', avec({ reservations: [tous(1, 12), auTour(1, 11, 2)] }),
|
||||
'RESERVATION_CONFLIT', { participant: 1, tour: 2, tables: [12, 11] }],
|
||||
// Le conflit se découvre aussi en traitant une portée « tous » : elle ne
|
||||
// remplace jamais une table déjà imposée à l'un de ses tours.
|
||||
['un tour désigné puis la portée « tous » ailleurs', avec({ reservations: [auTour(1, 11, 2), tous(1, 12)] }),
|
||||
'RESERVATION_CONFLIT', { participant: 1, tour: 2, tables: [11, 12] }],
|
||||
['deux portées « tous » à deux tables', avec({ reservations: [tous(1, 11), tous(1, 12)] }),
|
||||
'RESERVATION_CONFLIT', { participant: 1, tour: 1, tables: [11, 12] }],
|
||||
['trois réservés à une table de 2 au tour 2',
|
||||
avec({ reservations: [tous(1, 11), tous(2, 11), auTour(3, 11, 2)] }),
|
||||
'SURRESERVATION', { table: 11, tour: 2, reservees: 3, capacite: 2 }],
|
||||
];
|
||||
assert.deepEqual(ecartsDeRefus(cas), []);
|
||||
});
|
||||
|
||||
test('une table entièrement gelée, k_t = c_t, est acceptée', () => {
|
||||
const instance = normaliser({ ...base, reservations: [tous(1, 11), tous(2, 11)] });
|
||||
assert.deepEqual(instance.ancresParTable, Int32Array.from([2, 0]));
|
||||
});
|
||||
|
||||
test('une réservation redondante compte une personne, pas deux', () => {
|
||||
const instance = normaliser({
|
||||
...base,
|
||||
reservations: [tous(1, 11), auTour(1, 11, 2), tous(1, 11), tous(2, 11)],
|
||||
});
|
||||
assert.equal(instance.k, 2);
|
||||
assert.deepEqual(instance.ancresParTable, Int32Array.from([2, 0]));
|
||||
});
|
||||
|
||||
test('SURRESERVATION se juge à chaque table et à chaque tour, contre la capacité de cette table', () => {
|
||||
// Trois tables hors de l'ordre de leurs identifiants, de capacités
|
||||
// distinctes, et T ≠ R. Cinq participants : la plus grande table, pleine
|
||||
// plus un, les fixe tous, le dernier compris.
|
||||
const tables = [
|
||||
{ id: 13, numero: 1, capacite: 3 },
|
||||
{ id: 11, numero: 2, capacite: 2 },
|
||||
{ id: 12, numero: 3, capacite: 4 },
|
||||
];
|
||||
const tours = 2;
|
||||
const fixer = (reservations) => () =>
|
||||
normaliser(configuration({ participants: personnes(suite(1, 5)), tables, tours, reservations }));
|
||||
// c_t + 1 personnes fixées à la table t, au tour r ou à chaque tour par la
|
||||
// portée « tous » : refusé, au premier tour qui déborde.
|
||||
const debordees = tables.flatMap(({ id, capacite }) => [
|
||||
...suite(1, tours).map((tour) => [
|
||||
`${capacite + 1} fixées à la table ${id} au tour ${tour}`,
|
||||
fixer(suite(1, capacite + 1).map((p) => auTour(p, id, tour))),
|
||||
'SURRESERVATION', { table: id, tour, reservees: capacite + 1, capacite },
|
||||
]),
|
||||
[`${capacite + 1} ancrées à la table ${id}`,
|
||||
fixer(suite(1, capacite + 1).map((p) => tous(p, id))),
|
||||
'SURRESERVATION', { table: id, tour: 1, reservees: capacite + 1, capacite }],
|
||||
]);
|
||||
assert.deepEqual(ecartsDeRefus(debordees), []);
|
||||
// c_t personnes fixées remplissent la table sans la déborder : accepté.
|
||||
const remplies = tables.flatMap(({ id, capacite }) => [
|
||||
...suite(1, tours).map((tour) => [
|
||||
`${capacite} fixées à la table ${id} au tour ${tour}`,
|
||||
fixer(suite(1, capacite).map((p) => auTour(p, id, tour))),
|
||||
]),
|
||||
[`${capacite} ancrées à la table ${id}`, fixer(suite(1, capacite).map((p) => tous(p, id)))],
|
||||
]);
|
||||
assert.ok(remplies.length > 0, 'aucun cas examiné');
|
||||
const refusees = [];
|
||||
for (const [libelle, fonction] of remplies) {
|
||||
try {
|
||||
fonction();
|
||||
} catch (erreur) {
|
||||
refusees.push(`${libelle} : ${erreur?.message}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(refusees, []);
|
||||
});
|
||||
|
||||
test('une capacité de 2³¹ − 1, le plus grand entier d’un Int32Array, est acceptée', () => {
|
||||
const instance = normaliser({ ...base, ...capacite12(2 ** 31 - 1) });
|
||||
assert.equal(instance.capacite[1], 2 ** 31 - 1);
|
||||
});
|
||||
|
||||
test('une valeur hors du contrat de données lève TypeError', () => {
|
||||
const cas = [
|
||||
['identifiant de participant 0', { participants: personnes([0, 1]) }],
|
||||
['identifiant de participant 1.5', { participants: personnes([1.5]) }],
|
||||
['identifiant de participant en texte', { participants: personnes(['1']) }],
|
||||
['identifiant de table 0', { tables: tablesDe([0, 12], 2) }],
|
||||
['identifiant de table en texte', { tables: tablesDe(['11', 12], 2) }],
|
||||
['appartenance absente', { participants: [{ id: 1, nom: 'P1' }] }],
|
||||
['appartenance numérique', { participants: [{ id: 1, nom: 'P1', appartenance: 7 }] }],
|
||||
...['oui', '', 0, null].map((valeur) => [
|
||||
`exclusion ${libelleDe(valeur)}`,
|
||||
{ participants: [{ id: 1, nom: 'P1', appartenance: null, exclu: valeur }] },
|
||||
]),
|
||||
['portée inconnue', { reservations: [{ participant: 1, table: 11, portee: 'chaque' }] }],
|
||||
['contraintes absentes', { contraintes: undefined }],
|
||||
['une contrainte absente', {
|
||||
contraintes: { separerAppartenances: true, nouveauxVoisins: false, nouvelleTable: false },
|
||||
}],
|
||||
['une contrainte non booléenne', { contraintes: { ...SANS_CONTRAINTE, nouvelleTable: 1 } }],
|
||||
// Une liste absente, puis une valeur qui n'est pas un tableau mais que le
|
||||
// code parcourrait sans bruit : un ensemble s'itère, et un objet sans
|
||||
// longueur ne fait tourner aucune boucle.
|
||||
['participants absents', { participants: undefined }],
|
||||
['participants en ensemble', { participants: new Set(personnes([1, 2])) }],
|
||||
['tables absentes', { tables: undefined }],
|
||||
['tables en objet', { tables: {} }],
|
||||
['réservations absentes', { reservations: undefined }],
|
||||
['réservations en objet', { reservations: {} }],
|
||||
];
|
||||
const ecarts = [];
|
||||
for (const [libelle, modification] of cas) {
|
||||
try {
|
||||
normaliser({ ...base, ...modification });
|
||||
ecarts.push(`${libelle} : aucun refus`);
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof TypeError)) {
|
||||
ecarts.push(`${libelle} : ${erreur?.name} au lieu de TypeError`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(cas.length > 0, 'aucun cas examiné');
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nombrePlaces (§ 5.9)', () => {
|
||||
test('somme les capacités : ni les participants, ni les exclus, ni les réservations ne la changent', () => {
|
||||
const tables = [
|
||||
{ id: 13, numero: 1, capacite: 3 },
|
||||
{ id: 11, numero: 2, capacite: 2 },
|
||||
{ id: 12, numero: 3, capacite: 7 },
|
||||
];
|
||||
const salle = (participants, reservations = []) =>
|
||||
normaliser(configuration({ participants, tables, tours: 2, reservations }));
|
||||
assert.equal(nombrePlaces(salle(personnes(suite(1, 4)))), 12);
|
||||
assert.equal(nombrePlaces(salle(personnes(suite(1, 20)))), 12);
|
||||
assert.equal(nombrePlaces(salle([], [])), 12);
|
||||
assert.equal(
|
||||
nombrePlaces(salle([...personnes(suite(1, 4)), exclu(5)], [tous(1, 13), auTour(2, 12, 2), tous(5, 11)])),
|
||||
12,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nombrePlacesManquantes (§ 5.9)', () => {
|
||||
const salle = (reservations = [], participants = personnes(suite(1, 15))) =>
|
||||
normaliser(configuration({ participants, tables: tablesDe([11, 12, 13], 4), tours: 2, reservations }));
|
||||
|
||||
// Places manquantes lues sur l'instance réduite du § 5.2 : n − Σ (c_t − a_t),
|
||||
// l'opposé de l'écart Σ (c_t − k_t) − n du § 5.9.
|
||||
function placesManquantesReduites(instance) {
|
||||
let placesLibres = 0;
|
||||
for (let t = 0; t < instance.T; t += 1) {
|
||||
placesLibres += instance.capacite[t] - instance.ancresParTable[t];
|
||||
}
|
||||
return instance.n - placesLibres;
|
||||
}
|
||||
|
||||
test('vaut 3 pour trois tables de 4 et quinze personnes, que normaliser accepte', () => {
|
||||
assert.equal(nombrePlacesManquantes(salle()), 3);
|
||||
});
|
||||
|
||||
test('ne change pas quand on ajoute ou déplace une réservation', () => {
|
||||
const variantes = [
|
||||
[],
|
||||
[tous(1, 11)],
|
||||
[tous(1, 12)],
|
||||
[auTour(1, 11, 1)],
|
||||
[auTour(1, 11, 1), auTour(1, 12, 2)],
|
||||
[tous(1, 11), auTour(2, 12, 2), tous(3, 13), tous(4, 13)],
|
||||
];
|
||||
assert.ok(variantes.length > 0, 'aucune variante examinée');
|
||||
for (const reservations of variantes) {
|
||||
const instance = salle(reservations);
|
||||
assert.equal(nombrePlacesManquantes(instance), 3, JSON.stringify(reservations));
|
||||
assert.equal(placesManquantesReduites(instance), 3, JSON.stringify(reservations));
|
||||
}
|
||||
});
|
||||
|
||||
test('vaut 0, jamais un nombre négatif, quand la salle suffit', () => {
|
||||
assert.equal(nombrePlacesManquantes(salle([], personnes(suite(1, 12)))), 0);
|
||||
assert.equal(nombrePlacesManquantes(salle([], personnes(suite(1, 10)))), 0);
|
||||
});
|
||||
|
||||
test('un exclu ne compte pas : sa réintégration fait manquer une place', () => {
|
||||
const participants = [...personnes(suite(1, 12)), exclu(13)];
|
||||
assert.equal(nombrePlacesManquantes(salle([], participants)), 0);
|
||||
const reintegres = participants.map((participant) => ({ ...participant, exclu: false }));
|
||||
assert.equal(nombrePlacesManquantes(salle([], reintegres)), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plans : forme par identifiants et forme indexée (§ 8.9)', () => {
|
||||
// Quatre participants placés, fournis dans le désordre, un exclu ; trois
|
||||
// tables hors de l'ordre de leurs identifiants ; deux tours. T ≠ R : la
|
||||
// disposition p × R + r se distingue de p × T + r, et la longueur N × R de
|
||||
// N × T.
|
||||
function instancePlans() {
|
||||
return normaliser(configuration({
|
||||
participants: [...personnes([3, 8]), exclu(5), ...personnes([11, 6])],
|
||||
tables: tablesDe([40, 20, 30], 2),
|
||||
tours: 2,
|
||||
}));
|
||||
}
|
||||
|
||||
// tableDe[p × R + r], p dans l'ordre des ids [3, 6, 8, 11], et le plan par
|
||||
// identifiants qui lui correspond. Chaque table reçoit quelqu'un, une table
|
||||
// reste vide à un tour, et une liste de table comme une réserve portent
|
||||
// deux ids, dont l'ordre croissant se voit.
|
||||
const TABLE_DE = [
|
||||
RESERVE, 0,
|
||||
RESERVE, 1,
|
||||
0, 2,
|
||||
2, 1,
|
||||
];
|
||||
const PLAN = {
|
||||
tables: [40, 20, 30],
|
||||
tours: [[[8], [], [11]], [[3], [6, 11], [8]]],
|
||||
reserves: [[3, 6], []],
|
||||
};
|
||||
|
||||
test('planDepuisIndex rend des ids croissants par table et dans la réserve', () => {
|
||||
const instance = instancePlans();
|
||||
const plan = planDepuisIndex(instance, Int32Array.from(TABLE_DE));
|
||||
assert.deepEqual(plan, PLAN);
|
||||
// Une liste ordinaire, gelée, convient aussi et reste intacte.
|
||||
assert.deepEqual(planDepuisIndex(instance, geler([...TABLE_DE])), PLAN);
|
||||
const listes = [...plan.tours.flat(), ...plan.reserves];
|
||||
assert.ok(listes.length > 0, 'aucune liste examinée');
|
||||
for (const liste of listes) {
|
||||
assert.deepEqual(liste, [...liste].sort((a, b) => a - b));
|
||||
}
|
||||
// Le plan rendu a sa propre liste de tables : la modifier laisse
|
||||
// l'instance intacte.
|
||||
plan.tables.reverse();
|
||||
assert.deepEqual(instance.idsTables, [40, 20, 30]);
|
||||
});
|
||||
|
||||
test('indexerPlan ∘ planDepuisIndex est l’identité sur chaque plan indexé de l’instance', () => {
|
||||
const instance = instancePlans();
|
||||
const cases = instance.N * instance.R;
|
||||
// Chaque case prend une valeur de RESERVE, −1, à T − 1.
|
||||
const valeurs = instance.T + 1;
|
||||
let examines = 0;
|
||||
for (let rang = 0; rang < valeurs ** cases; rang += 1) {
|
||||
const tableDe = new Int32Array(cases);
|
||||
for (let i = 0, reste = rang; i < cases; i += 1, reste = Math.floor(reste / valeurs)) {
|
||||
tableDe[i] = RESERVE + (reste % valeurs);
|
||||
}
|
||||
const retour = indexerPlan(instance, planDepuisIndex(instance, tableDe));
|
||||
if (retour.length !== tableDe.length || retour.some((valeur, i) => valeur !== tableDe[i])) {
|
||||
assert.deepEqual(retour, tableDe);
|
||||
}
|
||||
examines += 1;
|
||||
}
|
||||
assert.equal(examines, 4 ** 8);
|
||||
});
|
||||
|
||||
test('indexerPlan lit chaque table par son identifiant, dans l’ordre que le plan déclare', () => {
|
||||
const instance = instancePlans();
|
||||
assert.deepEqual(indexerPlan(instance, geler(structuredClone(PLAN))), Int32Array.from(TABLE_DE));
|
||||
// Le même plan, ses tables déclarées dans un autre ordre, chaque liste et
|
||||
// chaque réserve dans le désordre.
|
||||
const permute = {
|
||||
tables: [30, 40, 20],
|
||||
tours: [[[11], [8], []], [[8], [3], [11, 6]]],
|
||||
reserves: [[6, 3], []],
|
||||
};
|
||||
const tableDe = indexerPlan(instance, geler(permute));
|
||||
assert.deepEqual(tableDe, Int32Array.from(TABLE_DE));
|
||||
assert.deepEqual(planDepuisIndex(instance, tableDe), PLAN);
|
||||
});
|
||||
|
||||
test('indexerPlan refuse ce que la forme indexée ne représente pas', () => {
|
||||
const instance = instancePlans();
|
||||
const variante = (modifier) => () => {
|
||||
const plan = structuredClone(PLAN);
|
||||
modifier(plan);
|
||||
return indexerPlan(instance, plan);
|
||||
};
|
||||
const cas = [
|
||||
['un participant inconnu', variante((plan) => plan.tours[0][0].push(99)),
|
||||
'PLAN_INCONNU', { participant: 99, tour: 1 }],
|
||||
['un participant inconnu en réserve', variante((plan) => plan.reserves[1].push(99)),
|
||||
'PLAN_INCONNU', { participant: 99, tour: 2 }],
|
||||
['un participant exclu', variante((plan) => plan.tours[1][1].unshift(5)),
|
||||
'PLAN_EXCLU_PLACE', { participant: 5, tour: 2 }],
|
||||
['un participant exclu en réserve', variante((plan) => plan.reserves[0].push(5)),
|
||||
'PLAN_EXCLU_PLACE', { participant: 5, tour: 1 }],
|
||||
['une table inconnue', variante((plan) => { plan.tables[1] = 50; }),
|
||||
'PLAN_TABLE_INCONNUE', { table: 50 }],
|
||||
['une table en double', variante((plan) => { plan.tables[1] = 30; }),
|
||||
'PLAN_TABLE_DOUBLON', { table: 30 }],
|
||||
// La table 30 retirée du plan, ses occupants passés en réserve : le plan
|
||||
// reste cohérent avec lui-même, mais ne reprend plus chaque table de
|
||||
// l'instance.
|
||||
['une table non déclarée', variante((plan) => {
|
||||
plan.tables.pop();
|
||||
plan.tours.forEach((listes, r) => plan.reserves[r].push(...listes.pop()));
|
||||
}), 'PLAN_TABLE_ABSENTE', { table: 30 }],
|
||||
// Aucune table déclarée, chacun en réserve : des trois absentes, la
|
||||
// première dans l'ordre de l'instance est nommée.
|
||||
['aucune table déclarée', variante((plan) => {
|
||||
plan.tables = [];
|
||||
plan.tours = [[], []];
|
||||
plan.reserves = [[3, 6, 8, 11], [3, 6, 8, 11]];
|
||||
}), 'PLAN_TABLE_ABSENTE', { table: 40 }],
|
||||
['un tour de moins', variante((plan) => plan.tours.pop()),
|
||||
'PLAN_TOURS', { attendu: 2, tours: 1, reserves: 2 }],
|
||||
['un tour de trop', variante((plan) => plan.tours.push([[], [], []])),
|
||||
'PLAN_TOURS', { attendu: 2, tours: 3, reserves: 2 }],
|
||||
['une réserve de trop', variante((plan) => plan.reserves.push([])),
|
||||
'PLAN_TOURS', { attendu: 2, tours: 2, reserves: 3 }],
|
||||
['une liste de table de trop', variante((plan) => plan.tours[1].push([])),
|
||||
'PLAN_LISTES', { tour: 2, listes: 4, tables: 3 }],
|
||||
['une liste de table de moins', variante((plan) => plan.tours[1].pop()),
|
||||
'PLAN_LISTES', { tour: 2, listes: 2, tables: 3 }],
|
||||
['assis et en réserve', variante((plan) => plan.reserves[0].unshift(8)),
|
||||
'PLAN_DOUBLE_PLACE', { participant: 8, tour: 1 }],
|
||||
['deux fois à une table', variante((plan) => plan.tours[1][1].push(6)),
|
||||
'PLAN_DOUBLE_PLACE', { participant: 6, tour: 2 }],
|
||||
['à deux tables d’un même tour', variante((plan) => plan.tours[0][1].push(8)),
|
||||
'PLAN_DOUBLE_PLACE', { participant: 8, tour: 1 }],
|
||||
['deux fois en réserve', variante((plan) => plan.reserves[0].push(3)),
|
||||
'PLAN_DOUBLE_PLACE', { participant: 3, tour: 1 }],
|
||||
['absent d’un tour', variante((plan) => plan.reserves[0].pop()),
|
||||
'PLAN_NON_ASSIS', { participant: 6, tour: 1 }],
|
||||
];
|
||||
assert.deepEqual(ecartsDeRefus(cas), []);
|
||||
});
|
||||
|
||||
test('indexerPlan refuse quiconque manque à un tour : chaque participant, à chaque tour', () => {
|
||||
const instance = instancePlans();
|
||||
// Chaque id de l'instance, du premier index au dernier, retiré de la
|
||||
// liste qui le porte à ce tour, table ou réserve.
|
||||
const cas = [3, 6, 8, 11].flatMap((id) => [1, 2].map((tour) => [
|
||||
`${id} retiré du tour ${tour}`,
|
||||
() => {
|
||||
const plan = structuredClone(PLAN);
|
||||
for (const liste of [...plan.tours[tour - 1], plan.reserves[tour - 1]]) {
|
||||
if (liste.includes(id)) liste.splice(liste.indexOf(id), 1);
|
||||
}
|
||||
return indexerPlan(instance, plan);
|
||||
},
|
||||
'PLAN_NON_ASSIS',
|
||||
{ participant: id, tour },
|
||||
]));
|
||||
assert.deepEqual(ecartsDeRefus(cas), []);
|
||||
});
|
||||
|
||||
// TABLE_DE dont les cases nommées reçoivent une autre valeur : fautes est
|
||||
// une liste de couples [case, valeur].
|
||||
const avecFautes = (fautes) => {
|
||||
const tableDe = [...TABLE_DE];
|
||||
for (const [i, valeur] of fautes) tableDe[i] = valeur;
|
||||
return tableDe;
|
||||
};
|
||||
|
||||
// Plans indexés hors de la forme de l'instance, et le message qui les
|
||||
// refuse. Les ids [3, 6, 8, 11] occupent les index 0 à 3 ; la case p × 2 + r
|
||||
// est celle de l'index p au tour r + 1. Trois tables : un index de table va
|
||||
// de 0 à 2. Une chaîne numérique n'est pas un index, et se cite entre
|
||||
// guillemets. Deux cases fautives : la première dans l'ordre des cases est
|
||||
// nommée, la case 1 (3 au tour 2) avant la case 2 (6 au tour 1).
|
||||
const FAUTIFS = [
|
||||
['longueur N × R − 1', TABLE_DE.slice(1), 'plan indexé de 7 cases, N × R = 8 attendues'],
|
||||
['longueur N × R + 1', [...TABLE_DE, RESERVE], 'plan indexé de 9 cases, N × R = 8 attendues'],
|
||||
['longueur N × T', Array(12).fill(RESERVE), 'plan indexé de 12 cases, N × R = 8 attendues'],
|
||||
['index de table T', avecFautes([[0, 3]]), 'participant 3, tour 1, index de table 3'],
|
||||
['index −2', avecFautes([[3, -2]]), 'participant 6, tour 2, index de table -2'],
|
||||
['index non entier', avecFautes([[5, 0.5]]), 'participant 8, tour 2, index de table 0.5'],
|
||||
['index null', avecFautes([[4, null]]), 'participant 8, tour 1, index de table null'],
|
||||
['chaîne "0"', avecFautes([[6, '0']]), 'participant 11, tour 1, index de table "0"'],
|
||||
['chaîne "-1"', avecFautes([[7, '-1']]), 'participant 11, tour 2, index de table "-1"'],
|
||||
['deux cases fautives', avecFautes([[2, 9], [1, 9]]), 'participant 3, tour 2, index de table 9'],
|
||||
].map(([libelle, tableDe, message]) => [
|
||||
libelle,
|
||||
tableDe,
|
||||
message.startsWith('plan indexé de') ? message : `plan indexé : ${message} hors de −1..2`,
|
||||
]);
|
||||
|
||||
// « Nom : message » de l'erreur que lève fonction, « aucun refus » sinon.
|
||||
function erreurDe(fonction) {
|
||||
try {
|
||||
fonction();
|
||||
} catch (erreur) {
|
||||
return `${erreur?.name} : ${erreur?.message}`;
|
||||
}
|
||||
return 'aucun refus';
|
||||
}
|
||||
|
||||
test('exigerPlanIndexe accepte un plan indexé de la forme de l’instance, Int32Array ou liste, sans le modifier', () => {
|
||||
const instance = instancePlans();
|
||||
assert.equal(exigerPlanIndexe(instance, Int32Array.from(TABLE_DE)), undefined);
|
||||
assert.equal(exigerPlanIndexe(instance, geler([...TABLE_DE])), undefined);
|
||||
});
|
||||
|
||||
test('exigerPlanIndexe refuse un plan indexé hors forme : RangeError qui nomme le participant, le tour et la valeur de la première case fautive', () => {
|
||||
const instance = instancePlans();
|
||||
assert.ok(FAUTIFS.length > 0, 'aucune variante examinée');
|
||||
const ecarts = [];
|
||||
for (const [libelle, tableDe, message] of FAUTIFS) {
|
||||
const recue = erreurDe(() => exigerPlanIndexe(instance, tableDe));
|
||||
const attendue = `RangeError : ${message}`;
|
||||
if (recue !== attendue) ecarts.push(`${libelle} : « ${recue} » au lieu de « ${attendue} »`);
|
||||
}
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('une seule garde : planDepuisIndex, mesurer et plafondsRealises lèvent l’erreur de exigerPlanIndexe, au même message', () => {
|
||||
const instance = instancePlans();
|
||||
const appelants = [
|
||||
['planDepuisIndex', (tableDe) => planDepuisIndex(instance, tableDe)],
|
||||
['mesurer', (tableDe) => mesurer(instance, tableDe)],
|
||||
['plafondsRealises', (tableDe) => plafondsRealises(instance, tableDe)],
|
||||
];
|
||||
const ecarts = [];
|
||||
let confrontees = 0;
|
||||
for (const [libelle, tableDe] of FAUTIFS) {
|
||||
const attendue = erreurDe(() => exigerPlanIndexe(instance, tableDe));
|
||||
for (const [nom, appeler] of appelants) {
|
||||
confrontees += 1;
|
||||
const recue = erreurDe(() => appeler(tableDe));
|
||||
if (recue !== attendue) ecarts.push(`${nom}, ${libelle} : « ${recue} » au lieu de « ${attendue} »`);
|
||||
}
|
||||
}
|
||||
assert.equal(confrontees, 3 * FAUTIFS.length);
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replierParPopulation (§ 5.4)', () => {
|
||||
test('chacun compte dans tous, puis parmi les ancrés ou parmi les mobiles, partiellement fixés compris, dans l’ordre canonique', () => {
|
||||
const statut = Uint8Array.from([ANCRE, MOBILE, PARTIELLEMENT_FIXE, ANCRE, MOBILE, PARTIELLEMENT_FIXE]);
|
||||
const enListe = (cumul, valeur) => [...cumul, valeur];
|
||||
assert.deepEqual(replierParPopulation([10, 11, 12, 13, 14, 15], statut, [], enListe), {
|
||||
tous: [10, 11, 12, 13, 14, 15],
|
||||
mobiles: [11, 12, 14, 15],
|
||||
ancres: [10, 13],
|
||||
});
|
||||
});
|
||||
|
||||
test('les trois populations partent de la valeur initiale ; une population sans membre la garde', () => {
|
||||
const plusGrand = (cumul, valeur) => (cumul === null || valeur > cumul ? valeur : cumul);
|
||||
assert.deepEqual(
|
||||
replierParPopulation([4, 9], Uint8Array.from([MOBILE, PARTIELLEMENT_FIXE]), null, plusGrand),
|
||||
{ tous: 9, mobiles: 9, ancres: null },
|
||||
);
|
||||
assert.deepEqual(
|
||||
replierParPopulation([3], Uint8Array.from([ANCRE]), null, plusGrand),
|
||||
{ tous: 3, mobiles: null, ancres: 3 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
replierParPopulation([], new Uint8Array(0), 'vide', plusGrand),
|
||||
{ tous: 'vide', mobiles: 'vide', ancres: 'vide' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('erreurs du moteur', () => {
|
||||
test('ErreurConfiguration porte son code et ses détails', () => {
|
||||
const erreur = new ErreurConfiguration('CAPACITE', { table: 12, capacite: 1 });
|
||||
assert.ok(erreur instanceof Error);
|
||||
assert.equal(erreur.name, 'ErreurConfiguration');
|
||||
assert.equal(erreur.code, 'CAPACITE');
|
||||
assert.deepEqual(erreur.details, { table: 12, capacite: 1 });
|
||||
assert.match(erreur.message, /CAPACITE/);
|
||||
assert.deepEqual(new ErreurConfiguration('TOURS').details, {});
|
||||
});
|
||||
|
||||
test('ErreurAnnulee est une Error qui porte son nom', () => {
|
||||
const erreur = new ErreurAnnulee();
|
||||
assert.ok(erreur instanceof Error);
|
||||
assert.equal(erreur.name, 'ErreurAnnulee');
|
||||
});
|
||||
});
|
||||
285
src/moteur/diagnostic.js
Normal file
285
src/moteur/diagnostic.js
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Diagnostic d'une configuration, avant toute recherche (§ 5.6) : ce que la
|
||||
// configuration impose à tout plan, chiffré. Un plancher est une borne
|
||||
// inférieure démontrée sur les plans que produit la recherche, ceux qui
|
||||
// assoient chacun à chaque tour et honorent les réservations ; un plan édité
|
||||
// à la main qui laisse quelqu'un en réserve (§ 12.6) peut mesurer moins. Un
|
||||
// plancher s'écrit là où le diagnostic sait le prouver, et vaut null
|
||||
// ailleurs, jamais 0 par défaut.
|
||||
//
|
||||
// Les quantités que le moteur calcule déjà viennent de leur unique
|
||||
// implémentation (§ 13.2) : nombre de places et places manquantes de
|
||||
// configuration.js, plafond a priori de plafond.js, retours imposés de la
|
||||
// mesure d'indicateurs.js. Le diagnostic ne modifie pas la configuration et
|
||||
// lève ce que normaliser lève.
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlancherCollisions
|
||||
* @property {string} groupe libellé de l'appartenance
|
||||
* @property {number} effectif membres présents
|
||||
* @property {number} plancherCumulees collisions cumulées, sur les R tours
|
||||
* @property {number} plancherPairesDistinctes
|
||||
* @property {number|null} plancherExcedent null quand il n'est pas
|
||||
* strictement positif
|
||||
*
|
||||
* @typedef {Object} Diagnostic
|
||||
* @property {number} placesManquantes max(0, N − Σ c_t), par tour
|
||||
* @property {{min: number|null, max: number|null, parPersonne: number[]}} plafondAPriori
|
||||
* parPersonne dans l'ordre de instance.ids ; min et max null pour
|
||||
* une population vide
|
||||
* @property {PlancherCollisions[]} collisions groupes dont le plancher de
|
||||
* collisions cumulées est > 0, dans l'ordre de instance.groupes
|
||||
* @property {number|null} redondanceMinimale null sans affilié
|
||||
* @property {boolean} animateursMemeAppartenance
|
||||
* @property {number} redondanceImposeeParAnimateurs min(R, T) − 1 ou 0
|
||||
* @property {Array<{table: number, ancres: number, capacite: number, gelee: boolean}>} ancrage
|
||||
* chaque table, par identifiant, dans l'ordre de la configuration ;
|
||||
* gelee quand ses ancrés occupent tous ses sièges
|
||||
* @property {number} retoursImposes
|
||||
* @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire
|
||||
*/
|
||||
import {
|
||||
LIBRE,
|
||||
SANS_GROUPE,
|
||||
STATUT,
|
||||
nombrePlaces,
|
||||
nombrePlacesManquantes,
|
||||
normaliser,
|
||||
} from './configuration.js';
|
||||
import { mesurer } from './indicateurs.js';
|
||||
import { plafondsAPriori } from './plafond.js';
|
||||
|
||||
// Paires que forment n personnes : C(n, 2).
|
||||
const paires = (n) => (n * (n - 1)) / 2;
|
||||
|
||||
// Plus petit nombre de paires d'un groupe qu'un tour réunit. poses[t] compte
|
||||
// les membres que les réservations assoient à la table t ce tour-là ; libres,
|
||||
// les autres. Chaque libre va, l'un après l'autre, à la table qui compte le
|
||||
// moins de membres, la première par index à égalité, et y forme une paire
|
||||
// avec chacun. Le coût Σ C(x_t, 2) est séparable et convexe : ce remplissage
|
||||
// glouton en donne le minimum sous la seule contrainte x_t ≥ poses[t]. Les
|
||||
// capacités n'y entrent pas : relâchées, elles laissent un minimum qui minore
|
||||
// celui de tout plan. Sans table, personne ne s'assied et aucune paire ne se
|
||||
// forme.
|
||||
function pairesMinimales(poses, libres) {
|
||||
const T = poses.length;
|
||||
if (T === 0) return 0;
|
||||
const membres = Int32Array.from(poses);
|
||||
for (let i = 0; i < libres; i += 1) {
|
||||
let moins = 0;
|
||||
for (let t = 1; t < T; t += 1) if (membres[t] < membres[moins]) moins = t;
|
||||
membres[moins] += 1;
|
||||
}
|
||||
let total = 0;
|
||||
for (let t = 0; t < T; t += 1) total += paires(membres[t]);
|
||||
return total;
|
||||
}
|
||||
|
||||
// Plancher de collisions de chaque groupe (§ 5.6), dans l'ordre de
|
||||
// instance.groupes ; un groupe n'y figure que si son plancher de collisions
|
||||
// cumulées est positif. À chaque tour, les membres que les réservations fixent
|
||||
// à ce tour, ancrés comme partiellement fixés, sont posés à leur table, et
|
||||
// pairesMinimales place les autres. Les paires d'un même tour sont
|
||||
// distinctes : le plus grand minimum d'un tour minore les paires distinctes,
|
||||
// la somme des minimums les collisions cumulées. Un groupe de s membres
|
||||
// n'offre que C(s, 2) paires distinctes : l'excédent, cumulées − distinctes,
|
||||
// ne descend pas sous plancherCumulees − C(s, 2), écrit quand c'est positif.
|
||||
function planchersCollisions({ N, T, R, groupe, groupes, fixe }) {
|
||||
const membres = groupes.map(() => []);
|
||||
for (let p = 0; p < N; p += 1) if (groupe[p] !== SANS_GROUPE) membres[groupe[p]].push(p);
|
||||
const planchers = [];
|
||||
for (let g = 0; g < groupes.length; g += 1) {
|
||||
let cumulees = 0;
|
||||
let distinctes = 0;
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const poses = new Int32Array(T);
|
||||
let libres = 0;
|
||||
for (const p of membres[g]) {
|
||||
const t = fixe[p * R + r];
|
||||
if (t === LIBRE) libres += 1;
|
||||
else poses[t] += 1;
|
||||
}
|
||||
const minimum = pairesMinimales(poses, libres);
|
||||
cumulees += minimum;
|
||||
distinctes = Math.max(distinctes, minimum);
|
||||
}
|
||||
if (cumulees === 0) continue;
|
||||
const excedent = cumulees - paires(membres[g].length);
|
||||
planchers.push({
|
||||
groupe: groupes[g],
|
||||
effectif: membres[g].length,
|
||||
plancherCumulees: cumulees,
|
||||
plancherPairesDistinctes: distinctes,
|
||||
plancherExcedent: excedent > 0 ? excedent : null,
|
||||
});
|
||||
}
|
||||
return planchers;
|
||||
}
|
||||
|
||||
// Redondance minimale (§ 5.6), r_min(p) = max(0, |F(p)| − G) : A(p) ne
|
||||
// dépasse pas G, et r(p) = |F(p)| − A(p). Elle s'évalue au plus grand |F(p)|
|
||||
// possible, le plus petit du plafond a priori de p et du nombre d'affiliés
|
||||
// autres que lui : elle minore la redondance de qui rencontre autant
|
||||
// d'affiliés, non celle d'un plan qui lui en fait rencontrer moins. Rend le
|
||||
// plus grand r_min(p) sur tous les participants ; null quand aucun n'est
|
||||
// affilié.
|
||||
function redondanceMinimale({ N, groupe, groupes }, aPriori) {
|
||||
const G = groupes.length;
|
||||
if (G === 0) return null;
|
||||
let affilies = 0;
|
||||
for (let p = 0; p < N; p += 1) if (groupe[p] !== SANS_GROUPE) affilies += 1;
|
||||
let plusGrande = 0;
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
const autres = groupe[p] === SANS_GROUPE ? affilies : affilies - 1;
|
||||
plusGrande = Math.max(plusGrande, Math.min(aPriori[p], autres) - G);
|
||||
}
|
||||
return plusGrande;
|
||||
}
|
||||
|
||||
// Vrai quand les ancrés, les animateurs, sont au moins deux et portent tous
|
||||
// la même appartenance déclarée (§ 5.6).
|
||||
function animateursMemeAppartenance({ N, statut, groupe }) {
|
||||
let commun = SANS_GROUPE;
|
||||
let animateurs = 0;
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
if (statut[p] !== STATUT.ANCRE) continue;
|
||||
if (groupe[p] === SANS_GROUPE || (animateurs > 0 && groupe[p] !== commun)) return false;
|
||||
commun = groupe[p];
|
||||
animateurs += 1;
|
||||
}
|
||||
return animateurs >= 2;
|
||||
}
|
||||
|
||||
// Ancrés et capacité de chaque table, dans l'ordre de l'instance, qui est
|
||||
// celui de la configuration ; gelee quand les ancrés occupent tous les sièges
|
||||
// (§ 5.9).
|
||||
function ancrage({ T, idsTables, capacite, ancresParTable }) {
|
||||
return Array.from({ length: T }, (_, t) => ({
|
||||
table: idsTables[t],
|
||||
ancres: ancresParTable[t],
|
||||
capacite: capacite[t],
|
||||
gelee: ancresParTable[t] === capacite[t],
|
||||
}));
|
||||
}
|
||||
|
||||
// Plafond du meilleur itinéraire d'un mobile p qui passe par la table t : le
|
||||
// plafond a priori de p, la table t réservée au tour 1. La formule ne lit pas
|
||||
// l'ordre des tours. Pour R ≥ 2, la réservation d'un seul tour rend p
|
||||
// partiellement fixé (§ 4.2), ce qui ne change ni n ni les ancrés (§ 5.9) :
|
||||
// seul l'itinéraire est contraint. Pour R = 1, elle couvre chacun des R tours
|
||||
// et ancre p à la table t : a_t vu de p l'exclut, et n_p, le n de la sonde,
|
||||
// vaut n − 1 comme pour un mobile ; le plafond est celui du même itinéraire.
|
||||
// Sans partiellement fixé, seuls les ancrés occupent t au tour 1 ; une table
|
||||
// où ils laissent un siège reçoit la réservation sans que normaliser lève
|
||||
// SURRESERVATION.
|
||||
function plafondPassantPar(configuration, instance, p, t) {
|
||||
const sonde = normaliser({
|
||||
...configuration,
|
||||
reservations: [
|
||||
...configuration.reservations,
|
||||
{ participant: instance.ids[p], table: instance.idsTables[t], portee: 'tour', tour: 1 },
|
||||
],
|
||||
});
|
||||
return plafondsAPriori(sonde)[p];
|
||||
}
|
||||
|
||||
// Plancher de l'écart d'itinéraire maximal (§ 12.10.4), écrit pour une
|
||||
// configuration tendue, Σ c_t = N, sans partiellement fixé ; null ailleurs.
|
||||
// Là, tout plan qui assied chacun remplit chaque siège à chaque tour :
|
||||
// l'occupation vaut la capacité, et le plafond réalisé d'un mobile vaut le
|
||||
// plafond de son itinéraire. Les mobiles partagent alors un plafond a priori
|
||||
// P*. Une table est inférieure quand le meilleur itinéraire qui passe par elle
|
||||
// vaut P*_t < P*. Ses c_t − k_t sièges mobiles reçoivent un mobile à chacun
|
||||
// des R tours : M_inf sièges-tours sur les tables inférieures, dont un mobile
|
||||
// n'occupe que R au plus. Au moins ⌈M_inf / R⌉ mobiles passent donc par une
|
||||
// table inférieure et finissent au moins P* − P*_t sous leur plafond a
|
||||
// priori. Sans table inférieure, rien n'est prouvé : null.
|
||||
//
|
||||
// Deux tables de même capacité et de mêmes ancrés s'échangent sans changer
|
||||
// aucun plafond : P*_t se calcule une fois par couple (c_t, k_t), que la Map
|
||||
// retrouve. Le couple ne se réduit pas aux sièges libres c_t − k_t : à sièges
|
||||
// libres égaux, les sièges qui tournent sont les mêmes, et les k_t ancrés
|
||||
// s'ajoutent au plafond hors du terme que n_p borne.
|
||||
function ecartItineraire(configuration, instance, aPriori) {
|
||||
const { N, T, R, capacite, ancresParTable, statut } = instance;
|
||||
if (nombrePlaces(instance) !== N || statut.includes(STATUT.PARTIELLEMENT_FIXE)) return null;
|
||||
const mobile = statut.indexOf(STATUT.MOBILE);
|
||||
if (mobile === -1) return null;
|
||||
const plafondCommun = aPriori[mobile];
|
||||
const parGabarit = new Map();
|
||||
let siegesToursInferieurs = 0;
|
||||
let plancher = null;
|
||||
for (let t = 0; t < T; t += 1) {
|
||||
const sieges = capacite[t] - ancresParTable[t];
|
||||
// Une table gelée n'a aucun siège mobile, aucun itinéraire n'y passe, et
|
||||
// la sonde la surréserverait.
|
||||
if (sieges === 0) continue;
|
||||
const gabarit = `${capacite[t]}|${ancresParTable[t]}`;
|
||||
let passant = parGabarit.get(gabarit);
|
||||
if (passant === undefined) {
|
||||
passant = plafondPassantPar(configuration, instance, mobile, t);
|
||||
parGabarit.set(gabarit, passant);
|
||||
}
|
||||
if (passant < plafondCommun) {
|
||||
siegesToursInferieurs += sieges * R;
|
||||
const ecartPassant = plafondCommun - passant;
|
||||
if (plancher === null || ecartPassant < plancher) plancher = ecartPassant;
|
||||
}
|
||||
}
|
||||
if (siegesToursInferieurs === 0) return null;
|
||||
return { plancher, mobilesAuMoins: Math.ceil(siegesToursInferieurs / R) };
|
||||
}
|
||||
|
||||
// Plus petite et plus grande valeur, null pour une liste vide.
|
||||
function etendue(valeurs) {
|
||||
if (valeurs.length === 0) return { min: null, max: null };
|
||||
let min = valeurs[0];
|
||||
let max = valeurs[0];
|
||||
for (const valeur of valeurs) {
|
||||
if (valeur < min) min = valeur;
|
||||
if (valeur > max) max = valeur;
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic d'une configuration, avant la recherche (§ 5.6).
|
||||
*
|
||||
* - placesManquantes : nombrePlacesManquantes ; la recherche refuse une
|
||||
* configuration où elles sont positives, le diagnostic les chiffre (§ 5.9).
|
||||
* - plafondAPriori : plafondsAPriori, et son étendue.
|
||||
* - collisions : planchers de collisions par groupe, posés sur les tours
|
||||
* fixés, capacités relâchées.
|
||||
* - redondanceMinimale : max_p max(0, min(a priori(p), affiliés autres que
|
||||
* p) − G).
|
||||
* - animateursMemeAppartenance, et redondanceImposeeParAnimateurs : la forme
|
||||
* close min(R, T) − 1 du § 5.6, qui compte un animateur à chaque table
|
||||
* visitée ; 0 quand les animateurs ne partagent pas une appartenance.
|
||||
* - ancrage : ancrés, capacité et gel de chaque table.
|
||||
* - retoursImposes : la rangée de instance.fixe se lit comme le plan qui
|
||||
* assied chacun aux seuls tours fixés, en réserve ailleurs ; sa mesure
|
||||
* compte max(0, f − 1) retours pour f tours réservés à une même table, ce
|
||||
* que mesure tout plan qui honore les réservations (§ 5.4).
|
||||
* - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null.
|
||||
*
|
||||
* @param {import('./types.js').Configuration} configuration
|
||||
* @returns {Diagnostic}
|
||||
*/
|
||||
export function diagnostiquer(configuration) {
|
||||
const instance = normaliser(configuration);
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
const memeAppartenance = animateursMemeAppartenance(instance);
|
||||
return {
|
||||
placesManquantes: nombrePlacesManquantes(instance),
|
||||
plafondAPriori: { ...etendue(aPriori), parPersonne: aPriori },
|
||||
collisions: planchersCollisions(instance),
|
||||
redondanceMinimale: redondanceMinimale(instance, aPriori),
|
||||
animateursMemeAppartenance: memeAppartenance,
|
||||
redondanceImposeeParAnimateurs: memeAppartenance ? Math.min(instance.R, instance.T) - 1 : 0,
|
||||
ancrage: ancrage(instance),
|
||||
retoursImposes: mesurer(instance, instance.fixe).totalRetoursImposes,
|
||||
ecartItineraire: ecartItineraire(configuration, instance, aPriori),
|
||||
};
|
||||
}
|
||||
1043
src/moteur/diagnostic.test.js
Normal file
1043
src/moteur/diagnostic.test.js
Normal file
File diff suppressed because it is too large
Load diff
28
src/moteur/erreurs.js
Normal file
28
src/moteur/erreurs.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Erreurs que lève le moteur. Le code d'une ErreurConfiguration nomme la
|
||||
// règle enfreinte ; ses détails désignent participants et tables par
|
||||
// identifiant, les tours par leur numéro à partir de 1, et une réservation,
|
||||
// qui n'a pas d'identifiant, par son rang à partir de 0 dans la liste qui la
|
||||
// porte. L'appelant lit le code et les détails ; le message, code et détails
|
||||
// en JSON, sert au journal et n'est pas un texte affiché, que fournit la
|
||||
// table des libellés (§ 14.6).
|
||||
|
||||
export class ErreurConfiguration extends Error {
|
||||
/**
|
||||
* @param {string} code
|
||||
* @param {Object} [details]
|
||||
*/
|
||||
constructor(code, details = {}) {
|
||||
super(`${code} ${JSON.stringify(details)}`);
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
ErreurConfiguration.prototype.name = 'ErreurConfiguration';
|
||||
|
||||
// Levée par une recherche interrompue à la demande : aucun résultat partiel
|
||||
// ne l'accompagne.
|
||||
export class ErreurAnnulee extends Error {}
|
||||
ErreurAnnulee.prototype.name = 'ErreurAnnulee';
|
||||
296
src/moteur/indicateurs.js
Normal file
296
src/moteur/indicateurs.js
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Indicateurs d'un plan (§ 5.4), par recalcul complet : la mesure qui fait
|
||||
// foi, dont sortent les chiffres affichés et ceux du classement (§ 5.10). Le
|
||||
// plan se mesure entier, les N participants et les R tours ; les
|
||||
// réservations ne retirent rien de la mesure, elles séparent seulement, dans
|
||||
// les retours et les rencontres répétées, ce qu'elles imposent de ce que le
|
||||
// moteur choisit. Tout tableau par personne suit l'ordre de instance.ids.
|
||||
//
|
||||
// Une rencontre est une paire de personnes assises à la même table au même
|
||||
// tour : la réserve n'est pas une table, une table vide ou à un seul occupant
|
||||
// ne forme aucune paire, et personne ne se rencontre soi-même. Deux personnes
|
||||
// sans appartenance ne sont pas collègues. Un ensemble vide rend null, jamais
|
||||
// 0 : le taux de diversité de qui ne rencontre aucun affilié, le minimum et la
|
||||
// moyenne d'une population sans membre.
|
||||
import {
|
||||
RESERVE,
|
||||
SANS_GROUPE,
|
||||
exigerPlanIndexe,
|
||||
replierParPopulation,
|
||||
} from './configuration.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Agregat
|
||||
* @property {number|null} min null pour une population vide
|
||||
* @property {number|null} moyenne null pour une population vide
|
||||
* @property {number} effectif
|
||||
*
|
||||
* @typedef {Object} TroisAgregats
|
||||
* @property {Agregat} tous
|
||||
* @property {Agregat} mobiles mobiles et partiellement fixés (§ 5.4)
|
||||
* @property {Agregat} ancres
|
||||
*
|
||||
* @typedef {Object} Mesures
|
||||
* @property {number[]} rencontres |met(p)|, ordre canonique
|
||||
* @property {number[]} affilies |F(p)|
|
||||
* @property {number[]} appartenancesVues A(p) — appartenances des affiliés
|
||||
* rencontrés, la sienne comprise quand
|
||||
* un collègue est rencontré
|
||||
* @property {number[]} redondance r(p) = |F(p)| − A(p)
|
||||
* @property {(number|null)[]} diversite d(p) = A(p) / |F(p)|, null si |F(p)| = 0
|
||||
* @property {number[]} toursAssis
|
||||
* @property {number[]} retoursChoisis par personne
|
||||
* @property {number[]} retoursImposes par personne
|
||||
* @property {TroisAgregats} aggRencontres
|
||||
* @property {TroisAgregats} aggRedondance
|
||||
* @property {number} totalRedondance Σ_p r(p), le seul total de la
|
||||
* redondance : la recherche et le
|
||||
* classement le lisent
|
||||
* @property {number} collisionsCumulees Σ paires de même groupe × tours ensemble
|
||||
* @property {number} pairesDistinctes paires de même groupe réunies ≥ 1 fois
|
||||
* @property {number} excedentCollisions cumulées − distinctes
|
||||
* @property {{choisies: number, imposees: number}} rencontresRepetees
|
||||
* paires réunies ≥ 2 fois ; « imposée » quand chaque rencontre de la
|
||||
* paire a lieu à une table où les deux sont réservés ce tour-là
|
||||
* @property {number} maxRencontresPaire sur toutes les paires ; 0 si aucune
|
||||
* @property {number} totalRetoursChoisis
|
||||
* @property {number} totalRetoursImposes
|
||||
* @property {Array<{groupe: string, effectif: number, collisionsCumulees: number,
|
||||
* pairesDistinctes: number, excedent: number}>} parGroupe
|
||||
* ordre de instance.groupes
|
||||
*/
|
||||
|
||||
// Plus grand compte que tient un Uint16Array.
|
||||
const UINT16_MAX = 0xffff;
|
||||
|
||||
// Rang de la paire (a, b), a < b, dans le triangle supérieur d'une matrice
|
||||
// N × N rangé ligne par ligne : la ligne a s'ouvre après les
|
||||
// (N − 1) + (N − 2) + … + (N − a) paires des lignes qui la précèdent.
|
||||
const rangPaire = (N, a, b) => (a * (2 * N - a - 1)) / 2 + (b - a - 1);
|
||||
|
||||
// Rencontres de chaque paire, rangées par rangPaire, en parcourant chaque
|
||||
// table de chaque tour. choisie vaut 1 pour une paire dont une rencontre au
|
||||
// moins a lieu hors d'une table où les deux sont réservés ce tour-là. Une
|
||||
// paire se réunit au plus une fois par tour : R borne chaque compte, qu'un
|
||||
// Uint16Array tient tant que R ≤ 65 535.
|
||||
function compterRencontres({ N, T, R, fixe }, tableDe) {
|
||||
const paires = (N * (N - 1)) / 2;
|
||||
const compte = R <= UINT16_MAX ? new Uint16Array(paires) : new Uint32Array(paires);
|
||||
const choisie = new Uint8Array(paires);
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
// Occupants de chaque table, par index croissants : a < b pour toute
|
||||
// paire (liste[i], liste[j]) avec i < j. La boucle intérieure part du
|
||||
// voisin suivant : une liste vide ou d'un seul occupant ne forme aucune
|
||||
// paire, et nul n'est apparié à lui-même.
|
||||
const assis = Array.from({ length: T }, () => []);
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
const t = tableDe[p * R + r];
|
||||
if (t !== RESERVE) assis[t].push(p);
|
||||
}
|
||||
for (let t = 0; t < T; t += 1) {
|
||||
const liste = assis[t];
|
||||
for (let i = 0; i < liste.length; i += 1) {
|
||||
const a = liste[i];
|
||||
const aReserve = fixe[a * R + r] === t;
|
||||
for (let j = i + 1; j < liste.length; j += 1) {
|
||||
const b = liste[j];
|
||||
const k = rangPaire(N, a, b);
|
||||
compte[k] += 1;
|
||||
if (!aReserve || fixe[b * R + r] !== t) choisie[k] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { compte, choisie };
|
||||
}
|
||||
|
||||
// Lecture du triangle des paires, dans l'ordre de rangPaire. Par personne :
|
||||
// |met(p)|, |F(p)| et A(p), vu[p * G + g] notant que p a déjà croisé un
|
||||
// affilié du groupe g. Par groupe : une paire de collègues réunie n fois
|
||||
// porte n collisions cumulées et une paire distincte. Sur toutes les paires :
|
||||
// les rencontres répétées, choisies ou imposées, et le plus grand compte.
|
||||
function lirePaires({ N, groupe, groupes }, { compte, choisie }) {
|
||||
const G = groupes.length;
|
||||
const rencontres = new Int32Array(N);
|
||||
const affilies = new Int32Array(N);
|
||||
const vues = new Int32Array(N);
|
||||
const vu = new Uint8Array(N * G);
|
||||
const collisions = new Array(G).fill(0);
|
||||
const distinctes = new Array(G).fill(0);
|
||||
let choisies = 0;
|
||||
let imposees = 0;
|
||||
let maxRencontresPaire = 0;
|
||||
|
||||
// p rencontre q : q compte dans met(p) ; un affilié compte aussi dans F(p),
|
||||
// et son groupe compte dans A(p) au premier membre de ce groupe que p
|
||||
// rencontre.
|
||||
const rencontrer = (p, q) => {
|
||||
rencontres[p] += 1;
|
||||
const g = groupe[q];
|
||||
if (g === SANS_GROUPE) return;
|
||||
affilies[p] += 1;
|
||||
if (vu[p * G + g] === 0) {
|
||||
vu[p * G + g] = 1;
|
||||
vues[p] += 1;
|
||||
}
|
||||
};
|
||||
|
||||
let k = 0;
|
||||
for (let a = 0; a < N; a += 1) {
|
||||
for (let b = a + 1; b < N; b += 1, k += 1) {
|
||||
const n = compte[k];
|
||||
if (n === 0) continue;
|
||||
rencontrer(a, b);
|
||||
rencontrer(b, a);
|
||||
if (n > maxRencontresPaire) maxRencontresPaire = n;
|
||||
if (n >= 2) {
|
||||
if (choisie[k] === 1) choisies += 1;
|
||||
else imposees += 1;
|
||||
}
|
||||
const g = groupe[a];
|
||||
if (g !== SANS_GROUPE && g === groupe[b]) {
|
||||
collisions[g] += n;
|
||||
distinctes[g] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
rencontres,
|
||||
affilies,
|
||||
vues,
|
||||
collisions,
|
||||
distinctes,
|
||||
rencontresRepetees: { choisies, imposees },
|
||||
maxRencontresPaire,
|
||||
};
|
||||
}
|
||||
|
||||
// Tours assis et retours de chaque personne (§ 5.4). Pour une table visitée
|
||||
// m fois, dont f à un tour où la personne y est réservée : max(0, f − 1)
|
||||
// retours imposés et (m − 1) − imposés retours choisis. visites et reservees
|
||||
// comptent par table les tours de la personne en cours ; le second parcours
|
||||
// de ses tours solde chaque table à sa première occurrence et remet ses
|
||||
// compteurs à zéro, si bien que la personne suivante les trouve nuls.
|
||||
function compterRetours({ N, T, R, fixe }, tableDe) {
|
||||
const toursAssis = [];
|
||||
const retoursChoisis = [];
|
||||
const retoursImposes = [];
|
||||
const visites = new Int32Array(T);
|
||||
const reservees = new Int32Array(T);
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
let assis = 0;
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const t = tableDe[p * R + r];
|
||||
if (t === RESERVE) continue;
|
||||
assis += 1;
|
||||
visites[t] += 1;
|
||||
if (fixe[p * R + r] === t) reservees[t] += 1;
|
||||
}
|
||||
let choisis = 0;
|
||||
let imposes = 0;
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const t = tableDe[p * R + r];
|
||||
if (t === RESERVE || visites[t] === 0) continue;
|
||||
const imposesTable = Math.max(0, reservees[t] - 1);
|
||||
imposes += imposesTable;
|
||||
choisis += visites[t] - 1 - imposesTable;
|
||||
visites[t] = 0;
|
||||
reservees[t] = 0;
|
||||
}
|
||||
toursAssis.push(assis);
|
||||
retoursChoisis.push(choisis);
|
||||
retoursImposes.push(imposes);
|
||||
}
|
||||
return { toursAssis, retoursChoisis, retoursImposes };
|
||||
}
|
||||
|
||||
// Population sans membre. min reste null tant qu'aucune valeur n'y entre :
|
||||
// aucun Infinity ne sert de minimum initial.
|
||||
const VIDE = Object.freeze({ min: null, somme: 0, effectif: 0 });
|
||||
|
||||
// La population, une valeur de plus ; celle qu'elle reçoit reste intacte.
|
||||
const ajouter = ({ min, somme, effectif }, valeur) => ({
|
||||
min: min === null || valeur < min ? valeur : min,
|
||||
somme: somme + valeur,
|
||||
effectif: effectif + 1,
|
||||
});
|
||||
|
||||
// Agrégat d'une population ; la moyenne d'une population vide vaut null.
|
||||
const conclure = ({ min, somme, effectif }) => ({
|
||||
min,
|
||||
moyenne: effectif === 0 ? null : somme / effectif,
|
||||
effectif,
|
||||
});
|
||||
|
||||
// Minimum, moyenne et effectif de valeurs[p] sur les trois populations du
|
||||
// § 5.4, que replierParPopulation forme.
|
||||
function troisAgregats(valeurs, statut) {
|
||||
const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, ajouter);
|
||||
return { tous: conclure(tous), mobiles: conclure(mobiles), ancres: conclure(ancres) };
|
||||
}
|
||||
|
||||
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
|
||||
|
||||
/**
|
||||
* Mesure complète d'un plan indexé : la mesure qui fait foi (§ 5.10). Ne
|
||||
* modifie ni l'instance ni le plan. Lève ce que lève exigerPlanIndexe
|
||||
* (configuration.js) avant de lire une case : une case manquante ou d'une
|
||||
* autre forme lèverait plus loin une erreur qui ne nommerait ni la personne
|
||||
* ni le tour.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @param {ArrayLike<number>} tableDe tableDe[p * R + r] = index de table, RESERVE pour la réserve
|
||||
* @returns {Mesures}
|
||||
*/
|
||||
export function mesurer(instance, tableDe) {
|
||||
exigerPlanIndexe(instance, tableDe);
|
||||
const { N, groupe, groupes, statut } = instance;
|
||||
const paires = lirePaires(instance, compterRencontres(instance, tableDe));
|
||||
const { collisions, distinctes } = paires;
|
||||
|
||||
const rencontres = Array.from(paires.rencontres);
|
||||
const affilies = Array.from(paires.affilies);
|
||||
const appartenancesVues = Array.from(paires.vues);
|
||||
const redondance = affilies.map((f, p) => f - appartenancesVues[p]);
|
||||
const diversite = affilies.map((f, p) => (f === 0 ? null : appartenancesVues[p] / f));
|
||||
|
||||
const effectifs = new Array(groupes.length).fill(0);
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
if (groupe[p] !== SANS_GROUPE) effectifs[groupe[p]] += 1;
|
||||
}
|
||||
const parGroupe = groupes.map((libelle, g) => ({
|
||||
groupe: libelle,
|
||||
effectif: effectifs[g],
|
||||
collisionsCumulees: collisions[g],
|
||||
pairesDistinctes: distinctes[g],
|
||||
excedent: collisions[g] - distinctes[g],
|
||||
}));
|
||||
const collisionsCumulees = somme(collisions);
|
||||
const pairesDistinctes = somme(distinctes);
|
||||
|
||||
const { toursAssis, retoursChoisis, retoursImposes } = compterRetours(instance, tableDe);
|
||||
|
||||
return {
|
||||
rencontres,
|
||||
affilies,
|
||||
appartenancesVues,
|
||||
redondance,
|
||||
diversite,
|
||||
toursAssis,
|
||||
retoursChoisis,
|
||||
retoursImposes,
|
||||
aggRencontres: troisAgregats(rencontres, statut),
|
||||
aggRedondance: troisAgregats(redondance, statut),
|
||||
totalRedondance: somme(redondance),
|
||||
collisionsCumulees,
|
||||
pairesDistinctes,
|
||||
excedentCollisions: collisionsCumulees - pairesDistinctes,
|
||||
rencontresRepetees: paires.rencontresRepetees,
|
||||
maxRencontresPaire: paires.maxRencontresPaire,
|
||||
totalRetoursChoisis: somme(retoursChoisis),
|
||||
totalRetoursImposes: somme(retoursImposes),
|
||||
parGroupe,
|
||||
};
|
||||
}
|
||||
579
src/moteur/indicateurs.test.js
Normal file
579
src/moteur/indicateurs.test.js
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des indicateurs (§ 5.4, § 12.6, § 14.10). Chaque valeur attendue
|
||||
// est écrite en clair : recopiée de la spécification, ou comptée à la main
|
||||
// sur le plan qui la précède, jamais calculée par le module éprouvé. Un plan
|
||||
// s'écrit par identifiants, une liste par table, et passe par normaliser et
|
||||
// indexerPlan comme un plan enregistré.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { indexerPlan, normaliser } from './configuration.js';
|
||||
import { mesurer } from './indicateurs.js';
|
||||
|
||||
const SANS_CONTRAINTE = {
|
||||
separerAppartenances: false,
|
||||
nouveauxVoisins: false,
|
||||
nouvelleTable: false,
|
||||
varierAppartenances: false,
|
||||
};
|
||||
|
||||
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
|
||||
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
|
||||
|
||||
// Agrégat d'une population vide.
|
||||
const AUCUN = { min: null, moyenne: null, effectif: 0 };
|
||||
|
||||
// Appartenances des participants 1..N : le libellé du groupe qui contient
|
||||
// l'identifiant, null hors de tout groupe. groupes est une liste de couples
|
||||
// [libellé, identifiants].
|
||||
function appartenances(N, groupes) {
|
||||
return Array.from({ length: N }, (_, i) => {
|
||||
const groupe = groupes.find(([, membres]) => membres.includes(i + 1));
|
||||
return groupe === undefined ? null : groupe[0];
|
||||
});
|
||||
}
|
||||
|
||||
// Instance et plan indexé d'un plan écrit par identifiants. Le participant
|
||||
// i + 1 porte appartenance[i] ; les tables portent les identifiants 1..T,
|
||||
// toutes de la capacité donnée ; tours[r][i] liste les participants assis à
|
||||
// la table i + 1 au tour r + 1, et reserves[r] ceux qui n'y sont assis nulle
|
||||
// part.
|
||||
function preparer({
|
||||
appartenance,
|
||||
capacite,
|
||||
tours,
|
||||
reserves = tours.map(() => []),
|
||||
reservations = [],
|
||||
}) {
|
||||
const participants = appartenance.map((libelle, i) => ({
|
||||
id: i + 1,
|
||||
nom: `P${i + 1}`,
|
||||
appartenance: libelle,
|
||||
}));
|
||||
const tables = tours[0].map((_, i) => ({ id: i + 1, numero: i + 1, capacite }));
|
||||
const instance = normaliser({
|
||||
participants,
|
||||
tables,
|
||||
tours: tours.length,
|
||||
reservations,
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
});
|
||||
const plan = { tables: tables.map(({ id }) => id), tours, reserves };
|
||||
return { instance, tableDe: indexerPlan(instance, plan) };
|
||||
}
|
||||
|
||||
function mesurerPlan(description) {
|
||||
const { instance, tableDe } = preparer(description);
|
||||
return mesurer(instance, tableDe);
|
||||
}
|
||||
|
||||
// Plan parfait de la petite démonstration (§ 15.3) : quatre tables de trois
|
||||
// sièges, quatre tours, chaque table réunit à chaque tour un membre de A, de
|
||||
// B et de C.
|
||||
const PLAN_PARFAIT = [
|
||||
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
|
||||
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
|
||||
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
|
||||
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
|
||||
];
|
||||
const PETITE = appartenances(12, [
|
||||
['A', [1, 5, 8, 12]],
|
||||
['B', [2, 4, 9, 11]],
|
||||
['C', [3, 6, 7, 10]],
|
||||
]);
|
||||
// Variante « conflit inévitable » : 10 passe de C à A.
|
||||
const VARIANTE = appartenances(12, [
|
||||
['A', [1, 5, 8, 10, 12]],
|
||||
['B', [2, 4, 9, 11]],
|
||||
['C', [3, 6, 7]],
|
||||
]);
|
||||
|
||||
// Trois tables de trois sièges, quatre tours. 1 est ancré à la table 1 ; 2
|
||||
// est mobile ; 3, réservé à la table 2 au tour 3, et 4, réservé à la table 3
|
||||
// aux tours 1 et 3, sont partiellement fixés. Chaque réservation est tenue.
|
||||
const PLAN_RESERVATIONS = {
|
||||
appartenance: [null, null, null, null],
|
||||
capacite: 3,
|
||||
tours: [
|
||||
[[1], [2, 3], [4]],
|
||||
[[1, 3], [], [2, 4]],
|
||||
[[1, 2], [3], [4]],
|
||||
[[1, 4], [2], [3]],
|
||||
],
|
||||
reservations: [tous(1, 1), auTour(3, 2, 3), auTour(4, 3, 1), auTour(4, 3, 3)],
|
||||
};
|
||||
|
||||
// Deux ancrés à la table 1, seule table, quatre tours.
|
||||
const DEUX_ANCRES = {
|
||||
appartenance: [null, null],
|
||||
capacite: 2,
|
||||
tours: [[[1, 2]], [[1, 2]], [[1, 2]], [[1, 2]]],
|
||||
reservations: [tous(1, 1), tous(2, 1)],
|
||||
};
|
||||
|
||||
describe('mesurer', () => {
|
||||
test('plan parfait de la petite démonstration : huit rencontres chacun, ni collision ni répétition', () => {
|
||||
// Aucun retour : chacun visite les quatre tables. Sans réservation, la
|
||||
// population des ancrés est vide.
|
||||
assert.deepEqual(mesurerPlan({ appartenance: PETITE, capacite: 3, tours: PLAN_PARFAIT }), {
|
||||
rencontres: new Array(12).fill(8),
|
||||
affilies: new Array(12).fill(8),
|
||||
appartenancesVues: new Array(12).fill(2),
|
||||
redondance: new Array(12).fill(6),
|
||||
diversite: new Array(12).fill(0.25),
|
||||
toursAssis: new Array(12).fill(4),
|
||||
retoursChoisis: new Array(12).fill(0),
|
||||
retoursImposes: new Array(12).fill(0),
|
||||
aggRencontres: {
|
||||
tous: { min: 8, moyenne: 8, effectif: 12 },
|
||||
mobiles: { min: 8, moyenne: 8, effectif: 12 },
|
||||
ancres: AUCUN,
|
||||
},
|
||||
aggRedondance: {
|
||||
tous: { min: 6, moyenne: 6, effectif: 12 },
|
||||
mobiles: { min: 6, moyenne: 6, effectif: 12 },
|
||||
ancres: AUCUN,
|
||||
},
|
||||
totalRedondance: 72,
|
||||
collisionsCumulees: 0,
|
||||
pairesDistinctes: 0,
|
||||
excedentCollisions: 0,
|
||||
rencontresRepetees: { choisies: 0, imposees: 0 },
|
||||
maxRencontresPaire: 1,
|
||||
totalRetoursChoisis: 0,
|
||||
totalRetoursImposes: 0,
|
||||
parGroupe: [
|
||||
{ groupe: 'A', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
|
||||
{ groupe: 'B', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
|
||||
{ groupe: 'C', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('variante 5, 4, 3 : quatre collisions cumulées sur quatre paires distinctes, excédent nul', () => {
|
||||
const mesures = mesurerPlan({ appartenance: VARIANTE, capacite: 3, tours: PLAN_PARFAIT });
|
||||
const { collisionsCumulees, pairesDistinctes, excedentCollisions } = mesures;
|
||||
assert.deepEqual(
|
||||
{ collisionsCumulees, pairesDistinctes, excedentCollisions },
|
||||
{ collisionsCumulees: 4, pairesDistinctes: 4, excedentCollisions: 0 },
|
||||
);
|
||||
assert.deepEqual(mesures.parGroupe, [
|
||||
{ groupe: 'A', effectif: 5, collisionsCumulees: 4, pairesDistinctes: 4, excedent: 0 },
|
||||
{ groupe: 'B', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
|
||||
{ groupe: 'C', effectif: 3, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
|
||||
]);
|
||||
// 1, 5, 8 et 12 croisent chacun 10, leur collègue : A(p) compte leur
|
||||
// propre appartenance avec les deux autres. 10 croise A et B, jamais C.
|
||||
assert.deepEqual(mesures.appartenancesVues, [3, 2, 2, 2, 3, 2, 2, 3, 2, 2, 2, 3]);
|
||||
});
|
||||
|
||||
test("l'excédent compte les retrouvailles de collègues au-delà de la première", () => {
|
||||
// Deux tables de quatre, trois tours ; X = {1, 2, 3, 4}, les autres sans
|
||||
// appartenance.
|
||||
const appartenance = appartenances(8, [['X', [1, 2, 3, 4]]]);
|
||||
const collisions = ({ collisionsCumulees, pairesDistinctes, excedentCollisions, parGroupe }) => ({
|
||||
collisionsCumulees,
|
||||
pairesDistinctes,
|
||||
excedentCollisions,
|
||||
parGroupe,
|
||||
});
|
||||
const reparti = mesurerPlan({
|
||||
appartenance,
|
||||
capacite: 4,
|
||||
tours: [
|
||||
[[1, 2, 5, 6], [3, 4, 7, 8]],
|
||||
[[1, 3, 5, 7], [2, 4, 6, 8]],
|
||||
[[1, 4, 5, 8], [2, 3, 6, 7]],
|
||||
],
|
||||
});
|
||||
assert.deepEqual(collisions(reparti), {
|
||||
collisionsCumulees: 6,
|
||||
pairesDistinctes: 6,
|
||||
excedentCollisions: 0,
|
||||
parGroupe: [{ groupe: 'X', effectif: 4, collisionsCumulees: 6, pairesDistinctes: 6, excedent: 0 }],
|
||||
});
|
||||
// 5 et 6, puis 7 et 8, se retrouvent aussi à chaque tour : deux personnes
|
||||
// sans appartenance ne sont pas des collègues.
|
||||
const concentre = mesurerPlan({
|
||||
appartenance,
|
||||
capacite: 4,
|
||||
tours: [
|
||||
[[1, 2, 5, 6], [3, 4, 7, 8]],
|
||||
[[1, 2, 5, 6], [3, 4, 7, 8]],
|
||||
[[1, 2, 5, 6], [3, 4, 7, 8]],
|
||||
],
|
||||
});
|
||||
assert.deepEqual(collisions(concentre), {
|
||||
collisionsCumulees: 6,
|
||||
pairesDistinctes: 2,
|
||||
excedentCollisions: 4,
|
||||
parGroupe: [{ groupe: 'X', effectif: 4, collisionsCumulees: 6, pairesDistinctes: 2, excedent: 4 }],
|
||||
});
|
||||
});
|
||||
|
||||
test("une table vide et une table à un seul occupant n'ajoutent aucune paire ni aucune rencontre de soi", () => {
|
||||
// Au tour 1, la table 3 est vide, 4 et 5 sont seuls à leur table. Au
|
||||
// tour 2, 5 l'est encore, et la table 1 est vide.
|
||||
const mesures = mesurerPlan({
|
||||
appartenance: ['X', 'X', 'Y', 'X', null],
|
||||
capacite: 3,
|
||||
tours: [
|
||||
[[1, 2, 3], [4], [], [5]],
|
||||
[[], [1, 4], [2, 3], [5]],
|
||||
],
|
||||
});
|
||||
assert.deepEqual(mesures.rencontres, [3, 2, 2, 1, 0]);
|
||||
assert.deepEqual(mesures.affilies, [3, 2, 2, 1, 0]);
|
||||
assert.deepEqual(mesures.appartenancesVues, [2, 2, 1, 1, 0]);
|
||||
assert.deepEqual(mesures.redondance, [1, 0, 1, 0, 0]);
|
||||
// 5 ne rencontre personne : son taux de diversité n'a pas de diviseur.
|
||||
assert.deepEqual(mesures.diversite, [2 / 3, 1, 1 / 2, 1, null]);
|
||||
// (1, 2) au tour 1 et (1, 4) au tour 2 ; 4, seul de X à sa table au tour
|
||||
// 1, ne s'y croise pas lui-même.
|
||||
assert.equal(mesures.collisionsCumulees, 2);
|
||||
// (2, 3), réunis aux deux tours, est la seule paire répétée.
|
||||
assert.equal(mesures.maxRencontresPaire, 2);
|
||||
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
|
||||
});
|
||||
|
||||
test('totalRedondance somme r(p) sur les personnes, et non son plus grand', () => {
|
||||
// Le plan de l'épreuve précédente : r(p) vaut 1, 0, 1, 0 et 0. Le total
|
||||
// est le seul Σ r(p) du moteur ; la recherche et le classement le lisent.
|
||||
const mesures = mesurerPlan({
|
||||
appartenance: ['X', 'X', 'Y', 'X', null],
|
||||
capacite: 3,
|
||||
tours: [
|
||||
[[1, 2, 3], [4], [], [5]],
|
||||
[[], [1, 4], [2, 3], [5]],
|
||||
],
|
||||
});
|
||||
assert.deepEqual(mesures.redondance, [1, 0, 1, 0, 0]);
|
||||
assert.equal(mesures.totalRedondance, 2);
|
||||
});
|
||||
|
||||
test("F(p) ne retient que les affiliés : qui ne croise aucun affilié n'a pas de taux de diversité", () => {
|
||||
// 3, seul affilié de la table, rencontre deux personnes sans
|
||||
// appartenance ; chacune d'elles rencontre un seul affilié, 3.
|
||||
const mesures = mesurerPlan({ appartenance: [null, null, 'X'], capacite: 3, tours: [[[1, 2, 3]]] });
|
||||
assert.deepEqual(mesures.rencontres, [2, 2, 2]);
|
||||
assert.deepEqual(mesures.affilies, [1, 1, 0]);
|
||||
assert.deepEqual(mesures.appartenancesVues, [1, 1, 0]);
|
||||
assert.deepEqual(mesures.diversite, [1, 1, null]);
|
||||
// La redondance se lit sur les affiliés rencontrés, |F(p)| − A(p), et non
|
||||
// sur toutes les rencontres : personne ne recroise une appartenance.
|
||||
assert.deepEqual(mesures.redondance, [0, 0, 0]);
|
||||
assert.deepEqual(mesures.aggRedondance, {
|
||||
tous: { min: 0, moyenne: 0, effectif: 3 },
|
||||
mobiles: { min: 0, moyenne: 0, effectif: 3 },
|
||||
ancres: AUCUN,
|
||||
});
|
||||
});
|
||||
|
||||
test("la réserve n'est pas une table : seuls les tours assis comptent", () => {
|
||||
// 6 est en réserve aux tours 1 et 2, 5 au tour 2. Ils partagent
|
||||
// l'appartenance X et ne sont jamais assis ensemble.
|
||||
const mesures = mesurerPlan({
|
||||
appartenance: [null, null, null, null, 'X', 'X'],
|
||||
capacite: 3,
|
||||
tours: [
|
||||
[[1, 2, 5], [3, 4]],
|
||||
[[1, 3], [2, 4]],
|
||||
[[1, 4, 6], [2, 3, 5]],
|
||||
],
|
||||
reserves: [[6], [5, 6], []],
|
||||
});
|
||||
assert.deepEqual(mesures.toursAssis, [3, 3, 3, 3, 2, 1]);
|
||||
assert.deepEqual(mesures.rencontres, [5, 4, 4, 4, 3, 2]);
|
||||
assert.equal(mesures.collisionsCumulees, 0);
|
||||
// Deux passages en réserve ne font pas un retour.
|
||||
assert.deepEqual(mesures.retoursChoisis, [2, 1, 1, 1, 0, 0]);
|
||||
// Une personne en réserve à un tour reste dans les agrégats.
|
||||
assert.deepEqual(mesures.aggRencontres.tous, { min: 2, moyenne: 22 / 6, effectif: 6 });
|
||||
});
|
||||
|
||||
test('les retours imposés par les réservations se comptent à part des retours choisis', () => {
|
||||
// 1 visite quatre fois la table 1, réservée à chaque tour : trois retours
|
||||
// imposés. 2 visite deux fois la table 2, sans réservation : un retour
|
||||
// choisi. 3 est placé à la table 2 au tour 1, puis y revient au tour 3 où
|
||||
// il y est réservé : un retour choisi. 4 visite trois fois la table 3,
|
||||
// dont deux réservées : un retour imposé et un choisi.
|
||||
const mesures = mesurerPlan(PLAN_RESERVATIONS);
|
||||
assert.deepEqual(mesures.retoursImposes, [3, 0, 0, 1]);
|
||||
assert.deepEqual(mesures.retoursChoisis, [0, 1, 1, 1]);
|
||||
assert.equal(mesures.totalRetoursImposes, 4);
|
||||
assert.equal(mesures.totalRetoursChoisis, 3);
|
||||
});
|
||||
|
||||
test('les agrégats portent sur tous et rangent les partiellement fixés parmi les mobiles', () => {
|
||||
// 1 est ancré, 2 mobile, 3 et 4 partiellement fixés.
|
||||
const mesures = mesurerPlan(PLAN_RESERVATIONS);
|
||||
assert.deepEqual(mesures.rencontres, [3, 3, 2, 2]);
|
||||
assert.deepEqual(mesures.aggRencontres, {
|
||||
tous: { min: 2, moyenne: 10 / 4, effectif: 4 },
|
||||
mobiles: { min: 2, moyenne: 7 / 3, effectif: 3 },
|
||||
ancres: { min: 3, moyenne: 3, effectif: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('une population vide rend null, jamais 0 ni Infinity', () => {
|
||||
const sansAncre = mesurerPlan({ appartenance: PETITE, capacite: 3, tours: PLAN_PARFAIT });
|
||||
assert.deepEqual(sansAncre.aggRencontres.ancres, AUCUN);
|
||||
assert.deepEqual(sansAncre.aggRedondance.ancres, AUCUN);
|
||||
const sansMobile = mesurerPlan(DEUX_ANCRES);
|
||||
assert.deepEqual(sansMobile.aggRencontres.mobiles, AUCUN);
|
||||
assert.deepEqual(sansMobile.aggRedondance.mobiles, AUCUN);
|
||||
});
|
||||
|
||||
test('deux ancrés à la même table : une rencontre répétée, imposée et non choisie', () => {
|
||||
const mesures = mesurerPlan(DEUX_ANCRES);
|
||||
assert.deepEqual(mesures.rencontresRepetees, { choisies: 0, imposees: 1 });
|
||||
assert.equal(mesures.maxRencontresPaire, 4);
|
||||
assert.deepEqual(mesures.retoursImposes, [3, 3]);
|
||||
assert.deepEqual(mesures.retoursChoisis, [0, 0]);
|
||||
});
|
||||
|
||||
test('une seule rencontre hors réservation rend la répétition choisie', () => {
|
||||
// 1 et 2 sont ancrés à la table 1 ; 3 y est réservé au seul tour 1 et y
|
||||
// reste au tour 2 ; 4 est mobile. (1, 2) se réunit quatre fois, chaque
|
||||
// fois réservés tous deux ; (1, 3) et (2, 3) au tour 1 réservés, au tour
|
||||
// 2 non ; (3, 4) aux tours 3 et 4, sans réservation.
|
||||
const mesures = mesurerPlan({
|
||||
appartenance: [null, null, null, null],
|
||||
capacite: 3,
|
||||
tours: [
|
||||
[[1, 2, 3], [4]],
|
||||
[[1, 2, 3], [4]],
|
||||
[[1, 2], [3, 4]],
|
||||
[[1, 2], [3, 4]],
|
||||
],
|
||||
reservations: [tous(1, 1), tous(2, 1), auTour(3, 1, 1)],
|
||||
});
|
||||
assert.deepEqual(mesures.rencontresRepetees, { choisies: 3, imposees: 1 });
|
||||
});
|
||||
|
||||
describe('une rencontre hors réservation rend la répétition choisie, quels que soient son rang et le membre non réservé', () => {
|
||||
// 1 et 2 sont assis ensemble à la table 1 aux trois tours. L'un y est
|
||||
// ancré ; l'autre n'y est réservé qu'aux deux tours autres que « libre ».
|
||||
// La rencontre de ce tour-là n'est réservée que pour l'ancré : la
|
||||
// répétition est choisie, que cette rencontre soit la première, celle du
|
||||
// milieu ou la dernière, et que le membre non réservé soit le premier de
|
||||
// la paire ou le second. Le partiellement fixé visite trois fois la
|
||||
// table, dont deux réservées : un retour imposé, un choisi. L'ancré,
|
||||
// réservé aux trois visites, compte deux retours imposés. Chaque cas est
|
||||
// une épreuve : un écart nomme chacun des cas qu'il touche.
|
||||
const cas = [
|
||||
{ libre: 1, partiel: 1, ancre: 2, retoursImposes: [1, 2], retoursChoisis: [1, 0] },
|
||||
{ libre: 2, partiel: 1, ancre: 2, retoursImposes: [1, 2], retoursChoisis: [1, 0] },
|
||||
{ libre: 3, partiel: 1, ancre: 2, retoursImposes: [1, 2], retoursChoisis: [1, 0] },
|
||||
{ libre: 1, partiel: 2, ancre: 1, retoursImposes: [2, 1], retoursChoisis: [0, 1] },
|
||||
{ libre: 2, partiel: 2, ancre: 1, retoursImposes: [2, 1], retoursChoisis: [0, 1] },
|
||||
{ libre: 3, partiel: 2, ancre: 1, retoursImposes: [2, 1], retoursChoisis: [0, 1] },
|
||||
];
|
||||
for (const { libre, partiel, ancre, retoursImposes, retoursChoisis } of cas) {
|
||||
test(`rencontre du tour ${libre} réservée pour ${ancre} seul`, () => {
|
||||
const mesures = mesurerPlan({
|
||||
appartenance: [null, null],
|
||||
capacite: 2,
|
||||
tours: [[[1, 2]], [[1, 2]], [[1, 2]]],
|
||||
reservations: [
|
||||
tous(ancre, 1),
|
||||
...[1, 2, 3].filter((tour) => tour !== libre).map((tour) => auTour(partiel, 1, tour)),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
|
||||
assert.deepEqual(mesures.retoursImposes, retoursImposes);
|
||||
assert.deepEqual(mesures.retoursChoisis, retoursChoisis);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('une répétition imposée se lit sur les réservations du tour des deux membres, ancrés ou non', () => {
|
||||
// 2 est ancré à la table 1 ; 3 et 4, réservés à la table 2 aux tours 2 et
|
||||
// 3, sont partiellement fixés ; 1 est mobile. (1, 2) se réunit aux trois
|
||||
// tours : 1, le premier membre de la paire, n'est jamais réservé, la
|
||||
// répétition est choisie. (3, 4) se réunit aux tours 2 et 3, chaque fois
|
||||
// à la table où tous deux sont réservés ce tour-là : la répétition est
|
||||
// imposée, bien qu'aucun des deux ne soit ancré et que 3 ne soit réservé
|
||||
// nulle part au tour 1.
|
||||
const mesures = mesurerPlan({
|
||||
appartenance: [null, null, null, null],
|
||||
capacite: 3,
|
||||
tours: [
|
||||
[[1, 2, 4], [3]],
|
||||
[[1, 2], [3, 4]],
|
||||
[[1, 2], [3, 4]],
|
||||
],
|
||||
reservations: [tous(2, 1), auTour(3, 2, 2), auTour(3, 2, 3), auTour(4, 2, 2), auTour(4, 2, 3)],
|
||||
});
|
||||
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 1 });
|
||||
});
|
||||
|
||||
test("une visite n'est réservée qu'à la table de la réservation : un plan qui ne la tient pas n'impose rien", () => {
|
||||
// 1 et 2 sont ancrés à la table 1 ; le plan les assoit ensemble à la
|
||||
// table 2 aux deux tours. Un tel plan, en dérive, se conserve et se
|
||||
// mesure (§ 9) : la réservation qu'il ne tient pas n'impose ni leurs
|
||||
// retours ni leur répétition, qui sont choisis.
|
||||
const mesures = mesurerPlan({
|
||||
appartenance: [null, null],
|
||||
capacite: 2,
|
||||
tours: [
|
||||
[[], [1, 2]],
|
||||
[[], [1, 2]],
|
||||
],
|
||||
reservations: [tous(1, 1), tous(2, 1)],
|
||||
});
|
||||
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
|
||||
assert.deepEqual(mesures.retoursImposes, [0, 0]);
|
||||
assert.deepEqual(mesures.retoursChoisis, [1, 1]);
|
||||
});
|
||||
|
||||
test("un seul membre réservé ailleurs suffit à rendre la répétition choisie : réservé ce tour-là ne suffit pas, il faut l'être à cette table", () => {
|
||||
// Deux plans en dérive, la paire assise à la table 2 aux deux tours. Dans
|
||||
// le premier, le premier membre est réservé à la table 1 et le second à la
|
||||
// table 2 ; dans le second, l'inverse. Chacun des deux membres est donc
|
||||
// tour à tour celui qui n'est pas réservé à la table de la rencontre.
|
||||
for (const reservations of [[tous(1, 1), tous(2, 2)], [tous(1, 2), tous(2, 1)]]) {
|
||||
const mesures = mesurerPlan({
|
||||
appartenance: [null, null],
|
||||
capacite: 2,
|
||||
tours: [
|
||||
[[], [1, 2]],
|
||||
[[], [1, 2]],
|
||||
],
|
||||
reservations,
|
||||
});
|
||||
assert.deepEqual(
|
||||
mesures.rencontresRepetees,
|
||||
{ choisies: 1, imposees: 0 },
|
||||
JSON.stringify(reservations),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("le compte d'une paire tient 65 536 tours", () => {
|
||||
const R = 65_536;
|
||||
const instance = normaliser({
|
||||
participants: [
|
||||
{ id: 1, nom: 'P1', appartenance: null },
|
||||
{ id: 2, nom: 'P2', appartenance: null },
|
||||
],
|
||||
tables: [{ id: 1, numero: 1, capacite: 2 }],
|
||||
tours: R,
|
||||
reservations: [],
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
});
|
||||
// Toutes les cases valent 0 : les deux participants partagent la table
|
||||
// d'index 0 à chaque tour.
|
||||
const mesures = mesurer(instance, new Int32Array(2 * R));
|
||||
assert.equal(mesures.maxRencontresPaire, R);
|
||||
assert.deepEqual(mesures.rencontres, [1, 1]);
|
||||
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
|
||||
});
|
||||
|
||||
test('personne à la même table : aucune rencontre, maximum par paire à 0', () => {
|
||||
const mesures = mesurerPlan({
|
||||
appartenance: ['X', 'X'],
|
||||
capacite: 2,
|
||||
tours: [
|
||||
[[1], [2]],
|
||||
[[2], [1]],
|
||||
],
|
||||
});
|
||||
assert.deepEqual(mesures.rencontres, [0, 0]);
|
||||
assert.deepEqual(mesures.diversite, [null, null]);
|
||||
assert.equal(mesures.maxRencontresPaire, 0);
|
||||
assert.equal(mesures.collisionsCumulees, 0);
|
||||
// L'effectif d'un groupe compte tous ses membres, qu'ils rencontrent
|
||||
// quelqu'un ou non.
|
||||
assert.deepEqual(mesures.parGroupe, [
|
||||
{ groupe: 'X', effectif: 2, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parGroupe suit l'ordre de instance.groupes, non l'ordre alphabétique", () => {
|
||||
// instance.groupes range les appartenances par première apparition, en
|
||||
// parcourant les identifiants croissants : Y, portée par 1, puis X, par
|
||||
// 2. 1 et 3, tous deux de Y, se rencontrent une fois.
|
||||
const mesures = mesurerPlan({ appartenance: ['Y', 'X', 'Y'], capacite: 3, tours: [[[1, 2, 3]]] });
|
||||
assert.deepEqual(mesures.parGroupe, [
|
||||
{ groupe: 'Y', effectif: 2, collisionsCumulees: 1, pairesDistinctes: 1, excedent: 0 },
|
||||
{ groupe: 'X', effectif: 1, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('sans participant présent : tableaux vides, agrégats à null, compteurs à 0', () => {
|
||||
const instance = normaliser({
|
||||
participants: [{ id: 1, nom: 'P1', appartenance: 'X', exclu: true }],
|
||||
tables: [{ id: 1, numero: 1, capacite: 2 }],
|
||||
tours: 2,
|
||||
reservations: [],
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
});
|
||||
assert.deepEqual(mesurer(instance, new Int32Array(0)), {
|
||||
rencontres: [],
|
||||
affilies: [],
|
||||
appartenancesVues: [],
|
||||
redondance: [],
|
||||
diversite: [],
|
||||
toursAssis: [],
|
||||
retoursChoisis: [],
|
||||
retoursImposes: [],
|
||||
aggRencontres: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
|
||||
aggRedondance: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
|
||||
totalRedondance: 0,
|
||||
collisionsCumulees: 0,
|
||||
pairesDistinctes: 0,
|
||||
excedentCollisions: 0,
|
||||
rencontresRepetees: { choisies: 0, imposees: 0 },
|
||||
maxRencontresPaire: 0,
|
||||
totalRetoursChoisis: 0,
|
||||
totalRetoursImposes: 0,
|
||||
parGroupe: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("un plan indexé qui n'a pas la forme de l'instance lève RangeError, qui nomme le participant et le tour", () => {
|
||||
const { instance, tableDe } = preparer({ appartenance: PETITE, capacite: 3, tours: PLAN_PARFAIT });
|
||||
// Douze participants, quatre tours : 48 cases, ni une de moins ni une de
|
||||
// trop.
|
||||
assert.throws(() => mesurer(instance, tableDe.subarray(1)), RangeError);
|
||||
assert.throws(() => mesurer(instance, Int32Array.of(...tableDe, 0)), RangeError);
|
||||
// Quatre tables : une case vaut −1 ou un index entier de 0 à 3. Les
|
||||
// chaînes "0" et "-1" ne sont ni l'un ni l'autre : une comparaison qui
|
||||
// convertit lirait l'une comme la table d'index 0, l'autre comme la
|
||||
// réserve. Chaque intrus se pose à la première case, à une case du milieu
|
||||
// et à la dernière.
|
||||
for (const intrus of [4, -2, 1.5, '0', '-1']) {
|
||||
for (const k of [0, 5, 47]) {
|
||||
const fautif = Array.from(tableDe);
|
||||
fautif[k] = intrus;
|
||||
assert.throws(() => mesurer(instance, fautif), RangeError, `case ${k} à ${JSON.stringify(intrus)}`);
|
||||
}
|
||||
}
|
||||
// La case p × R + r est celle du participant d'index p au tour r + 1 : la
|
||||
// dernière est celle de 12 au tour 4. Le message cite une chaîne entre
|
||||
// guillemets, pour qu'elle ne se lise pas comme l'index qu'elle contient.
|
||||
const fautif = Array.from(tableDe);
|
||||
fautif[47] = '0';
|
||||
assert.throws(() => mesurer(instance, fautif), {
|
||||
name: 'RangeError',
|
||||
message: /participant 12, tour 4, index de table "0" hors de −1\.\.3/,
|
||||
});
|
||||
});
|
||||
|
||||
test("mesurer ne modifie ni l'instance ni le plan", () => {
|
||||
const { instance, tableDe } = preparer(PLAN_RESERVATIONS);
|
||||
const avant = {
|
||||
tableDe: tableDe.slice(),
|
||||
fixe: instance.fixe.slice(),
|
||||
statut: instance.statut.slice(),
|
||||
groupe: instance.groupe.slice(),
|
||||
};
|
||||
mesurer(instance, tableDe);
|
||||
assert.deepEqual(
|
||||
{ tableDe, fixe: instance.fixe, statut: instance.statut, groupe: instance.groupe },
|
||||
avant,
|
||||
);
|
||||
});
|
||||
});
|
||||
544
src/moteur/integration.long.test.js
Normal file
544
src/moteur/integration.long.test.js
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves d'intégration lourdes du moteur. D'abord la grande démonstration
|
||||
// (§ 5.5, § 12.10.4, § 15.1) : trois propositions traversent la chaîne
|
||||
// d'integration.test.js, de rechercher au certificat. La configuration est
|
||||
// exactement tendue et impose ses planchers, que le diagnostic chiffre avant
|
||||
// la recherche : 99 retours imposés par les 33 ancrages ; un écart
|
||||
// d'itinéraire maximal d'au moins 1, porté par au moins 24 mobiles ; un
|
||||
// certificat « minimum atteint » hors d'atteinte. Les seuils de qualité sont
|
||||
// posés après mesure, la mesure en commentaire (§ 14.1). Puis la grille de
|
||||
// propriétés du § 14.12, sur des configurations tirées.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CATALOGUE } from '../demo/catalogue.js';
|
||||
import { FLUX, creerPcg32 } from '../demo/prng.js';
|
||||
import { STATUT, indexerPlan, nombrePlaces, normaliser } from './configuration.js';
|
||||
import { diagnostiquer } from './diagnostic.js';
|
||||
import { mesurer } from './indicateurs.js';
|
||||
import {
|
||||
ecartsAuPlafondAPriori,
|
||||
ecartsItineraire,
|
||||
minimumAtteint,
|
||||
troisChiffres,
|
||||
} from './manque.js';
|
||||
import { plafondsAPriori, plafondsRealises } from './plafond.js';
|
||||
import {
|
||||
creerEtat,
|
||||
defaire,
|
||||
proposerEtAppliquer,
|
||||
rechercher,
|
||||
regenerer,
|
||||
scoreComplet,
|
||||
scoreIncremental,
|
||||
} from './recherche.js';
|
||||
import { verifierIndicateurs, verifierInvariants } from './verification.js';
|
||||
|
||||
// Compte d'arrêt choisi par la mesure. À 1 000 000 mouvements, chacune des
|
||||
// trois propositions atteint, sur chaque seuil ci-dessous, la meilleure
|
||||
// valeur que la configuration permette, sans rencontre répétée ni retour
|
||||
// choisi. Avec cette graine, chaque seuil tient à partir de son propre
|
||||
// compte :
|
||||
// - rencontres minimales 24, sur tous comme sur les ancrés : déjà à
|
||||
// 100 000 ;
|
||||
// - écart au plafond a priori maximal 1, et rencontres minimales 27 des
|
||||
// mobiles : dès 250 000 ; à 200 000, ils valent 2 et 26 ;
|
||||
// - manque maximal 0, aucun retour choisi : dès 350 000 ; à 300 000, une
|
||||
// proposition garde un manque de 1 et un retour choisi ;
|
||||
// - aucune collision : dès 450 000 ; à 400 000, les trois propositions en
|
||||
// portent 8, 1 et 2.
|
||||
// 1 000 000 vaut ainsi 4 fois le compte où tiennent l'écart au plafond a
|
||||
// priori et les mobiles, 2,9 fois celui du manque et des retours choisis,
|
||||
// 2,2 fois celui des collisions. Sur les graines 1 à 8, les vingt-quatre
|
||||
// propositions tiennent l'écart au plafond a priori et les mobiles dès
|
||||
// 250 000, le manque et les retours choisis dès 350 000, les collisions dès
|
||||
// 500 000 ; à 450 000, deux en portent encore 1 et 5.
|
||||
const REGLAGES = Object.freeze({ graine: 314_159, arret: 1_000_000, nombre: 3 });
|
||||
|
||||
// Seuils de qualité, posés après mesure ; chacun est la meilleure valeur
|
||||
// que la configuration permette.
|
||||
//
|
||||
// Rencontres minimales : 24 mesuré à chacune des trois propositions. C'est
|
||||
// le plafond a priori des quatre animateurs des tables de 7, qu'aucune
|
||||
// proposition ne dépasse (§ 15.1) : le minimum global ne peut pas le
|
||||
// dépasser, et l'épreuve exige qu'il l'atteigne. Les deux lignes
|
||||
// secondaires du § 5.4 se lisent à part : 24 chez les ancrés, que ces
|
||||
// quatre animateurs gouvernent ; 27 chez les mobiles. Un mobile assis une
|
||||
// fois à une table de 7 rencontre 27 personnes au plus, une de moins que
|
||||
// son plafond a priori de 28, et au moins 24 mobiles y passent (§ 15.1) ;
|
||||
// l'écart au plafond a priori maximal de 1 interdit à chacun d'en
|
||||
// rencontrer moins. Un agrégat « tous » qui écarterait les ancrés vaudrait
|
||||
// 27 (§ 5.4).
|
||||
const RENCONTRES_MIN = 24;
|
||||
const RENCONTRES_MIN_ANCRES = 24;
|
||||
const RENCONTRES_MIN_MOBILES = 27;
|
||||
// Écart au plafond a priori maximal : 1 mesuré à chacune des trois
|
||||
// propositions. Il ne descend pas sous l'écart d'itinéraire maximal, dont le
|
||||
// plancher vaut 1 (§ 12.10.4) : ce seuil aussi est la meilleure valeur
|
||||
// possible.
|
||||
const ECART_AU_PLAFOND_A_PRIORI_MAX = 1;
|
||||
// Collisions cumulées : 0 mesuré à chacune des trois propositions, la
|
||||
// meilleure valeur possible ; le diagnostic n'impose à cette configuration
|
||||
// aucun plancher de collisions. Une recherche qui ne sépare pas les
|
||||
// appartenances en laisse de 14 à 20 à chaque proposition.
|
||||
const COLLISIONS_CUMULEES_MAX = 0;
|
||||
|
||||
// Plus grand élément d'une liste non vide.
|
||||
const plusGrand = (valeurs) => valeurs.reduce((plus, valeur) => Math.max(plus, valeur));
|
||||
|
||||
// Nombre de personnes non ancrées dont l'écart d'itinéraire, ecarts[p] dans
|
||||
// l'ordre canonique, atteint plancher. Le diagnostic en écrit le minimum,
|
||||
// mobilesAuMoins, à côté de plancher.
|
||||
function mobilesAuPlancher(instance, ecarts, plancher) {
|
||||
let compte = 0;
|
||||
for (let p = 0; p < instance.N; p += 1) {
|
||||
if (instance.statut[p] !== STATUT.ANCRE && ecarts[p] >= plancher) compte += 1;
|
||||
}
|
||||
return compte;
|
||||
}
|
||||
|
||||
// La génération et ses mesures, calculées au premier appel puis partagées
|
||||
// par les épreuves du fichier : la recherche en est le coût. Chaque
|
||||
// proposition porte la liste de ses violations ; ses mesures, ses plafonds
|
||||
// réalisés, ses trois chiffres et ses écarts d'itinéraire ne sont calculés
|
||||
// que pour un plan sans violation. indexerPlan n'examine ni capacités ni
|
||||
// réservations et lève à la première faute de forme, là où le vérificateur
|
||||
// les nomme toutes.
|
||||
let generation = null;
|
||||
function genererGrande() {
|
||||
if (generation !== null) return generation;
|
||||
const configuration = CATALOGUE.find(({ cle }) => cle === 'grande').construire();
|
||||
const instance = normaliser(configuration);
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
const propositions = rechercher(configuration, REGLAGES).map(({ id, plan }) => {
|
||||
const violations = verifierInvariants(instance, plan);
|
||||
if (violations.length > 0) return { id, violations };
|
||||
const tableDe = indexerPlan(instance, plan);
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
const realises = plafondsRealises(instance, tableDe);
|
||||
return {
|
||||
id,
|
||||
violations,
|
||||
mesures,
|
||||
realises,
|
||||
chiffres: troisChiffres(instance, mesures, aPriori, realises),
|
||||
ecartsItineraire: ecartsItineraire(aPriori, realises),
|
||||
};
|
||||
});
|
||||
generation = { instance, aPriori, diagnostic: diagnostiquer(configuration), propositions };
|
||||
return generation;
|
||||
}
|
||||
|
||||
// Les propositions de la génération, chacune jugée par verifierInvariants
|
||||
// avant toute lecture de ses mesures. Un parcours vide ne prouverait rien
|
||||
// (§ 14.2).
|
||||
function propositionsValides() {
|
||||
const { propositions } = genererGrande();
|
||||
assert.equal(propositions.length, REGLAGES.nombre);
|
||||
for (const { id, violations } of propositions) {
|
||||
assert.deepEqual(violations, [], `proposition ${id}`);
|
||||
}
|
||||
return propositions;
|
||||
}
|
||||
|
||||
describe('intégration : la grande démonstration de bout en bout (§ 5.5, § 12.10.4, § 15.1)', () => {
|
||||
test('chaque proposition tient invariants et indicateurs, porte les 99 retours imposés du diagnostic et aucun retour choisi', () => {
|
||||
const { aPriori, diagnostic } = genererGrande();
|
||||
// 33 ancrages de 4 tours, 3 retours chacun : attribués aux réservations,
|
||||
// les mêmes pour toute proposition (§ 5.4).
|
||||
assert.equal(diagnostic.retoursImposes, 99);
|
||||
for (const { id, mesures, realises } of propositionsValides()) {
|
||||
assert.deepEqual(verifierIndicateurs(mesures, aPriori, realises), [], `proposition ${id}`);
|
||||
assert.equal(mesures.totalRetoursImposes, diagnostic.retoursImposes, `proposition ${id}`);
|
||||
// Retours choisis : 0 mesuré à chacune des trois propositions, la
|
||||
// meilleure valeur possible ; avec cette graine, dès 350 000
|
||||
// mouvements (REGLAGES). Les deux comptes restent séparés, chacun à sa
|
||||
// valeur : leur somme afficherait 99 retours choisis à chaque
|
||||
// proposition, une constante où la différence entre propositions ne se
|
||||
// lit plus (§ 5.4, § 14.10).
|
||||
assert.equal(mesures.totalRetoursChoisis, 0, `proposition ${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("le plancher du diagnostic tient : écart d'itinéraire maximal ≥ 1, au moins 24 mobiles sous leur plafond a priori", () => {
|
||||
const { instance, diagnostic } = genererGrande();
|
||||
// Les tables de 7 offrent 96 sièges-tours mobiles, qu'un mobile n'occupe
|
||||
// que 4 fois au plus : au moins 24 mobiles y passent, et chacun y perd au
|
||||
// moins 1 sur son plafond a priori de 28 avant toute rencontre, son écart
|
||||
// d'itinéraire (§ 12.10.4). Sans ce plancher écrit, les comparaisons
|
||||
// ci-dessous n'éprouveraient rien.
|
||||
assert.deepEqual(diagnostic.ecartItineraire, { plancher: 1, mobilesAuMoins: 24 });
|
||||
const { plancher, mobilesAuMoins } = diagnostic.ecartItineraire;
|
||||
for (const { id, chiffres, ecartsItineraire: ecarts } of propositionsValides()) {
|
||||
assert.ok(
|
||||
chiffres.ecartItineraireMax.tous >= plancher,
|
||||
`proposition ${id} : écart d'itinéraire maximal ${chiffres.ecartItineraireMax.tous}, plancher ${plancher}`,
|
||||
);
|
||||
const mobilesSous = mobilesAuPlancher(instance, ecarts, plancher);
|
||||
assert.ok(
|
||||
mobilesSous >= mobilesAuMoins,
|
||||
`proposition ${id} : ${mobilesSous} mobiles sous leur plafond a priori, au moins ${mobilesAuMoins} attendus`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('jamais « minimum atteint » (§ 15.1), même quand chacun atteint son plafond réalisé', () => {
|
||||
const { aPriori } = genererGrande();
|
||||
for (const { id, mesures, chiffres } of propositionsValides()) {
|
||||
// Manque maximal, seuil posé après mesure : 0 à chacune des trois
|
||||
// propositions, sur les 260 personnes. Chacun rencontre tout ce que son
|
||||
// itinéraire permet ; un certificat lu sur le seul manque serait
|
||||
// décerné, et il ne l'est pas : il se lit contre le plafond a priori
|
||||
// (§ 5.5).
|
||||
assert.equal(chiffres.manqueMax.tous, 0, `proposition ${id}`);
|
||||
assert.equal(minimumAtteint(mesures, aPriori), false, `proposition ${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('seuils de qualité posés après mesure : rencontres minimales et leurs lignes secondaires, écart au plafond a priori maximal, collisions cumulées', () => {
|
||||
const { aPriori } = genererGrande();
|
||||
for (const { id, mesures } of propositionsValides()) {
|
||||
const { tous, ancres, mobiles } = mesures.aggRencontres;
|
||||
assert.equal(tous.min, RENCONTRES_MIN, `proposition ${id} : rencontres minimales`);
|
||||
assert.equal(ancres.min, RENCONTRES_MIN_ANCRES, `proposition ${id} : rencontres minimales des ancrés`);
|
||||
assert.equal(mobiles.min, RENCONTRES_MIN_MOBILES, `proposition ${id} : rencontres minimales des mobiles`);
|
||||
const ecartMax = plusGrand(ecartsAuPlafondAPriori(mesures, aPriori));
|
||||
assert.ok(
|
||||
ecartMax <= ECART_AU_PLAFOND_A_PRIORI_MAX,
|
||||
`proposition ${id} : écart au plafond a priori maximal ${ecartMax}, seuil ${ECART_AU_PLAFOND_A_PRIORI_MAX}`,
|
||||
);
|
||||
assert.ok(
|
||||
mesures.collisionsCumulees <= COLLISIONS_CUMULEES_MAX,
|
||||
`proposition ${id} : ${mesures.collisionsCumulees} collisions cumulées, seuil ${COLLISIONS_CUMULEES_MAX}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Grille de propriétés (§ 14.12). Les quatre démonstrations ont quatre
|
||||
// tours, chacun y porte une appartenance, aucune n'a de partiellement fixé
|
||||
// ni d'exclu, et une seule laisse des places vides : la grille tire des
|
||||
// configurations hors de ce cadre, d'une graine écrite ici. Les propriétés
|
||||
// éprouvées valent pour tout plan que rend la recherche, quelle qu'en soit
|
||||
// la qualité : un compte d'arrêt bref suffit. Un historique court fait jouer
|
||||
// la bascule de la descente dans l'ordre des contraintes, qu'un plancher
|
||||
// compte ci-dessous.
|
||||
const GRILLE = Object.freeze({ graine: 1_729, configurations: 300, arret: 3_000, nombre: 2 });
|
||||
|
||||
// Une salle sur cinq est trop petite, deux sont tendues, deux ont des places
|
||||
// vides.
|
||||
const SALLES = Object.freeze(['tendue', 'tendue', 'places vides', 'places vides', 'trop petite']);
|
||||
const LIBELLES = Object.freeze(['A', 'B', 'C']);
|
||||
|
||||
// Mouvements appliqués où la marche confronte le score tenu à jour au score
|
||||
// recalculé, après le placement initial ; un mouvement appliqué sur trois est
|
||||
// défait, comme dans recherche.long.test.js.
|
||||
const PALIERS = Object.freeze([1, 10, 100, 1_000]);
|
||||
const DEFAIT_TOUS_LES = 3;
|
||||
|
||||
// k entiers distincts de 0 à n − 1, tirés sans remise par le mélange
|
||||
// partiel de Fisher et Yates.
|
||||
function tirerSansRemise(rng, n, k) {
|
||||
const pile = Array.from({ length: n }, (_, i) => i);
|
||||
for (let i = 0; i < k; i += 1) {
|
||||
const j = i + rng.borne(n - i);
|
||||
[pile[i], pile[j]] = [pile[j], pile[i]];
|
||||
}
|
||||
return pile.slice(0, k);
|
||||
}
|
||||
|
||||
// Une configuration de la grille, ses réglages de génération, sa salle et le
|
||||
// nombre de places qui lui manquent, tirés de rng :
|
||||
// - de 2 à 6 tables de 2 à 6 sièges, de 1 à 5 tours ;
|
||||
// - N = S personnes présentes dans une salle tendue, S = Σ c_t ; S − v, v de
|
||||
// 1 à ⌊S / 2⌋, dans une salle à places vides ; S + 1 à S + 3 dans une salle
|
||||
// trop petite ;
|
||||
// - chacun une fois sur deux : des ancrés, des réservations d'une partie des
|
||||
// tours, un ou deux exclus. Une réservation d'une partie des tours fait un
|
||||
// partiellement fixé quand R ≥ 2, parfois deux fois à la même table, et un
|
||||
// ancré par la portée « tour » quand R = 1 ;
|
||||
// - de 1 à 3 appartenances, et des personnes sans appartenance ;
|
||||
// - des identifiants de participant croissants à trous, des identifiants de
|
||||
// table dans un ordre quelconque, une liste de participants mélangée ;
|
||||
// - une réservation d'exclu une fois sur deux, suspendue et sans siège, sa
|
||||
// table fût-elle pleine (§ 4.4), et une réservation en double une fois sur
|
||||
// quatre ;
|
||||
// - chaque contrainte active trois fois sur quatre ; la graine de la
|
||||
// génération, et un historique de 16 à 64 cases.
|
||||
// Une réservation de personne présente ne se pose que sur une table où il
|
||||
// reste un siège à chacun de ses tours : la configuration ne lève jamais
|
||||
// SURRESERVATION, et normaliser l'accepte toujours.
|
||||
function tirerConfiguration(rng) {
|
||||
const T = 2 + rng.borne(5);
|
||||
const capacites = Array.from({ length: T }, () => 2 + rng.borne(5));
|
||||
const R = 1 + rng.borne(5);
|
||||
const S = capacites.reduce((somme, c) => somme + c, 0);
|
||||
const salle = SALLES[rng.borne(SALLES.length)];
|
||||
let N = S;
|
||||
if (salle === 'places vides') N = S - 1 - rng.borne(Math.floor(S / 2));
|
||||
if (salle === 'trop petite') N = S + 1 + rng.borne(3);
|
||||
const avecAncres = rng.borne(2) === 0;
|
||||
const avecToursReserves = rng.borne(2) === 0;
|
||||
const exclus = rng.borne(2) === 0 ? 1 + rng.borne(2) : 0;
|
||||
const G = 1 + rng.borne(LIBELLES.length);
|
||||
|
||||
const ids = [];
|
||||
for (let i = 0, id = 0; i < N + exclus; i += 1) {
|
||||
id += 1 + rng.borne(2);
|
||||
ids.push(id);
|
||||
}
|
||||
const idsExclus = new Set(tirerSansRemise(rng, ids.length, exclus).map((i) => ids[i]));
|
||||
const participants = ids.map((id) => {
|
||||
const g = rng.borne(G + 1);
|
||||
return {
|
||||
id,
|
||||
nom: `P${id}`,
|
||||
appartenance: g < G ? LIBELLES[g] : null,
|
||||
exclu: idsExclus.has(id),
|
||||
};
|
||||
});
|
||||
const idsTables = [];
|
||||
for (let t = 0, id = 0; t < T; t += 1) {
|
||||
id += 1 + rng.borne(3);
|
||||
idsTables.push(id);
|
||||
}
|
||||
rng.melanger(idsTables);
|
||||
const tables = idsTables.map((id, t) => ({ id, numero: t + 1, capacite: capacites[t] }));
|
||||
|
||||
// fixees[t × R + r] : les personnes présentes que les réservations
|
||||
// assoient à la table t au tour r.
|
||||
const fixees = new Int32Array(T * R);
|
||||
const siegeLibre = (t, r) => fixees[t * R + r] < capacites[t];
|
||||
const tousLesTours = Array.from({ length: R }, (_, r) => r);
|
||||
const reservations = [];
|
||||
for (const { id, exclu } of participants) {
|
||||
const role = rng.borne(6);
|
||||
if (exclu) {
|
||||
if (role < 3) {
|
||||
const table = idsTables[rng.borne(T)];
|
||||
const portee = role === 0 ? { portee: 'tous' } : { portee: 'tour', tour: 1 + rng.borne(R) };
|
||||
reservations.push({ participant: id, table, ...portee });
|
||||
}
|
||||
} else if (avecAncres && role === 0) {
|
||||
const t = rng.borne(T);
|
||||
if (tousLesTours.every((r) => siegeLibre(t, r))) {
|
||||
for (const r of tousLesTours) fixees[t * R + r] += 1;
|
||||
reservations.push({ participant: id, table: idsTables[t], portee: 'tous' });
|
||||
}
|
||||
} else if (avecToursReserves && role === 1) {
|
||||
const tours = R === 1 ? 1 : 1 + rng.borne(R - 1);
|
||||
for (const r of tirerSansRemise(rng, R, tours)) {
|
||||
const t = rng.borne(T);
|
||||
if (!siegeLibre(t, r)) continue;
|
||||
fixees[t * R + r] += 1;
|
||||
reservations.push({ participant: id, table: idsTables[t], portee: 'tour', tour: r + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reservations.length > 0 && rng.borne(4) === 0) {
|
||||
reservations.push({ ...reservations[rng.borne(reservations.length)] });
|
||||
}
|
||||
rng.melanger(participants);
|
||||
const contraintes = {
|
||||
separerAppartenances: rng.borne(4) > 0,
|
||||
nouveauxVoisins: rng.borne(4) > 0,
|
||||
nouvelleTable: rng.borne(4) > 0,
|
||||
varierAppartenances: rng.borne(4) > 0,
|
||||
};
|
||||
const reglages = {
|
||||
graine: rng.suivant(),
|
||||
arret: GRILLE.arret,
|
||||
nombre: GRILLE.nombre,
|
||||
historique: 16 + rng.borne(49),
|
||||
};
|
||||
return {
|
||||
configuration: { participants, tables, tours: R, reservations, contraintes },
|
||||
reglages,
|
||||
salle,
|
||||
placesManquantes: Math.max(0, N - S),
|
||||
};
|
||||
}
|
||||
|
||||
// Une marche depuis le placement initial de la proposition de graine
|
||||
// dérivée graine, mouvements tirés du même générateur que sa descente :
|
||||
// chaque palier ajoute à fautes un désaccord entre le score tenu à jour et le
|
||||
// score recalculé. Rend vrai quand la marche atteint le dernier palier. Une
|
||||
// configuration où les mouvements ne s'appliquent pas l'arrête plus tôt :
|
||||
// celle dont chaque tour n'a de sièges mobiles qu'à une seule table, ou n'y
|
||||
// a que des places fantômes.
|
||||
function marcher(nom, instance, graine, fautes) {
|
||||
const rng = creerPcg32(graine, FLUX.RECHERCHE);
|
||||
const etat = creerEtat(instance, rng);
|
||||
const confronter = (appliques) => {
|
||||
const tenu = scoreIncremental(etat);
|
||||
const recalcule = scoreComplet(etat);
|
||||
if (tenu.some((valeur, k) => valeur !== recalcule[k])) {
|
||||
fautes.push(`${nom}, ${appliques} mouvements : score tenu ${tenu}, recalculé ${recalcule}`);
|
||||
}
|
||||
};
|
||||
confronter(0);
|
||||
let appliques = 0;
|
||||
let palier = 0;
|
||||
for (let essai = 0; palier < PALIERS.length && essai < 100 * PALIERS.at(-1); essai += 1) {
|
||||
const mouvement = proposerEtAppliquer(etat, rng);
|
||||
if (mouvement === null) continue;
|
||||
appliques += 1;
|
||||
if (appliques % DEFAIT_TOUS_LES === 0) defaire(etat, mouvement);
|
||||
if (appliques === PALIERS[palier]) {
|
||||
confronter(appliques);
|
||||
palier += 1;
|
||||
}
|
||||
}
|
||||
return palier === PALIERS.length;
|
||||
}
|
||||
|
||||
// Ajoute à fautes ce qu'une proposition enfreint : les deux vérificateurs,
|
||||
// les retours imposés du diagnostic, ses planchers de collisions et d'écart
|
||||
// d'itinéraire, et sa régénération à partir de sa graine dérivée, de son
|
||||
// compte d'arrêt et de son historique, sans son plan ni son identifiant. Ses
|
||||
// mesures ne se lisent que sur un plan sans violation.
|
||||
function juger(nom, { configuration, instance, aPriori, diagnostic }, proposition, fautes) {
|
||||
const { graine, arret, historique, plan } = proposition;
|
||||
const violations = verifierInvariants(instance, plan);
|
||||
if (violations.length > 0) {
|
||||
fautes.push(`${nom} : ${JSON.stringify(violations)}`);
|
||||
return false;
|
||||
}
|
||||
const tableDe = indexerPlan(instance, plan);
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
const realises = plafondsRealises(instance, tableDe);
|
||||
const indicateurs = verifierIndicateurs(mesures, aPriori, realises);
|
||||
if (indicateurs.length > 0) fautes.push(`${nom} : ${JSON.stringify(indicateurs)}`);
|
||||
if (mesures.totalRetoursImposes !== diagnostic.retoursImposes) {
|
||||
fautes.push(`${nom} : ${mesures.totalRetoursImposes} retours imposés, ${diagnostic.retoursImposes} au diagnostic`);
|
||||
}
|
||||
for (const plancher of diagnostic.collisions) {
|
||||
const mesure = mesures.parGroupe.find(({ groupe }) => groupe === plancher.groupe);
|
||||
if (mesure.collisionsCumulees < plancher.plancherCumulees
|
||||
|| mesure.pairesDistinctes < plancher.plancherPairesDistinctes
|
||||
|| (plancher.plancherExcedent !== null && mesure.excedent < plancher.plancherExcedent)) {
|
||||
fautes.push(
|
||||
`${nom}, groupe ${plancher.groupe} : collisions ${mesure.collisionsCumulees} sur `
|
||||
+ `${mesure.pairesDistinctes} paires, excédent ${mesure.excedent}, sous le plancher `
|
||||
+ JSON.stringify(plancher),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (diagnostic.ecartItineraire !== null) {
|
||||
const { plancher, mobilesAuMoins } = diagnostic.ecartItineraire;
|
||||
const { ecartItineraireMax } = troisChiffres(instance, mesures, aPriori, realises);
|
||||
const mobilesSous = mobilesAuPlancher(instance, ecartsItineraire(aPriori, realises), plancher);
|
||||
if (ecartItineraireMax.tous < plancher || mobilesSous < mobilesAuMoins) {
|
||||
fautes.push(
|
||||
`${nom} : écart d'itinéraire maximal ${ecartItineraireMax.tous}, ${mobilesSous} mobiles `
|
||||
+ `à au moins ${plancher}, sous le plancher ${JSON.stringify(diagnostic.ecartItineraire)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (JSON.stringify(regenerer(configuration, { graine, arret, historique })) !== JSON.stringify(plan)) {
|
||||
fautes.push(`${nom} : la régénération rend un autre plan`);
|
||||
}
|
||||
// Une proposition a basculé dans l'ordre des contraintes exactement quand
|
||||
// son plan atteint un écart au plafond a priori maximal nul.
|
||||
return Math.max(...ecartsAuPlafondAPriori(mesures, aPriori)) === 0;
|
||||
}
|
||||
|
||||
// Planchers de la grille (§ 14.2) : ce que la boucle doit avoir éprouvé pour
|
||||
// que ses propriétés disent quelque chose. Un générateur qui cesserait de
|
||||
// tirer l'un de ces cas laisserait les propriétés vraies sans les éprouver.
|
||||
// Les comptes lisent donc ce que la boucle a éprouvé, l'instance, le
|
||||
// diagnostic ou la marche, et non l'étiquette que le générateur a tirée.
|
||||
// Seules les salles trop petites se comptent sur leur étiquette : une salle
|
||||
// ainsi étiquetée qui ne l'est pas fait échouer la boucle, qui exige son
|
||||
// refus. Chaque plancher est posé sous le compte que donne la graine écrite,
|
||||
// en commentaire.
|
||||
const PLANCHERS = Object.freeze([
|
||||
['configurations éprouvées', 'eprouvees', 200], // 230 sur 300
|
||||
['salles trop petites refusées', 'tropPetites', 50], // 70
|
||||
['salles tendues', 'tendues', 90], // 120
|
||||
['salles à places vides', 'placesVides', 80], // 110
|
||||
['avec ancrés', 'ancres', 90], // 122
|
||||
['avec partiellement fixés', 'partiellementFixes', 60], // 87
|
||||
['avec exclus', 'exclus', 90], // 122
|
||||
['à un seul tour', 'unTour', 30], // 47
|
||||
['avec retours imposés', 'retoursImposes', 70], // 103
|
||||
['avec plancher de collisions', 'plancherCollisions', 120], // 173
|
||||
["avec plancher d'excédent", 'plancherExcedent', 10], // 15
|
||||
["avec plancher d'écart d'itinéraire", 'plancherEcartItineraire', 35], // 50
|
||||
['marches au dernier palier', 'marches', 400], // 460, une par proposition
|
||||
["propositions qui basculent dans l'ordre des contraintes", 'bascules', 60], // 86
|
||||
]);
|
||||
|
||||
describe('intégration : grille de propriétés sur des configurations tirées (§ 14.12)', () => {
|
||||
test(`${GRILLE.configurations} configurations : chaque proposition tient les deux vérificateurs, les retours imposés et les planchers du diagnostic, et se régénère ; le score tenu à jour égale le recalcul le long d'une marche tirée de sa graine dérivée ; une salle trop petite est refusée du nombre de places du diagnostic`, () => {
|
||||
const rng = creerPcg32(GRILLE.graine, FLUX.DEMO);
|
||||
const comptes = {
|
||||
eprouvees: 0,
|
||||
tropPetites: 0,
|
||||
tendues: 0,
|
||||
placesVides: 0,
|
||||
ancres: 0,
|
||||
partiellementFixes: 0,
|
||||
exclus: 0,
|
||||
unTour: 0,
|
||||
retoursImposes: 0,
|
||||
plancherCollisions: 0,
|
||||
plancherExcedent: 0,
|
||||
plancherEcartItineraire: 0,
|
||||
marches: 0,
|
||||
bascules: 0,
|
||||
};
|
||||
const fautes = [];
|
||||
for (let c = 1; c <= GRILLE.configurations; c += 1) {
|
||||
const { configuration, reglages, salle, placesManquantes } = tirerConfiguration(rng);
|
||||
const nom = `configuration ${c}`;
|
||||
const instance = normaliser(configuration);
|
||||
const diagnostic = diagnostiquer(configuration);
|
||||
if (diagnostic.placesManquantes !== placesManquantes) {
|
||||
fautes.push(`${nom} : ${diagnostic.placesManquantes} places manquantes au diagnostic, ${placesManquantes} tirées`);
|
||||
}
|
||||
if (salle === 'trop petite') {
|
||||
comptes.tropPetites += 1;
|
||||
try {
|
||||
rechercher(configuration, reglages);
|
||||
fautes.push(`${nom} : salle trop petite acceptée`);
|
||||
} catch (erreur) {
|
||||
if (erreur.code !== 'PLACES_MANQUANTES'
|
||||
|| erreur.details.placesManquantes !== diagnostic.placesManquantes) {
|
||||
fautes.push(`${nom} : ${erreur.message}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
comptes.eprouvees += 1;
|
||||
if (nombrePlaces(instance) === instance.N) comptes.tendues += 1;
|
||||
else comptes.placesVides += 1;
|
||||
if (instance.k > 0) comptes.ancres += 1;
|
||||
if (instance.statut.includes(STATUT.PARTIELLEMENT_FIXE)) comptes.partiellementFixes += 1;
|
||||
if (instance.exclus.size > 0) comptes.exclus += 1;
|
||||
if (instance.R === 1) comptes.unTour += 1;
|
||||
if (diagnostic.retoursImposes > 0) comptes.retoursImposes += 1;
|
||||
if (diagnostic.collisions.length > 0) comptes.plancherCollisions += 1;
|
||||
if (diagnostic.collisions.some(({ plancherExcedent }) => plancherExcedent !== null)) {
|
||||
comptes.plancherExcedent += 1;
|
||||
}
|
||||
if (diagnostic.ecartItineraire !== null) comptes.plancherEcartItineraire += 1;
|
||||
const contexte = { configuration, instance, aPriori: plafondsAPriori(instance), diagnostic };
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
if (propositions.length !== GRILLE.nombre) fautes.push(`${nom} : ${propositions.length} propositions`);
|
||||
for (const proposition of propositions) {
|
||||
const nomProposition = `${nom}, proposition ${proposition.id}`;
|
||||
if (juger(nomProposition, contexte, proposition, fautes)) comptes.bascules += 1;
|
||||
if (marcher(nomProposition, instance, proposition.graine, fautes)) comptes.marches += 1;
|
||||
}
|
||||
}
|
||||
// Les vingt premières fautes suffisent à désigner un défaut ; le message
|
||||
// en donne le nombre.
|
||||
assert.deepEqual(fautes.slice(0, 20), [], `${fautes.length} fautes`);
|
||||
const sousLePlancher = PLANCHERS
|
||||
.filter(([, cle, plancher]) => comptes[cle] < plancher)
|
||||
.map(([libelle, cle, plancher]) => `${libelle} : ${comptes[cle]}, plancher ${plancher}`);
|
||||
assert.deepEqual(sousLePlancher, [], JSON.stringify(comptes));
|
||||
});
|
||||
});
|
||||
224
src/moteur/integration.test.js
Normal file
224
src/moteur/integration.test.js
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuve d'intégration du moteur, de la configuration au classement, sur la
|
||||
// petite démonstration et sa variante conflit (§ 5.5, § 5.6, § 5.7,
|
||||
// § 15.3). Chaque proposition de rechercher traverse la chaîne entière :
|
||||
// verifierInvariants juge son plan ; mesurer et plafondsRealises le
|
||||
// mesurent ; verifierIndicateurs éprouve la ligne de chacun contre le
|
||||
// plafond a priori ; classer ordonne les propositions, et minimumAtteint lit
|
||||
// le certificat de la première ; diagnostiquer fournit les planchers que la
|
||||
// meilleure respecte. Chaque quantité vient de son module (§ 13.2) :
|
||||
// l'épreuve n'en recalcule aucune.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
|
||||
import { CRITERES, classer } from './classement.js';
|
||||
import { indexerPlan, normaliser } from './configuration.js';
|
||||
import { diagnostiquer } from './diagnostic.js';
|
||||
import { mesurer } from './indicateurs.js';
|
||||
import { minimumAtteint } from './manque.js';
|
||||
import { plafondsAPriori, plafondsRealises } from './plafond.js';
|
||||
import { rechercher } from './recherche.js';
|
||||
import { verifierIndicateurs, verifierInvariants } from './verification.js';
|
||||
|
||||
// Réglages de la génération : une graine fixée, écrite ici (§ 15.5, point 2).
|
||||
// À 100 000 mouvements, avec cette graine, les trois propositions de chaque
|
||||
// démonstration rencontrent 8 pour les douze, sans rencontre répétée, et ne
|
||||
// portent que les collisions que le diagnostic impose : aucune dans la
|
||||
// petite démonstration, 4 dans sa variante. Une proposition de la variante
|
||||
// garde 15 retours choisis, les cinq autres aucun. Les assertions des
|
||||
// épreuves tiennent avec cette graine dès 80 000 mouvements, soit une marge
|
||||
// de 1,25 ; en deçà, les trois propositions de la petite démonstration
|
||||
// gardent des retours choisis. La marge est celle de cette graine : sur les
|
||||
// graines 1 à 64, les assertions tiennent pour 43 à 100 000, pour toutes à
|
||||
// 200 000.
|
||||
//
|
||||
// Le compte pèse sur la relance sous surveillance (§ 14.14) : toute
|
||||
// modification du moteur, ou du générateur que lit le catalogue, rejoue ce
|
||||
// fichier.
|
||||
const REGLAGES = Object.freeze({ graine: 314_159, arret: 100_000, nombre: 3 });
|
||||
|
||||
// La même génération, arrêtée au premier mouvement. Aucune de ses
|
||||
// propositions n'atteint son plafond a priori : avec cette graine, leurs
|
||||
// écarts au plafond a priori maximaux valent 2, 3 et 3, et sur les graines
|
||||
// 1 à 64, aucune proposition brève ne l'atteint non plus.
|
||||
const REGLAGES_BREVES = Object.freeze({ ...REGLAGES, arret: 1 });
|
||||
|
||||
// Configuration neuve d'une démonstration du catalogue.
|
||||
const demo = (cle) => CATALOGUE.find((entree) => entree.cle === cle).construire();
|
||||
|
||||
// Génère les propositions d'une configuration et les fait traverser la
|
||||
// chaîne. Chaque plan est jugé par verifierInvariants avant d'être indexé :
|
||||
// indexerPlan n'examine ni capacités ni réservations et lève à la première
|
||||
// faute de forme, là où le vérificateur les nomme toutes. Vient ensuite la
|
||||
// ligne N − 1 ≥ a priori ≥ réalisé ≥ rencontres de chacun. decalage
|
||||
// s'ajoute aux identifiants que rend rechercher, comme l'appelant qui
|
||||
// accumule des générations donne à chaque proposition un identifiant unique
|
||||
// (§ 5.7). Rend le plafond a priori de chacun, et les propositions dans
|
||||
// l'ordre de leurs identifiants, avec leurs mesures.
|
||||
function generer(configuration, etiquette, reglages, decalage = 0) {
|
||||
const instance = normaliser(configuration);
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
const propositions = rechercher(configuration, reglages).map(({ id, plan }) => {
|
||||
const nom = `${etiquette}, proposition ${id + decalage}`;
|
||||
assert.deepEqual(verifierInvariants(instance, plan), [], nom);
|
||||
const tableDe = indexerPlan(instance, plan);
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
const realises = plafondsRealises(instance, tableDe);
|
||||
assert.deepEqual(verifierIndicateurs(mesures, aPriori, realises), [], nom);
|
||||
return { id: id + decalage, mesures };
|
||||
});
|
||||
// Un parcours vide ne prouverait rien (§ 14.2).
|
||||
assert.equal(propositions.length, reglages.nombre, etiquette);
|
||||
return { aPriori, propositions };
|
||||
}
|
||||
|
||||
// Le classement de propositions mesurées sur une même configuration, et
|
||||
// celle qu'il met en tête.
|
||||
function classerPropositions({ aPriori, propositions }) {
|
||||
const classement = classer(
|
||||
propositions.map(({ id, mesures }) => ({ id, mesures, plafondsAPriori: aPriori })),
|
||||
);
|
||||
const premiere = propositions.find(({ id }) => id === classement.ordre[0]);
|
||||
return { classement, premiere };
|
||||
}
|
||||
|
||||
// La génération de la variante conflit aux réglages de l'épreuve, calculée
|
||||
// au premier appel puis partagée par les deux épreuves qui la lisent : la
|
||||
// recherche en est le coût.
|
||||
let variante = null;
|
||||
function genererVariante() {
|
||||
variante ??= generer(demo('petite-conflit'), 'petite-conflit', REGLAGES);
|
||||
return variante;
|
||||
}
|
||||
|
||||
// Le plan écrit du § 15.3 fait visiter les quatre tables à chacun : aucun
|
||||
// retour choisi. Le classement ne lit pas les retours (§ 5.7) : entre
|
||||
// propositions égales sur ses cinq critères, l'identifiant désigne la
|
||||
// première, qu'elle garde des retours ou non. L'épreuve exige donc le zéro
|
||||
// d'une proposition au moins, la preuve que la recherche tient compte de
|
||||
// nouvelleTable.
|
||||
function exigerUneSansRetourChoisi(etiquette, propositions) {
|
||||
const retours = propositions.map(({ mesures }) => mesures.totalRetoursChoisis);
|
||||
assert.ok(retours.includes(0), `${etiquette} : retours choisis par proposition ${retours.join(', ')}`);
|
||||
}
|
||||
|
||||
describe('intégration : de la configuration au classement (§ 5.5, § 5.7, § 15.3)', () => {
|
||||
test('petite : chaque proposition tient invariants et indicateurs et ne porte aucune collision, une au moins aucun retour choisi ; la première du classement porte « minimum atteint »', () => {
|
||||
const generation = generer(demo('petite'), 'petite', REGLAGES);
|
||||
const { classement, premiere } = classerPropositions(generation);
|
||||
// Le plafond a priori est connu : aucun critère n'est sauté, et le
|
||||
// certificat se lit contre lui (§ 5.5).
|
||||
assert.deepEqual(classement.criteresAppliques, [...CRITERES]);
|
||||
assert.equal(classement.critereSaute, null);
|
||||
assert.equal(minimumAtteint(premiere.mesures, generation.aPriori), true);
|
||||
// Le certificat ne lit que les rencontres : il se décerne aussi à un plan
|
||||
// qui réunit des collègues. Chaque appartenance de quatre membres peut
|
||||
// se répartir sur les quatre tables à chaque tour, comme dans le plan
|
||||
// écrit du § 15.3 : aucune proposition ne réunit de collègues, et le
|
||||
// compteur de collisions se mesure à zéro (§ 15.3).
|
||||
for (const { id, mesures } of generation.propositions) {
|
||||
assert.equal(mesures.collisionsCumulees, 0, `proposition ${id}`);
|
||||
}
|
||||
exigerUneSansRetourChoisi('petite', generation.propositions);
|
||||
});
|
||||
|
||||
test('petite-conflit : chaque proposition porte les 4 collisions du plancher du diagnostic, une au moins aucun retour choisi ; la meilleure respecte les planchers, rencontre 8 pour les douze, sur 4 paires, excédent 0', () => {
|
||||
const configuration = demo('petite-conflit');
|
||||
// Le groupe de cinq ne se répartit pas sur quatre tables : au moins une
|
||||
// collision par tour, 4 cumulées sur les 4 tours, portées par une paire
|
||||
// distincte au moins (§ 15.3). Le diagnostic les chiffre ; une liste vide
|
||||
// laisserait la boucle ci-dessous sans rien éprouver.
|
||||
const { collisions } = diagnostiquer(configuration);
|
||||
assert.deepEqual(
|
||||
collisions.map(({ plancherCumulees, plancherPairesDistinctes }) => [
|
||||
plancherCumulees,
|
||||
plancherPairesDistinctes,
|
||||
]),
|
||||
[[4, 1]],
|
||||
);
|
||||
const generation = genererVariante();
|
||||
const { premiere } = classerPropositions(generation);
|
||||
// Tout plan valide respecte ces planchers : ils éprouvent l'accord du
|
||||
// diagnostic et de la mesure, non la recherche.
|
||||
for (const plancher of collisions) {
|
||||
const mesure = premiere.mesures.parGroupe.find(({ groupe }) => groupe === plancher.groupe);
|
||||
assert.ok(
|
||||
mesure.collisionsCumulees >= plancher.plancherCumulees,
|
||||
`${plancher.groupe} : ${mesure.collisionsCumulees} collisions cumulées, plancher ${plancher.plancherCumulees}`,
|
||||
);
|
||||
assert.ok(
|
||||
mesure.pairesDistinctes >= plancher.plancherPairesDistinctes,
|
||||
`${plancher.groupe} : ${mesure.pairesDistinctes} paires distinctes, plancher ${plancher.plancherPairesDistinctes}`,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(premiere.mesures.rencontres, Array(12).fill(8));
|
||||
// La recherche se juge à ses collisions : chaque proposition atteint le
|
||||
// plancher des collisions cumulées sans le dépasser. La meilleure les
|
||||
// porte sur 4 paires distinctes, comme le plan écrit du § 15.3 (épreuve
|
||||
// suivante).
|
||||
for (const { id, mesures } of generation.propositions) {
|
||||
assert.equal(mesures.collisionsCumulees, collisions[0].plancherCumulees, `proposition ${id}`);
|
||||
}
|
||||
assert.equal(premiere.mesures.pairesDistinctes, 4);
|
||||
// Deux voisins par tour, quatre tours : qui rencontre 8 personnes n'en
|
||||
// revoit aucune. Chaque paire se réunit donc une fois au plus, et
|
||||
// l'excédent 0 découle de 8 pour les douze. Il éprouve mesurer, non la
|
||||
// recherche.
|
||||
assert.equal(premiere.mesures.excedentCollisions, 0);
|
||||
exigerUneSansRetourChoisi('petite-conflit', generation.propositions);
|
||||
});
|
||||
|
||||
test('le plan écrit du § 15.3, mesuré sur la variante conflit : 8 pour les douze, 4 collisions sur 4 paires, excédent 0', () => {
|
||||
// Le témoin de l'épreuve précédente : la personne passée de C à A
|
||||
// retrouve un membre de A à chacun des quatre tours, chaque fois un
|
||||
// autre. Ce que l'épreuve exige de la recherche est donc atteignable.
|
||||
const instance = normaliser(demo('petite-conflit'));
|
||||
const mesures = mesurer(instance, indexerPlan(instance, PLAN_PARFAIT_PETITE));
|
||||
assert.deepEqual(mesures.rencontres, Array(12).fill(8));
|
||||
assert.equal(mesures.collisionsCumulees, 4);
|
||||
assert.equal(mesures.pairesDistinctes, 4);
|
||||
assert.equal(mesures.excedentCollisions, 0);
|
||||
});
|
||||
|
||||
test('petite-conflit, deux générations accumulées : classer met la plus longue en tête, numérotée après la brève, et départage ses propositions égales par identifiant croissant (§ 5.7, § 15.5)', () => {
|
||||
// La génération brève prend les identifiants 1 à 3, la longue 4 à 6 :
|
||||
// l'ordre attendu contredit l'ordre des identifiants, et seul un
|
||||
// classement qui applique ses critères peut le rendre.
|
||||
const breve = generer(demo('petite-conflit'), 'petite-conflit, génération brève', REGLAGES_BREVES);
|
||||
const variante = genererVariante();
|
||||
const longue = {
|
||||
aPriori: variante.aPriori,
|
||||
propositions: variante.propositions.map(({ id, mesures }) => ({ id: id + REGLAGES.nombre, mesures })),
|
||||
};
|
||||
// Ce que l'ordre départage, mesuré avant d'être lu. Chaque proposition
|
||||
// longue atteint son plafond a priori, et les trois sont égales sur les
|
||||
// quatre autres critères du classement ; aucune brève n'atteint le sien.
|
||||
// Sur une même population, la redondance moyenne ordonne comme la somme
|
||||
// que lit le classement.
|
||||
const autresCriteres = ({ mesures }) => [
|
||||
mesures.excedentCollisions,
|
||||
mesures.collisionsCumulees,
|
||||
mesures.rencontresRepetees.choisies,
|
||||
mesures.aggRedondance.tous.moyenne,
|
||||
];
|
||||
for (const proposition of longue.propositions) {
|
||||
const nom = `proposition ${proposition.id}`;
|
||||
assert.equal(minimumAtteint(proposition.mesures, longue.aPriori), true, nom);
|
||||
assert.deepEqual(autresCriteres(proposition), autresCriteres(longue.propositions[0]), nom);
|
||||
}
|
||||
for (const { id, mesures } of breve.propositions) {
|
||||
assert.equal(minimumAtteint(mesures, breve.aPriori), false, `proposition ${id}`);
|
||||
}
|
||||
// Le premier critère renvoie les brèves derrière les longues ; les
|
||||
// longues, égales sur les cinq, se départagent par identifiant croissant
|
||||
// (§ 15.5, point 4).
|
||||
const { classement } = classerPropositions({
|
||||
aPriori: longue.aPriori,
|
||||
propositions: [...breve.propositions, ...longue.propositions],
|
||||
});
|
||||
assert.deepEqual(classement.ordre.slice(0, REGLAGES.nombre), [4, 5, 6]);
|
||||
assert.deepEqual([...classement.ordre.slice(REGLAGES.nombre)].sort((a, b) => a - b), [1, 2, 3]);
|
||||
});
|
||||
});
|
||||
333
src/moteur/manque.js
Normal file
333
src/moteur/manque.js
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le manque, et ce qui s'en lit (§ 5.5, § 12.10). Chaque personne porte quatre
|
||||
// quantités, rangées sur une ligne (§ 12.10.5) :
|
||||
//
|
||||
// N − 1 ≥ plafond a priori ≥ plafond réalisé ≥ rencontres
|
||||
//
|
||||
// Trois différences s'en lisent, sous les noms du glossaire du § 5.5 :
|
||||
// manque = plafond réalisé − rencontres ; écart d'itinéraire = plafond a
|
||||
// priori − plafond réalisé ; écart au plafond a priori = plafond a priori −
|
||||
// rencontres, la somme des deux premières. Chacune a sa fonction : manques,
|
||||
// ecartsItineraire, ecartsAuPlafondAPriori. Chacune est une soustraction sur
|
||||
// des tableaux que mesurer (indicateurs.js) et plafond.js ont déjà calculés :
|
||||
// aucune fonction ne relit le plan ni les paires qu'il réunit, et aucune ne
|
||||
// modifie ce qu'elle reçoit. Tout tableau par personne suit l'ordre
|
||||
// canonique, celui de instance.ids.
|
||||
//
|
||||
// Les agrégats portent sur les trois populations du § 5.4 : tous, les
|
||||
// mobiles — partiellement fixés compris — et les ancrés, que forme
|
||||
// replierParPopulation (configuration.js). Une population vide rend null,
|
||||
// jamais 0.
|
||||
//
|
||||
// Le plafond a priori vaut null quand il est inconnu (§ 12.10.9) : seul null
|
||||
// le dit. undefined, la valeur d'un champ manquant, lève TypeError au lieu
|
||||
// de passer pour inconnu. Une liste qui n'a pas une case par personne lève
|
||||
// RangeError : elle décrit une autre population.
|
||||
import { replierParPopulation } from './configuration.js';
|
||||
|
||||
/**
|
||||
* @typedef {{tous: number|null, mobiles: number|null, ancres: number|null}} ParPopulation
|
||||
* un chiffre par population ; null pour une population vide
|
||||
*
|
||||
* @typedef {Object} TroisChiffres les trois chiffres du § 12.10.4
|
||||
* @property {ParPopulation} manqueMax le plus grand manque
|
||||
* @property {ParPopulation} effectifManque personnes dont le manque vaut au
|
||||
* moins 1 ; 0 sur une population
|
||||
* non vide sans manque
|
||||
* @property {ParPopulation|null} ecartItineraireMax le plus grand écart
|
||||
* d'itinéraire ; null quand le plafond a priori est inconnu
|
||||
*
|
||||
* @typedef {{id: number, manque: number, plafondRealise: number}} RangProfil
|
||||
* la personne d'un rang du profil, désignée par son identifiant
|
||||
*
|
||||
* @typedef {Object} Comparaison
|
||||
* @property {boolean} comparable
|
||||
* @property {number} [rangsA] rangs où A sert mieux
|
||||
* @property {number} [rangsB] rangs où B sert mieux
|
||||
* @property {number} [egalite] rangs à manques égaux
|
||||
* @property {number|null} [rangBascule] à partir de 1
|
||||
* @property {'A'|'B'|null} [domine]
|
||||
*/
|
||||
|
||||
// Lève TypeError quand valeurs n'est pas une liste, faute de longueur
|
||||
// numérique ; attendu nomme ce que le paramètre nom admet.
|
||||
function exigerListe(valeurs, nom, attendu = 'liste') {
|
||||
if (typeof valeurs?.length !== 'number') throw new TypeError(`${nom} : ${attendu} attendue`);
|
||||
}
|
||||
|
||||
// Lève TypeError quand valeurs n'est pas une liste, et RangeError quand elle
|
||||
// n'a pas N cases.
|
||||
function exigerLongueur(valeurs, N, nom) {
|
||||
exigerListe(valeurs, nom);
|
||||
if (valeurs.length !== N) {
|
||||
throw new RangeError(`${nom} : ${valeurs.length} cases, ${N} attendues`);
|
||||
}
|
||||
}
|
||||
|
||||
// Lève TypeError quand plafondsAPriori n'est ni null ni une liste, et
|
||||
// RangeError quand c'est une liste qui n'a pas N cases.
|
||||
function exigerAPriori(plafondsAPriori, N) {
|
||||
if (plafondsAPriori === null) return;
|
||||
exigerListe(plafondsAPriori, 'plafondsAPriori', 'liste ou null');
|
||||
exigerLongueur(plafondsAPriori, N, 'plafondsAPriori');
|
||||
}
|
||||
|
||||
// gauche[p] − droite[p] pour chaque personne, deux listes de même longueur.
|
||||
function soustraire(gauche, droite) {
|
||||
const differences = [];
|
||||
for (let p = 0; p < gauche.length; p += 1) differences.push(gauche[p] - droite[p]);
|
||||
return differences;
|
||||
}
|
||||
|
||||
// Replie valeurs[p] sur les trois populations (replierParPopulation).
|
||||
// replier(cumul, valeur) rend le cumul suivant ; cumul vaut null au premier
|
||||
// membre d'une population, et une population sans membre reste à null.
|
||||
const parPopulation = (valeurs, statut, replier) =>
|
||||
replierParPopulation(valeurs, statut, null, replier);
|
||||
|
||||
// Le plus grand des valeurs repliées.
|
||||
const plusGrand = (cumul, valeur) => (cumul === null || valeur > cumul ? valeur : cumul);
|
||||
|
||||
// Le nombre de valeurs repliées qui valent au moins 1.
|
||||
const compterAuMoinsUn = (cumul, valeur) => (cumul ?? 0) + (valeur >= 1 ? 1 : 0);
|
||||
|
||||
/**
|
||||
* Manque de chacun (§ 12.10.2) : plafond réalisé − rencontres, en personnes,
|
||||
* dans l'ordre des deux listes. Un manque négatif est le signe d'un calcul
|
||||
* faux, que verifierIndicateurs signale (DEPASSEMENT) ; manques le rend tel
|
||||
* quel.
|
||||
*
|
||||
* Lève TypeError quand l'un des deux arguments n'est pas une liste, et
|
||||
* RangeError quand les deux listes n'ont pas la même longueur.
|
||||
*
|
||||
* @param {ArrayLike<number>} plafondsRealises
|
||||
* @param {ArrayLike<number>} rencontres
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function manques(plafondsRealises, rencontres) {
|
||||
exigerListe(plafondsRealises, 'plafondsRealises');
|
||||
exigerLongueur(rencontres, plafondsRealises.length, 'rencontres');
|
||||
return soustraire(plafondsRealises, rencontres);
|
||||
}
|
||||
|
||||
/**
|
||||
* Écart d'itinéraire de chacun (§ 5.5, § 12.10.4) : plafond a priori −
|
||||
* plafond réalisé, ce que l'itinéraire de la proposition retire avant toute
|
||||
* rencontre. Une personne en réserve à plusieurs tours le porte quand son
|
||||
* manque est nul (§ 12.6). Son plus grand est le troisième des trois chiffres.
|
||||
* null quand le plafond a priori est inconnu.
|
||||
*
|
||||
* Lève TypeError quand plafondsRealises n'est pas une liste, ou que
|
||||
* plafondsAPriori n'est ni une liste ni null, et RangeError quand
|
||||
* plafondsAPriori n'a pas une case par élément de plafondsRealises.
|
||||
*
|
||||
* @param {ArrayLike<number>|null} plafondsAPriori
|
||||
* @param {ArrayLike<number>} plafondsRealises
|
||||
* @returns {number[]|null}
|
||||
*/
|
||||
export function ecartsItineraire(plafondsAPriori, plafondsRealises) {
|
||||
exigerListe(plafondsRealises, 'plafondsRealises');
|
||||
exigerAPriori(plafondsAPriori, plafondsRealises.length);
|
||||
return plafondsAPriori === null ? null : soustraire(plafondsAPriori, plafondsRealises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Écart au plafond a priori de chacun (§ 5.5) : plafond a priori −
|
||||
* rencontres, soit le manque plus l'écart d'itinéraire. Le certificat de
|
||||
* « minimum atteint » le lit, et son plus grand ouvre le classement (§ 5.7).
|
||||
* null quand le plafond a priori est inconnu.
|
||||
*
|
||||
* Lève TypeError quand mesures.rencontres n'est pas une liste, ou que
|
||||
* plafondsAPriori n'est ni une liste ni null, et RangeError quand
|
||||
* plafondsAPriori n'a pas une case par élément de mesures.rencontres.
|
||||
*
|
||||
* @param {{rencontres: ArrayLike<number>}} mesures seule la liste rencontres
|
||||
* est lue
|
||||
* @param {ArrayLike<number>|null} plafondsAPriori
|
||||
* @returns {number[]|null}
|
||||
*/
|
||||
export function ecartsAuPlafondAPriori(mesures, plafondsAPriori) {
|
||||
const { rencontres } = mesures;
|
||||
exigerListe(rencontres, 'mesures.rencontres');
|
||||
exigerAPriori(plafondsAPriori, rencontres.length);
|
||||
return plafondsAPriori === null ? null : soustraire(plafondsAPriori, rencontres);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les trois chiffres de la page de qualité (§ 12.10.4), chacun sur les trois
|
||||
* populations du § 5.4 :
|
||||
* 1. manqueMax, le plus grand manque ;
|
||||
* 2. effectifManque, le nombre de personnes dont le manque vaut au moins 1 :
|
||||
* 0 sur une population non vide sans manque, un zéro mesuré ;
|
||||
* 3. ecartItineraireMax, le plus grand écart d'itinéraire, plafond a priori
|
||||
* − plafond réalisé, tel que ecartsItineraire le rend. Quand le plafond
|
||||
* a priori est inconnu, ce chiffre vaut null tout entier, et non par
|
||||
* population : il s'écrit « inconnu », non « — » (§ 12.10.9).
|
||||
* Une population vide vaut null dans chacun.
|
||||
*
|
||||
* Le troisième chiffre voit ce que les deux premiers ne voient pas : une
|
||||
* personne assise à peu de tours a un plafond réalisé bas, un manque nul, et
|
||||
* un grand écart d'itinéraire.
|
||||
*
|
||||
* Lève RangeError quand mesures.rencontres, plafondsRealises ou
|
||||
* plafondsAPriori n'a pas une case par participant de l'instance ; TypeError
|
||||
* quand l'une d'elles n'est pas une liste, plafondsAPriori pouvant valoir
|
||||
* null.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance N et statut sont lus
|
||||
* @param {{rencontres: ArrayLike<number>}} mesures seule la liste rencontres
|
||||
* est lue
|
||||
* @param {ArrayLike<number>|null} plafondsAPriori
|
||||
* @param {ArrayLike<number>} plafondsRealises
|
||||
* @returns {TroisChiffres}
|
||||
*/
|
||||
export function troisChiffres(instance, mesures, plafondsAPriori, plafondsRealises) {
|
||||
const { N, statut } = instance;
|
||||
// Ces gardes confrontent chaque liste à l'instance. Celles de manques et
|
||||
// d'ecartsItineraire ne comparent les listes qu'entre elles, et laissent
|
||||
// passer trois listes accordées mais mesurées sur une autre population,
|
||||
// celles d'une proposition mesurée avant une exclusion par exemple. La case
|
||||
// p n'y désigne plus la personne p de l'instance, et parPopulation lirait
|
||||
// sans bruit le statut d'une autre personne, ou au-delà de N.
|
||||
exigerLongueur(mesures.rencontres, N, 'mesures.rencontres');
|
||||
exigerLongueur(plafondsRealises, N, 'plafondsRealises');
|
||||
exigerAPriori(plafondsAPriori, N);
|
||||
const manque = manques(plafondsRealises, mesures.rencontres);
|
||||
const ecarts = ecartsItineraire(plafondsAPriori, plafondsRealises);
|
||||
return {
|
||||
manqueMax: parPopulation(manque, statut, plusGrand),
|
||||
effectifManque: parPopulation(manque, statut, compterAuMoinsUn),
|
||||
ecartItineraireMax: ecarts === null ? null : parPopulation(ecarts, statut, plusGrand),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Profil de manque d'une proposition (§ 12.10.6) : une entrée par
|
||||
* participant, triée par manque décroissant, puis par plafond réalisé
|
||||
* croissant — à manque égal, la personne au plafond le plus bas,
|
||||
* proportionnellement la plus privée, d'abord (§ 12.10.3) —, puis par
|
||||
* identifiant croissant. Deux participants n'ont jamais le même identifiant :
|
||||
* la clé ordonne tout, sans dépendre de l'ordre reçu.
|
||||
*
|
||||
* Lève RangeError quand manques ou plafondsRealises n'a pas une case par
|
||||
* participant.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance N et ids sont lus
|
||||
* @param {ArrayLike<number>} manques ordre canonique
|
||||
* @param {ArrayLike<number>} plafondsRealises ordre canonique
|
||||
* @returns {RangProfil[]}
|
||||
*/
|
||||
export function profilManque(instance, manques, plafondsRealises) {
|
||||
const { N, ids } = instance;
|
||||
exigerLongueur(manques, N, 'manques');
|
||||
exigerLongueur(plafondsRealises, N, 'plafondsRealises');
|
||||
const profil = [];
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
profil.push({ id: ids[p], manque: manques[p], plafondRealise: plafondsRealises[p] });
|
||||
}
|
||||
return profil.sort(
|
||||
(a, b) => b.manque - a.manque || a.plafondRealise - b.plafondRealise || a.id - b.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Vrai quand les deux profils portent les mêmes identifiants, chacun autant
|
||||
// de fois : leurs listes d'identifiants, triées, coïncident.
|
||||
function memesPersonnes(profilA, profilB) {
|
||||
if (profilA.length !== profilB.length) return false;
|
||||
const croissants = (profil) => profil.map(({ id }) => id).sort((x, y) => x - y);
|
||||
const idsB = croissants(profilB);
|
||||
return croissants(profilA).every((id, i) => id === idsB[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare deux profils de manque rang par rang (§ 12.10.6, § 12.10.7). Chaque
|
||||
* profil vient de profilManque et suit son propre ordre : le rang i désigne
|
||||
* en général deux personnes différentes, et la comparaison ne dit rien d'une
|
||||
* personne.
|
||||
*
|
||||
* Deux profils qui ne portent pas les mêmes identifiants rendent
|
||||
* { comparable: false } : leurs rangs ne se correspondent pas (§ 12.6).
|
||||
* Sinon, au rang i, la proposition au plus petit manque sert mieux, et la
|
||||
* comparaison porte :
|
||||
* - rangsA, rangsB, egalite : les rangs où A sert mieux, où B sert mieux, où
|
||||
* les deux manques sont égaux ;
|
||||
* - rangBascule : quand le signe de manqueA(i) − manqueB(i), les égalités
|
||||
* omises, change une fois et une seule, le rang, compté à partir de 1, où
|
||||
* la proposition qui servait moins bien commence à servir strictement
|
||||
* mieux. Avant lui, elle ne sert mieux à aucun rang ; à partir de lui,
|
||||
* l'autre ne sert plus mieux à aucun. null quand le signe ne change pas, ou
|
||||
* change plusieurs fois ;
|
||||
* - domine : 'A' quand A sert au moins aussi bien que B à chaque rang et
|
||||
* strictement mieux à l'un d'eux, 'B' dans le cas symétrique, null sinon.
|
||||
* Deux profils égaux à chaque rang ne se dominent pas.
|
||||
* Un parcours des rangs, après la comparaison des identifiants, suffit.
|
||||
*
|
||||
* @param {RangProfil[]} profilA
|
||||
* @param {RangProfil[]} profilB
|
||||
* @returns {Comparaison}
|
||||
*/
|
||||
export function comparerProfils(profilA, profilB) {
|
||||
if (!memesPersonnes(profilA, profilB)) return { comparable: false };
|
||||
let rangsA = 0;
|
||||
let rangsB = 0;
|
||||
let egalite = 0;
|
||||
// Signe du dernier rang à manques différents : −1 quand A y sert mieux, +1
|
||||
// quand B y sert mieux, 0 avant le premier.
|
||||
let signe = 0;
|
||||
let changements = 0;
|
||||
let bascule = null;
|
||||
for (let i = 0; i < profilA.length; i += 1) {
|
||||
const difference = profilA[i].manque - profilB[i].manque;
|
||||
if (difference === 0) {
|
||||
egalite += 1;
|
||||
continue;
|
||||
}
|
||||
const signeRang = difference < 0 ? -1 : 1;
|
||||
if (signeRang < 0) rangsA += 1;
|
||||
else rangsB += 1;
|
||||
if (signe !== 0 && signeRang !== signe) {
|
||||
changements += 1;
|
||||
if (changements === 1) bascule = i + 1;
|
||||
}
|
||||
signe = signeRang;
|
||||
}
|
||||
let domine = null;
|
||||
if (rangsA > 0 && rangsB === 0) domine = 'A';
|
||||
if (rangsB > 0 && rangsA === 0) domine = 'B';
|
||||
return {
|
||||
comparable: true,
|
||||
rangsA,
|
||||
rangsB,
|
||||
egalite,
|
||||
rangBascule: changements === 1 ? bascule : null,
|
||||
domine,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificat de « minimum atteint » (§ 5.5) : vrai quand chacun atteint son
|
||||
* plafond a priori, son écart au plafond a priori valant 0 ; faux sinon. Le
|
||||
* certificat se lit contre le plafond a priori, jamais sur le seul manque :
|
||||
* une proposition qui assoit chacun sur un itinéraire bas atteint partout son
|
||||
* plafond réalisé, et ne prouve rien.
|
||||
*
|
||||
* null quand le plafond a priori est inconnu, le certificat restant hors
|
||||
* d'atteinte (§ 12.6), et sur une population vide, où il n'y a personne à
|
||||
* servir (§ 5.4).
|
||||
*
|
||||
* Lève TypeError quand mesures.rencontres n'est pas une liste, ou que
|
||||
* plafondsAPriori n'est ni une liste ni null, et RangeError quand
|
||||
* plafondsAPriori n'a pas une case par élément de mesures.rencontres.
|
||||
*
|
||||
* @param {{rencontres: ArrayLike<number>}} mesures seule la liste rencontres
|
||||
* est lue
|
||||
* @param {ArrayLike<number>|null} plafondsAPriori
|
||||
* @returns {boolean|null}
|
||||
*/
|
||||
export function minimumAtteint(mesures, plafondsAPriori) {
|
||||
const ecarts = ecartsAuPlafondAPriori(mesures, plafondsAPriori);
|
||||
if (ecarts === null || ecarts.length === 0) return null;
|
||||
return ecarts.every((ecart) => ecart === 0);
|
||||
}
|
||||
705
src/moteur/manque.test.js
Normal file
705
src/moteur/manque.test.js
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du manque et de ce qui s'en lit (§ 5.5, § 12.10). Chaque valeur
|
||||
// attendue est écrite en clair : recopiée de la spécification, ou comptée à
|
||||
// la main sur le plan qui la précède, jamais calculée par le module éprouvé.
|
||||
// Un plan s'écrit par identifiants et passe par normaliser et indexerPlan
|
||||
// comme un plan enregistré ; ses rencontres et ses plafonds viennent de
|
||||
// indicateurs.js et de plafond.js, éprouvés à part. Quand une épreuve les
|
||||
// recopie en clair, ce sont les termes de la ligne de décomposition
|
||||
// (§ 12.10.5) dont ses chiffres sont les différences.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
|
||||
import { indexerPlan, normaliser } from './configuration.js';
|
||||
import { mesurer } from './indicateurs.js';
|
||||
import {
|
||||
comparerProfils,
|
||||
ecartsAuPlafondAPriori,
|
||||
ecartsItineraire,
|
||||
manques,
|
||||
minimumAtteint,
|
||||
profilManque,
|
||||
troisChiffres,
|
||||
} from './manque.js';
|
||||
import { plafondsAPriori, plafondsRealises } from './plafond.js';
|
||||
|
||||
const SANS_CONTRAINTE = {
|
||||
separerAppartenances: false,
|
||||
nouveauxVoisins: false,
|
||||
nouvelleTable: false,
|
||||
varierAppartenances: false,
|
||||
};
|
||||
|
||||
// Un chiffre sur trois populations vides (§ 5.4) : null, jamais 0.
|
||||
const AUCUN = { tous: null, mobiles: null, ancres: null };
|
||||
|
||||
// Huit pour chacun des douze participants de la petite démonstration : leur
|
||||
// plafond a priori (§ 15.3), et leur plafond réalisé sur tout plan qui
|
||||
// remplit les quatre tables à chaque tour.
|
||||
const HUIT = Object.freeze(new Array(12).fill(8));
|
||||
|
||||
// Ce qui n'est pas une liste, pour les épreuves des gardes : un nombre plutôt
|
||||
// qu'undefined. Lire undefined.length lève TypeError, garde ou non ; lire
|
||||
// (8).length rend undefined sans lever, et seule une garde de liste lève
|
||||
// alors TypeError. L'épreuve échoue donc quand la garde disparaît.
|
||||
const PAS_UNE_LISTE = 8;
|
||||
|
||||
// Fige une valeur et, à toute profondeur, les objets et les tableaux qu'elle
|
||||
// contient : une écriture du module éprouvé y lève TypeError.
|
||||
function figer(valeur) {
|
||||
if (typeof valeur !== 'object' || valeur === null || ArrayBuffer.isView(valeur)) return valeur;
|
||||
for (const element of Object.values(valeur)) figer(element);
|
||||
return Object.freeze(valeur);
|
||||
}
|
||||
|
||||
// Participants d'identifiants donnés, sans appartenance.
|
||||
const sansAppartenance = (ids) => ids.map((id) => ({ id, nom: `P${id}`, appartenance: null }));
|
||||
|
||||
// Tables d'identifiant et de numéro 1 à n, n le nombre de capacités.
|
||||
const tablesDe = (capacites) =>
|
||||
capacites.map((capacite, i) => ({ id: i + 1, numero: i + 1, capacite }));
|
||||
|
||||
// La petite démonstration (§ 15.3) : douze participants, quatre tables de 3,
|
||||
// quatre tours, aucune réservation.
|
||||
const PETITE = normaliser(CATALOGUE.find(({ cle }) => cle === 'petite').construire());
|
||||
|
||||
// Le plan parfait, 3 et 4 échangés au tour 1 : 1 retrouve 4 au tour 2, et 3
|
||||
// retrouve 5 au tour 3. Ces quatre personnes rencontrent 7 personnes, les
|
||||
// huit autres toujours 8 ; les tables restent pleines, et le plafond réalisé
|
||||
// vaut 8 pour chacun.
|
||||
const PLAN_DEGRADE = {
|
||||
tables: [1, 2, 3, 4],
|
||||
tours: [[[1, 2, 4], [3, 5, 6], [7, 8, 9], [10, 11, 12]], ...PLAN_PARFAIT_PETITE.tours.slice(1)],
|
||||
reserves: [[], [], [], []],
|
||||
};
|
||||
|
||||
// Le plan parfait, 1 et 5 échangés au tour 1. 1 y quitte 2 et 3, et 5 y
|
||||
// quitte 4 et 6, qu'ils ne voient à aucun autre tour. Chacun s'assoit près de
|
||||
// deux personnes qu'il revoit à un autre tour : 1 près de 4 et 6, revus aux
|
||||
// tours 2 et 3 ; 5 près de 2 et 3, revus aux tours 2 et 3. 2, 3, 4 et 6
|
||||
// perdent celui des deux qui quitte leur table, et revoient ailleurs celui
|
||||
// qui y arrive. Les tables restent pleines, et le plafond réalisé vaut 8 pour
|
||||
// chacun.
|
||||
//
|
||||
// id 1 2 3 4 5 6 7 à 12
|
||||
// rencontres 6 7 7 7 6 7 8
|
||||
// manque 2 1 1 1 2 1 0
|
||||
const PLAN_DEUX_MANQUES = {
|
||||
tables: [1, 2, 3, 4],
|
||||
tours: [[[2, 3, 5], [1, 4, 6], [7, 8, 9], [10, 11, 12]], ...PLAN_PARFAIT_PETITE.tours.slice(1)],
|
||||
reserves: [[], [], [], []],
|
||||
};
|
||||
|
||||
// Deux tables de 3 sièges, quatre tours, six participants sans appartenance.
|
||||
// 1 est ancré à la table 1 et 2 à la table 2 ; 3, réservé à la table 1 au
|
||||
// tour 1, est partiellement fixé et compte parmi les mobiles (§ 5.4) ; 4, 5 et
|
||||
// 6 sont mobiles. 6, assis au seul tour 1, est en réserve ensuite (§ 12.6).
|
||||
const CONFIGURATION_ANCRAGES = {
|
||||
participants: sansAppartenance([1, 2, 3, 4, 5, 6]),
|
||||
tables: tablesDe([3, 3]),
|
||||
tours: 4,
|
||||
reservations: [
|
||||
{ participant: 1, table: 1, portee: 'tous' },
|
||||
{ participant: 2, table: 2, portee: 'tous' },
|
||||
{ participant: 3, table: 1, portee: 'tour', tour: 1 },
|
||||
],
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
};
|
||||
const ANCRAGES = normaliser(CONFIGURATION_ANCRAGES);
|
||||
const PLAN_ANCRAGES = {
|
||||
tables: [1, 2],
|
||||
tours: [
|
||||
[[1, 3, 4], [2, 5, 6]],
|
||||
[[1, 3, 5], [2, 4]],
|
||||
[[1, 4], [2, 3, 5]],
|
||||
[[1, 3, 4], [2, 5]],
|
||||
],
|
||||
reserves: [[], [6], [6], [6]],
|
||||
};
|
||||
// Les termes de PLAN_ANCRAGES, comptés à la main. Occupation des tables 1 et
|
||||
// 2 : 3 et 3 au tour 1, 3 et 2 au tour 2, 2 et 3 au tour 3, 3 et 2 au tour 4.
|
||||
// N − 1 = 5 et n = 4 : n_p vaut 4 pour un ancré, 3 pour les autres. Plafond
|
||||
// réalisé = Σ a_t sur les tables visitées + min(n_p, Σ (o − 1 − a_t) sur les
|
||||
// tours assis), a_t valant 1 à chaque table, et 0 à la sienne pour un ancré.
|
||||
//
|
||||
// id rencontrés rencontres plafond réalisé a priori
|
||||
// 1 3, 4, 5 3 0 + min(4, 2+2+1+2) = 4 4
|
||||
// 2 3, 4, 5, 6 4 0 + min(4, 2+1+2+1) = 4 4
|
||||
// 3 1, 2, 4, 5 4 2 + min(3, 1+1+1+1) = 5 5
|
||||
// 4 1, 2, 3 3 2 + min(3, 1+0+0+1) = 4 5
|
||||
// 5 1, 2, 3, 6 4 2 + min(3, 1+1+1+0) = 5 5
|
||||
// 6 2, 5 2 1 + min(3, 1) = 2 5
|
||||
//
|
||||
// Le plafond a priori se lit sur les capacités : 0 + min(4, 4 × 2) pour un
|
||||
// ancré ; les deux tables visitées, 2 + min(3, 4 × 1), pour les autres.
|
||||
// Manque = réalisé − rencontres : 1, 0, 1, 1, 1, 0. Écart d'itinéraire = a
|
||||
// priori − réalisé : 0, 0, 0, 1, 0, 3.
|
||||
const RENCONTRES_ANCRAGES = [3, 4, 4, 3, 4, 2];
|
||||
const REALISES_ANCRAGES = [4, 4, 5, 4, 5, 2];
|
||||
const A_PRIORI_ANCRAGES = [4, 4, 5, 5, 5, 5];
|
||||
|
||||
// Quatre participants, deux tables de 3, un tour, deux à chaque table. Chacun
|
||||
// rencontre la seule personne que sa table lui offre : plafond réalisé 1,
|
||||
// manque 0. Une table pleine lui en offrait deux : plafond a priori 2, écart
|
||||
// d'itinéraire 1.
|
||||
const BAS = normaliser({
|
||||
participants: sansAppartenance([1, 2, 3, 4]),
|
||||
tables: tablesDe([3, 3]),
|
||||
tours: 1,
|
||||
reservations: [],
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
});
|
||||
const PLAN_BAS = { tables: [1, 2], tours: [[[1, 2], [3, 4]]], reserves: [[]] };
|
||||
|
||||
// Aucun participant ; le plan n'a aucune case.
|
||||
const VIDE = normaliser({
|
||||
participants: [],
|
||||
tables: tablesDe([2]),
|
||||
tours: 1,
|
||||
reservations: [],
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
});
|
||||
const PLAN_VIDE = { tables: [1], tours: [[[]]], reserves: [[]] };
|
||||
|
||||
// Mesures, plafonds réalisés et plafonds a priori d'un plan par
|
||||
// identifiants.
|
||||
function chiffrer(instance, plan) {
|
||||
const tableDe = indexerPlan(instance, plan);
|
||||
return {
|
||||
mesures: mesurer(instance, tableDe),
|
||||
realises: plafondsRealises(instance, tableDe),
|
||||
aPriori: plafondsAPriori(instance),
|
||||
};
|
||||
}
|
||||
|
||||
// Profil de manque d'un plan par identifiants.
|
||||
function profilDe(instance, plan) {
|
||||
const { mesures, realises } = chiffrer(instance, plan);
|
||||
return profilManque(instance, manques(realises, mesures.rencontres), realises);
|
||||
}
|
||||
|
||||
// Profil écrit à la main : le manque de chaque rang, le rang 1 d'abord, et
|
||||
// l'identifiant de la personne à chaque rang, 1 à n par défaut. Le plafond
|
||||
// réalisé ne départage pas : comparerProfils ne lit que le manque.
|
||||
const profil = (manquesParRang, ids = manquesParRang.map((_, i) => i + 1)) =>
|
||||
manquesParRang.map((manque, i) => ({ id: ids[i], manque, plafondRealise: 10 }));
|
||||
|
||||
describe('manques (§ 12.10.2)', () => {
|
||||
test('plan parfait de la petite démonstration : douze manques nuls', () => {
|
||||
const { mesures, realises } = chiffrer(PETITE, PLAN_PARFAIT_PETITE);
|
||||
assert.deepEqual(mesures.rencontres, HUIT);
|
||||
assert.deepEqual(realises, HUIT);
|
||||
assert.deepEqual(manques(figer(realises), figer(mesures.rencontres)), new Array(12).fill(0));
|
||||
});
|
||||
|
||||
test('plan dégradé : un manque de 1 pour les quatre personnes qui perdent une rencontre', () => {
|
||||
const { mesures, realises } = chiffrer(PETITE, PLAN_DEGRADE);
|
||||
assert.deepEqual(mesures.rencontres, [7, 8, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8]);
|
||||
assert.deepEqual(realises, HUIT);
|
||||
assert.deepEqual(manques(realises, mesures.rencontres), [1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test("ancrés et réserve : plafond réalisé − rencontres, personne par personne", () => {
|
||||
const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
|
||||
assert.deepEqual(mesures.rencontres, RENCONTRES_ANCRAGES);
|
||||
assert.deepEqual(realises, REALISES_ANCRAGES);
|
||||
assert.deepEqual(aPriori, A_PRIORI_ANCRAGES);
|
||||
assert.deepEqual(manques(realises, mesures.rencontres), [1, 0, 1, 1, 1, 0]);
|
||||
// Les tableaux typés se lisent comme des listes.
|
||||
assert.deepEqual(
|
||||
manques(Int32Array.from(REALISES_ANCRAGES), Int32Array.from(RENCONTRES_ANCRAGES)),
|
||||
[1, 0, 1, 1, 1, 0],
|
||||
);
|
||||
});
|
||||
|
||||
test('deux listes de longueurs différentes lèvent RangeError', () => {
|
||||
assert.throws(() => manques([8, 8], [8]), RangeError);
|
||||
assert.throws(() => manques([8], [8, 8]), RangeError);
|
||||
});
|
||||
|
||||
test("une valeur qui n'est pas une liste lève TypeError, à l'une ou l'autre place", () => {
|
||||
assert.throws(() => manques(PAS_UNE_LISTE, [8]), TypeError);
|
||||
assert.throws(() => manques([8], PAS_UNE_LISTE), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ecartsItineraire (§ 5.5)', () => {
|
||||
test('plafond a priori − plafond réalisé, personne par personne', () => {
|
||||
const { realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
|
||||
// 4 − 4, 4 − 4, 5 − 5, 5 − 4, 5 − 5, 5 − 2 : 6, en réserve à trois tours,
|
||||
// porte l'écart d'itinéraire que son manque nul ne porte pas (§ 12.6).
|
||||
assert.deepEqual(ecartsItineraire(figer(aPriori), figer(realises)), [0, 0, 0, 1, 0, 3]);
|
||||
});
|
||||
|
||||
test('null quand le plafond a priori est inconnu ; undefined ne passe pas pour inconnu', () => {
|
||||
const { realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
|
||||
assert.equal(ecartsItineraire(null, realises), null);
|
||||
assert.throws(() => ecartsItineraire(undefined, realises), TypeError);
|
||||
});
|
||||
|
||||
test("un plafond a priori d'une autre longueur lève RangeError ; un nombre seul pour plafonds réalisés, TypeError", () => {
|
||||
const { realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
|
||||
assert.throws(() => ecartsItineraire(A_PRIORI_ANCRAGES.slice(1), realises), RangeError);
|
||||
assert.throws(() => ecartsItineraire(null, PAS_UNE_LISTE), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ecartsAuPlafondAPriori (§ 5.5)', () => {
|
||||
test("plafond a priori − rencontres : le manque plus l'écart d'itinéraire, personne par personne", () => {
|
||||
const { mesures, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
|
||||
// 1 + 0, 0 + 0, 1 + 0, 1 + 1, 1 + 0, 0 + 3.
|
||||
assert.deepEqual(ecartsAuPlafondAPriori(figer(mesures), figer(aPriori)), [1, 0, 1, 2, 1, 3]);
|
||||
});
|
||||
|
||||
test('null quand le plafond a priori est inconnu ; undefined ne passe pas pour inconnu', () => {
|
||||
const { mesures } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
|
||||
assert.equal(ecartsAuPlafondAPriori(mesures, null), null);
|
||||
assert.throws(() => ecartsAuPlafondAPriori(mesures, undefined), TypeError);
|
||||
});
|
||||
|
||||
test("un plafond a priori d'une autre longueur que les rencontres lève RangeError", () => {
|
||||
const { mesures } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
|
||||
assert.throws(() => ecartsAuPlafondAPriori(mesures, [4, 4, 5, 5, 5]), RangeError);
|
||||
});
|
||||
|
||||
test('des rencontres qui ne sont pas une liste lèvent TypeError, le plafond a priori connu ou inconnu', () => {
|
||||
assert.throws(() => ecartsAuPlafondAPriori({ rencontres: PAS_UNE_LISTE }, A_PRIORI_ANCRAGES), TypeError);
|
||||
assert.throws(() => ecartsAuPlafondAPriori({ rencontres: PAS_UNE_LISTE }, null), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('troisChiffres (§ 12.10.4)', () => {
|
||||
test('plan parfait de la petite démonstration : 0, un vrai 0, et 0 ; « — » pour les ancrés', () => {
|
||||
const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_PARFAIT_PETITE);
|
||||
// Douze personnes mesurées, aucune en manque : l'effectif du manque est un
|
||||
// zéro mesuré, non un « — ». Sans réservation, la population des ancrés
|
||||
// est vide, et chacun des trois chiffres s'y écrit « — » (§ 15.3).
|
||||
assert.deepEqual(troisChiffres(PETITE, figer(mesures), figer(aPriori), figer(realises)), {
|
||||
manqueMax: { tous: 0, mobiles: 0, ancres: null },
|
||||
effectifManque: { tous: 0, mobiles: 0, ancres: null },
|
||||
ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null },
|
||||
});
|
||||
});
|
||||
|
||||
test('plan dégradé : le plus grand manque vaut 1, et quatre personnes le portent', () => {
|
||||
const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_DEGRADE);
|
||||
assert.deepEqual(troisChiffres(PETITE, mesures, aPriori, realises), {
|
||||
manqueMax: { tous: 1, mobiles: 1, ancres: null },
|
||||
effectifManque: { tous: 4, mobiles: 4, ancres: null },
|
||||
ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null },
|
||||
});
|
||||
});
|
||||
|
||||
test("un manque de 2 : l'effectif compte les personnes, non la somme des manques (§ 12.10.8)", () => {
|
||||
const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_DEUX_MANQUES);
|
||||
assert.deepEqual(mesures.rencontres, [6, 7, 7, 7, 6, 7, 8, 8, 8, 8, 8, 8]);
|
||||
assert.deepEqual(realises, HUIT);
|
||||
// Manques 2, 1, 1, 1, 2, 1, puis six 0 : six personnes en manque, pour
|
||||
// une somme de 8.
|
||||
assert.deepEqual(troisChiffres(PETITE, mesures, aPriori, realises), {
|
||||
manqueMax: { tous: 2, mobiles: 2, ancres: null },
|
||||
effectifManque: { tous: 6, mobiles: 6, ancres: null },
|
||||
ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null },
|
||||
});
|
||||
});
|
||||
|
||||
test('le placement du § 12.10.4 : un manque de 13, porté par une seule personne', () => {
|
||||
// Chacun atteint un plafond réalisé de 27, sauf une personne qui ne
|
||||
// rencontre que 14 : le plus grand manque vaut 13, l'effectif 1.
|
||||
// troisChiffres lit des listes, non un plan : celles de l'exemple, écrites
|
||||
// à la main sur trois personnes, le plafond a priori inconnu.
|
||||
const trois = normaliser({
|
||||
participants: sansAppartenance([1, 2, 3]),
|
||||
tables: tablesDe([3]),
|
||||
tours: 1,
|
||||
reservations: [],
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
});
|
||||
assert.deepEqual(troisChiffres(trois, { rencontres: [27, 27, 14] }, null, [27, 27, 27]), {
|
||||
manqueMax: { tous: 13, mobiles: 13, ancres: null },
|
||||
effectifManque: { tous: 1, mobiles: 1, ancres: null },
|
||||
ecartItineraireMax: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('ancrés, partiellement fixé et réserve : chaque chiffre sur ses trois populations', () => {
|
||||
const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
|
||||
// Manque 1, 0, 1, 1, 1, 0 ; écart d'itinéraire 0, 0, 0, 1, 0, 3. Les
|
||||
// ancrés sont 1 et 2 ; les mobiles, 3, partiellement fixé, puis 4, 5 et 6.
|
||||
// 6, en réserve à trois tours sur quatre, a un manque nul : les deux
|
||||
// premiers chiffres ne le voient pas, le troisième le montre (§ 12.10.4).
|
||||
assert.deepEqual(troisChiffres(ANCRAGES, mesures, aPriori, realises), {
|
||||
manqueMax: { tous: 1, mobiles: 1, ancres: 1 },
|
||||
effectifManque: { tous: 4, mobiles: 3, ancres: 1 },
|
||||
ecartItineraireMax: { tous: 3, mobiles: 3, ancres: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
test("plafond a priori inconnu : l'écart d'itinéraire maximal vaut null, les deux autres se mesurent", () => {
|
||||
const { mesures, realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
|
||||
assert.deepEqual(troisChiffres(ANCRAGES, mesures, null, realises), {
|
||||
manqueMax: { tous: 1, mobiles: 1, ancres: 1 },
|
||||
effectifManque: { tous: 4, mobiles: 3, ancres: 1 },
|
||||
ecartItineraireMax: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("itinéraires bas : aucun manque, et l'écart d'itinéraire le dit (§ 5.5)", () => {
|
||||
const { mesures, realises, aPriori } = chiffrer(BAS, PLAN_BAS);
|
||||
assert.deepEqual(mesures.rencontres, [1, 1, 1, 1]);
|
||||
assert.deepEqual(realises, [1, 1, 1, 1]);
|
||||
assert.deepEqual(aPriori, [2, 2, 2, 2]);
|
||||
assert.deepEqual(troisChiffres(BAS, mesures, aPriori, realises), {
|
||||
manqueMax: { tous: 0, mobiles: 0, ancres: null },
|
||||
effectifManque: { tous: 0, mobiles: 0, ancres: null },
|
||||
ecartItineraireMax: { tous: 1, mobiles: 1, ancres: null },
|
||||
});
|
||||
});
|
||||
|
||||
test('population vide : « — » pour chacun des trois chiffres, jamais 0 (§ 5.4)', () => {
|
||||
const { mesures, realises, aPriori } = chiffrer(VIDE, PLAN_VIDE);
|
||||
assert.deepEqual(aPriori, []);
|
||||
assert.deepEqual(troisChiffres(VIDE, mesures, aPriori, realises), {
|
||||
manqueMax: AUCUN,
|
||||
effectifManque: AUCUN,
|
||||
ecartItineraireMax: AUCUN,
|
||||
});
|
||||
});
|
||||
|
||||
test('une liste sans une case par participant lève RangeError, seule ou avec les deux autres', () => {
|
||||
const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
|
||||
const court = (liste) => liste.slice(1);
|
||||
assert.throws(() => troisChiffres(ANCRAGES, mesures, aPriori, court(realises)), RangeError);
|
||||
assert.throws(() => troisChiffres(ANCRAGES, mesures, court(aPriori), realises), RangeError);
|
||||
assert.throws(
|
||||
() => troisChiffres(ANCRAGES, { rencontres: court(mesures.rencontres) }, aPriori, realises),
|
||||
RangeError,
|
||||
);
|
||||
// Les trois listes ensemble, mesurées sur une autre population : accordées
|
||||
// entre elles, elles passent les gardes de manques et d'ecartsItineraire,
|
||||
// qui les comparent l'une à l'autre ; seules celles de troisChiffres les
|
||||
// confrontent à l'instance. D'abord une population plus petite d'une
|
||||
// personne, puis une proposition mesurée avant l'exclusion de 6 et relue
|
||||
// sur l'instance qui l'exclut (§ 9, § 12.6) ; chaque fois l'a priori
|
||||
// connu, puis inconnu.
|
||||
const plusPetite = { rencontres: court(mesures.rencontres) };
|
||||
assert.throws(() => troisChiffres(ANCRAGES, plusPetite, court(aPriori), court(realises)), RangeError);
|
||||
assert.throws(() => troisChiffres(ANCRAGES, plusPetite, null, court(realises)), RangeError);
|
||||
const sansSix = normaliser({
|
||||
...CONFIGURATION_ANCRAGES,
|
||||
participants: CONFIGURATION_ANCRAGES.participants.map((p) => (p.id === 6 ? { ...p, exclu: true } : p)),
|
||||
});
|
||||
assert.deepEqual(sansSix.ids, [1, 2, 3, 4, 5]);
|
||||
assert.throws(() => troisChiffres(sansSix, mesures, aPriori, realises), RangeError);
|
||||
assert.throws(() => troisChiffres(sansSix, mesures, null, realises), RangeError);
|
||||
});
|
||||
|
||||
test("une valeur qui n'est pas une liste lève TypeError ; un a priori undefined aussi", () => {
|
||||
const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES);
|
||||
assert.throws(() => troisChiffres(ANCRAGES, mesures, aPriori, PAS_UNE_LISTE), TypeError);
|
||||
assert.throws(() => troisChiffres(ANCRAGES, { rencontres: PAS_UNE_LISTE }, aPriori, realises), TypeError);
|
||||
assert.throws(() => troisChiffres(ANCRAGES, mesures, undefined, realises), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('profilManque (§ 12.10.6)', () => {
|
||||
test('manque décroissant, puis plafond réalisé croissant, puis identifiant croissant', () => {
|
||||
// Identifiants 10 à 50, reçus dans le désordre ; les listes suivent
|
||||
// l'ordre canonique, celui de instance.ids.
|
||||
const instance = normaliser({
|
||||
participants: sansAppartenance([30, 10, 50, 20, 40]),
|
||||
tables: tablesDe([5]),
|
||||
tours: 1,
|
||||
reservations: [],
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
});
|
||||
assert.deepEqual(instance.ids, [10, 20, 30, 40, 50]);
|
||||
// id 10 20 30 40 50
|
||||
// manque 1 2 1 1 0
|
||||
// plafond réalisé 5 3 4 5 2
|
||||
assert.deepEqual(profilManque(instance, figer([1, 2, 1, 1, 0]), figer([5, 3, 4, 5, 2])), [
|
||||
{ id: 20, manque: 2, plafondRealise: 3 },
|
||||
{ id: 30, manque: 1, plafondRealise: 4 },
|
||||
{ id: 10, manque: 1, plafondRealise: 5 },
|
||||
{ id: 40, manque: 1, plafondRealise: 5 },
|
||||
{ id: 50, manque: 0, plafondRealise: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("plan dégradé : les quatre personnes en manque d'abord, chaque palier par identifiant", () => {
|
||||
const ids = profilDe(PETITE, PLAN_DEGRADE).map(({ id }) => id);
|
||||
assert.deepEqual(ids, [1, 3, 4, 5, 2, 6, 7, 8, 9, 10, 11, 12]);
|
||||
});
|
||||
|
||||
test("une liste sans une case par participant lève RangeError", () => {
|
||||
assert.throws(() => profilManque(PETITE, new Array(11).fill(0), HUIT), RangeError);
|
||||
assert.throws(() => profilManque(PETITE, new Array(12).fill(0), HUIT.slice(1)), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
// Les plans voisins du plan parfait de la petite démonstration : à un tour,
|
||||
// deux personnes assises à deux tables différentes échangent leurs places.
|
||||
// Quatre tours, six paires de tables, neuf échanges par paire : 216 plans.
|
||||
function voisinsDuParfait() {
|
||||
const { tours } = PLAN_PARFAIT_PETITE;
|
||||
const voisins = [];
|
||||
for (let r = 0; r < tours.length; r += 1) {
|
||||
for (let i = 0; i < tours[r].length; i += 1) {
|
||||
for (let j = i + 1; j < tours[r].length; j += 1) {
|
||||
for (const x of tours[r][i]) {
|
||||
for (const y of tours[r][j]) {
|
||||
const tour = tours[r].map((liste, t) => {
|
||||
if (t === i) return liste.map((id) => (id === x ? y : id));
|
||||
if (t === j) return liste.map((id) => (id === y ? x : id));
|
||||
return liste;
|
||||
});
|
||||
voisins.push({
|
||||
nom: `${x} et ${y} échangés au tour ${r + 1}`,
|
||||
plan: { ...PLAN_PARFAIT_PETITE, tours: tours.map((t, q) => (q === r ? tour : t)) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return voisins;
|
||||
}
|
||||
|
||||
// Deux autres plans parfaits : les tours du plan parfait dans l'ordre
|
||||
// inverse, et chaque tour décalé d'une table. Les rencontres restent les
|
||||
// mêmes.
|
||||
const AUTRES_PARFAITS = [
|
||||
{
|
||||
nom: 'tours inversés',
|
||||
plan: { ...PLAN_PARFAIT_PETITE, tours: [...PLAN_PARFAIT_PETITE.tours].reverse() },
|
||||
},
|
||||
{
|
||||
nom: 'tables décalées',
|
||||
plan: { ...PLAN_PARFAIT_PETITE, tours: PLAN_PARFAIT_PETITE.tours.map((t) => [...t.slice(1), t[0]]) },
|
||||
},
|
||||
];
|
||||
|
||||
// Vrai quand chacun des douze rencontre huit personnes distinctes, compté sur
|
||||
// les listes du plan sans passer par le moteur. Sur un plan qui remplit les
|
||||
// quatre tables à chaque tour, le plafond réalisé vaut 8 pour chacun : c'est
|
||||
// exactement le cas d'un manque nul pour tous.
|
||||
function chacunRencontreHuit(plan) {
|
||||
const rencontres = new Map();
|
||||
for (const tour of plan.tours) {
|
||||
for (const liste of tour) {
|
||||
for (const a of liste) {
|
||||
if (!rencontres.has(a)) rencontres.set(a, new Set());
|
||||
for (const b of liste) if (b !== a) rencontres.get(a).add(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
return rencontres.size === 12 && [...rencontres.values()].every((vus) => vus.size === 8);
|
||||
}
|
||||
|
||||
describe('comparerProfils : dominance de profil (§ 12.10.7)', () => {
|
||||
test("petite démonstration : le plan parfait domine tout plan qui n'est pas à manque nul, et rien ne le domine", () => {
|
||||
const parfait = profilDe(PETITE, PLAN_PARFAIT_PETITE);
|
||||
const concurrents = [...voisinsDuParfait(), ...AUTRES_PARFAITS];
|
||||
// Le test refuse de passer sans concurrent, et sans concurrent de chaque
|
||||
// sorte (§ 14.2). Un échange retire à la personne déplacée deux voisins
|
||||
// qu'elle ne retrouve à aucun autre tour, et l'assoit près d'une personne
|
||||
// au moins qu'elle rencontre à un autre tour : elle perd deux rencontres
|
||||
// et en gagne une au plus. Aucun voisin n'est donc à manque nul.
|
||||
assert.equal(concurrents.length, 218);
|
||||
assert.deepEqual(
|
||||
concurrents.filter(({ plan }) => chacunRencontreHuit(plan)).map(({ nom }) => nom),
|
||||
['tours inversés', 'tables décalées'],
|
||||
);
|
||||
const ecarts = concurrents.flatMap(({ nom, plan }) => {
|
||||
const autre = profilDe(PETITE, plan);
|
||||
const nul = chacunRencontreHuit(plan);
|
||||
const lus = [
|
||||
['parfait contre lui', comparerProfils(parfait, autre).domine, nul ? null : 'A'],
|
||||
['lui contre parfait', comparerProfils(autre, parfait).domine, nul ? null : 'B'],
|
||||
];
|
||||
return lus
|
||||
.filter(([, obtenu, attendu]) => obtenu !== attendu)
|
||||
.map(([sens, obtenu, attendu]) => `${nom}, ${sens} : ${obtenu} au lieu de ${attendu}`);
|
||||
});
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('plan parfait contre plan dégradé : quatre rangs à A, huit égalités, aucune bascule', () => {
|
||||
const parfait = profilDe(PETITE, PLAN_PARFAIT_PETITE);
|
||||
const degrade = profilDe(PETITE, PLAN_DEGRADE);
|
||||
assert.deepEqual(comparerProfils(figer(parfait), figer(degrade)), {
|
||||
comparable: true,
|
||||
rangsA: 4,
|
||||
rangsB: 0,
|
||||
egalite: 8,
|
||||
rangBascule: null,
|
||||
domine: 'A',
|
||||
});
|
||||
});
|
||||
|
||||
test("deux profils tout à zéro ne se dominent pas", () => {
|
||||
assert.deepEqual(comparerProfils(profil([0, 0, 0]), profil([0, 0, 0], [3, 2, 1])), {
|
||||
comparable: true,
|
||||
rangsA: 0,
|
||||
rangsB: 0,
|
||||
egalite: 3,
|
||||
rangBascule: null,
|
||||
domine: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("A domine B quand il sert au moins aussi bien à chaque rang, et mieux à l'un d'eux", () => {
|
||||
// Manque de A − manque de B, rang par rang : 0, 0, −1.
|
||||
assert.deepEqual(comparerProfils(profil([3, 2, 0]), profil([3, 2, 1], [2, 3, 1])), {
|
||||
comparable: true,
|
||||
rangsA: 1,
|
||||
rangsB: 0,
|
||||
egalite: 2,
|
||||
rangBascule: null,
|
||||
domine: 'A',
|
||||
});
|
||||
assert.deepEqual(comparerProfils(profil([3, 2, 1], [2, 3, 1]), profil([3, 2, 0])), {
|
||||
comparable: true,
|
||||
rangsA: 0,
|
||||
rangsB: 1,
|
||||
egalite: 2,
|
||||
rangBascule: null,
|
||||
domine: 'B',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparerProfils : rangs et bascule (§ 12.10.6)', () => {
|
||||
test("un seul changement de signe : le rang de bascule, premier rang où l'autre sert mieux", () => {
|
||||
// −1, −1, +1, +1, 0 : A sert mieux aux rangs 1 et 2, B aux rangs 3 et 4.
|
||||
assert.deepEqual(comparerProfils(profil([5, 3, 2, 1, 0]), profil([6, 4, 1, 0, 0], [5, 4, 3, 2, 1])), {
|
||||
comparable: true,
|
||||
rangsA: 2,
|
||||
rangsB: 2,
|
||||
egalite: 1,
|
||||
rangBascule: 3,
|
||||
domine: null,
|
||||
});
|
||||
// +1, +1, −1 : B sert mieux d'abord, A à partir du rang 3.
|
||||
assert.deepEqual(comparerProfils(profil([6, 4, 1]), profil([5, 3, 2])), {
|
||||
comparable: true,
|
||||
rangsA: 1,
|
||||
rangsB: 2,
|
||||
egalite: 0,
|
||||
rangBascule: 3,
|
||||
domine: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("une égalité au croisement n'est pas un changement de signe", () => {
|
||||
// −1, 0, +1, +1 : une seule bascule, au rang 3, où B sert strictement mieux.
|
||||
assert.deepEqual(comparerProfils(profil([5, 3, 2, 1]), profil([6, 3, 1, 0])), {
|
||||
comparable: true,
|
||||
rangsA: 1,
|
||||
rangsB: 2,
|
||||
egalite: 1,
|
||||
rangBascule: 3,
|
||||
domine: null,
|
||||
});
|
||||
// 0, 0, −1, +1 : les égalités de tête ne fixent aucun signe ; bascule au rang 4.
|
||||
assert.deepEqual(comparerProfils(profil([4, 3, 1, 1]), profil([4, 3, 2, 0])), {
|
||||
comparable: true,
|
||||
rangsA: 1,
|
||||
rangsB: 1,
|
||||
egalite: 2,
|
||||
rangBascule: 4,
|
||||
domine: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('deux changements de signe ou plus : aucun rang de bascule (§ 12.6)', () => {
|
||||
// −1, +1, −1, 0.
|
||||
assert.deepEqual(comparerProfils(profil([4, 3, 1, 0]), profil([5, 2, 2, 0])), {
|
||||
comparable: true,
|
||||
rangsA: 2,
|
||||
rangsB: 1,
|
||||
egalite: 1,
|
||||
rangBascule: null,
|
||||
domine: null,
|
||||
});
|
||||
// −1, +1, −1, +1.
|
||||
assert.deepEqual(comparerProfils(profil([4, 3, 1, 1]), profil([5, 2, 2, 0])), {
|
||||
comparable: true,
|
||||
rangsA: 2,
|
||||
rangsB: 2,
|
||||
egalite: 0,
|
||||
rangBascule: null,
|
||||
domine: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparerProfils : populations (§ 12.6)', () => {
|
||||
test('populations différentes : { comparable: false }', () => {
|
||||
assert.deepEqual(comparerProfils(profil([1, 0, 0]), profil([1, 0, 0], [1, 2, 4])), {
|
||||
comparable: false,
|
||||
});
|
||||
assert.deepEqual(comparerProfils(profil([1, 0, 0]), profil([1, 0])), { comparable: false });
|
||||
assert.deepEqual(comparerProfils(profil([1, 0]), profil([1, 0, 0])), { comparable: false });
|
||||
});
|
||||
|
||||
test("les mêmes personnes à d'autres rangs restent comparables, et deux profils vides aussi", () => {
|
||||
assert.equal(comparerProfils(profil([1, 0, 0]), profil([1, 0, 0], [3, 1, 2])).comparable, true);
|
||||
assert.deepEqual(comparerProfils([], []), {
|
||||
comparable: true,
|
||||
rangsA: 0,
|
||||
rangsB: 0,
|
||||
egalite: 0,
|
||||
rangBascule: null,
|
||||
domine: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('minimumAtteint : le certificat du § 5.5', () => {
|
||||
test('vrai sur le plan parfait de la petite démonstration', () => {
|
||||
const { mesures, aPriori } = chiffrer(PETITE, PLAN_PARFAIT_PETITE);
|
||||
assert.deepEqual(aPriori, HUIT);
|
||||
assert.equal(minimumAtteint(figer(mesures), figer(aPriori)), true);
|
||||
});
|
||||
|
||||
test("faux après l'échange de deux personnes au tour 1, qui crée deux répétitions", () => {
|
||||
const { mesures, aPriori } = chiffrer(PETITE, PLAN_DEGRADE);
|
||||
assert.equal(mesures.rencontresRepetees.choisies, 2);
|
||||
assert.equal(minimumAtteint(mesures, aPriori), false);
|
||||
});
|
||||
|
||||
test('se lit contre le plafond a priori, jamais sur le seul manque', () => {
|
||||
// Des itinéraires bas : chacun atteint son plafond réalisé, aucun son
|
||||
// plafond a priori.
|
||||
const { mesures, realises, aPriori } = chiffrer(BAS, PLAN_BAS);
|
||||
assert.deepEqual(manques(realises, mesures.rencontres), [0, 0, 0, 0]);
|
||||
assert.equal(minimumAtteint(mesures, aPriori), false);
|
||||
});
|
||||
|
||||
test('égaler le plafond a priori, non le dépasser : un dépassement ne certifie rien', () => {
|
||||
// 9 rencontres pour un plafond a priori de 8 rompent la ligne de
|
||||
// décomposition (§ 12.10.5) : un calcul faux, que le certificat ne couvre
|
||||
// pas.
|
||||
assert.equal(minimumAtteint({ rencontres: [9, 8] }, [8, 8]), false);
|
||||
});
|
||||
|
||||
test('null quand le plafond a priori est inconnu, et sur une population vide', () => {
|
||||
assert.equal(minimumAtteint(chiffrer(PETITE, PLAN_PARFAIT_PETITE).mesures, null), null);
|
||||
const { mesures, aPriori } = chiffrer(VIDE, PLAN_VIDE);
|
||||
assert.equal(minimumAtteint(mesures, aPriori), null);
|
||||
});
|
||||
|
||||
test("un a priori undefined lève TypeError ; d'une autre longueur, RangeError", () => {
|
||||
const { mesures } = chiffrer(PETITE, PLAN_PARFAIT_PETITE);
|
||||
assert.throws(() => minimumAtteint(mesures, undefined), TypeError);
|
||||
assert.throws(() => minimumAtteint(mesures, HUIT.slice(1)), RangeError);
|
||||
});
|
||||
|
||||
test('des rencontres qui ne sont pas une liste lèvent TypeError, le plafond a priori connu ou inconnu', () => {
|
||||
assert.throws(() => minimumAtteint({ rencontres: PAS_UNE_LISTE }, HUIT), TypeError);
|
||||
assert.throws(() => minimumAtteint({ rencontres: PAS_UNE_LISTE }, null), TypeError);
|
||||
});
|
||||
});
|
||||
359
src/moteur/plafond.js
Normal file
359
src/moteur/plafond.js
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Plafonds de rencontres (§ 5.5). Le plafond d'une personne p sur un
|
||||
// itinéraire — la table qu'elle occupe à chaque tour — vaut
|
||||
//
|
||||
// min(N − 1, Σ_{t∈D} a_t + min(n_p, S))
|
||||
//
|
||||
// D est l'ensemble des tables distinctes visitées ; a_t, les ancrés de la
|
||||
// table t, p non compté ; n_p = n − 1 pour un mobile ou un partiellement
|
||||
// fixé, n pour un ancré ; S, les sièges qui changent d'occupant, sommés sur
|
||||
// les tours assis. Les ancrés d'une table visitée comptent une fois, quel
|
||||
// que soit le nombre de tours passés là ; les sièges se renouvellent à chaque
|
||||
// tour, sans livrer plus de personnes qu'il n'existe de mobiles. Un tour en
|
||||
// réserve n'entre ni dans D ni dans S.
|
||||
//
|
||||
// La formule s'écrit une fois, dans borne, et se calcule sur deux bases :
|
||||
// - les capacités, S = Σ_{t∈D} m_t · (c_t − 1 − a_t), m_t les tours passés à
|
||||
// t : plafondItineraire, et son maximum sur les itinéraires admissibles, le
|
||||
// plafond a priori ;
|
||||
// - l'occupation, S = Σ_{(t,r)∈I} (o_{t,r} − 1 − a_t), I les couples (table,
|
||||
// tour) où p est assis : le plafond réalisé. Sur un plan qui honore les
|
||||
// réservations, les a_t ancrés et p occupent la table à chaque tour, et
|
||||
// chaque terme est positif ou nul.
|
||||
//
|
||||
// Un itinéraire admissible impose les tours fixés par les réservations et
|
||||
// place chaque tour libre à une table non entièrement ancrée : les ancrés
|
||||
// d'une telle table occupent chacun de ses sièges à chaque tour. Quand
|
||||
// aucune table ne l'est, les tours libres restent en réserve.
|
||||
//
|
||||
// Le plafond a priori majore le plafond réalisé de tout plan valide. Dans un
|
||||
// tel plan, l'occupation ne dépasse pas la capacité : le plafond réalisé de p
|
||||
// ne dépasse pas le plafond de son itinéraire. Cet itinéraire tient ses tours
|
||||
// fixés et place ses tours libres à des tables admissibles, ou en réserve ;
|
||||
// asseoir p à une table admissible n'ôte rien au plafond, puisque
|
||||
// a_t ≥ 0 et c_t − 1 − a_t ≥ 0.
|
||||
//
|
||||
// Une personne se désigne par son index dans l'instance, une table par son
|
||||
// index ; un tableau rendu suit l'ordre de instance.ids. Aucune fonction ne
|
||||
// modifie l'instance.
|
||||
//
|
||||
// Une case d'itinéraire en réserve vaut RESERVE, et RESERVE vaut LIBRE
|
||||
// (configuration.js) : une rangée de instance.fixe se lit comme l'itinéraire
|
||||
// qui laisse en réserve chaque tour libre.
|
||||
import { LIBRE, RESERVE, STATUT, decrire, exigerPlanIndexe } from './configuration.js';
|
||||
import { ErreurConfiguration } from './erreurs.js';
|
||||
|
||||
// Nombre de multiensembles au-delà duquel l'énumération refuse une
|
||||
// signature.
|
||||
const LIMITE_ENUMERATION = 300_000;
|
||||
|
||||
// La formule du § 5.5. ancres : Σ a_t sur les tables distinctes ; sieges :
|
||||
// les sièges qui changent d'occupant ; nP : n_p.
|
||||
function borne(instance, nP, ancres, sieges) {
|
||||
return Math.min(instance.N - 1, ancres + Math.min(nP, sieges));
|
||||
}
|
||||
|
||||
// n_p : les mobiles — partiellement fixés compris — que p peut rencontrer,
|
||||
// p excepté quand il en est un.
|
||||
const mobilesRencontrables = (instance, p) =>
|
||||
instance.statut[p] === STATUT.ANCRE ? instance.n : instance.n - 1;
|
||||
|
||||
// Table d'ancrage de p, −1 s'il n'est pas ancré.
|
||||
const ancrageDe = (instance, p) =>
|
||||
instance.statut[p] === STATUT.ANCRE ? instance.fixe[p * instance.R] : -1;
|
||||
|
||||
// a_t vu de p : les ancrés de la table t, p non compté.
|
||||
const ancresVus = (instance, t, ancrage) => instance.ancresParTable[t] - (t === ancrage ? 1 : 0);
|
||||
|
||||
// Tables qu'un tour libre peut recevoir, par index croissant : celles où les
|
||||
// ancrés laissent au moins un siège.
|
||||
function tablesAdmissibles({ T, capacite, ancresParTable }) {
|
||||
const admissibles = [];
|
||||
for (let t = 0; t < T; t += 1) if (ancresParTable[t] < capacite[t]) admissibles.push(t);
|
||||
return admissibles;
|
||||
}
|
||||
|
||||
// Vrai quand la table de la case debut + r figure déjà à l'un des tours
|
||||
// précédents de la rangée.
|
||||
function dejaVisitee(cases, debut, r) {
|
||||
for (let q = 0; q < r; q += 1) if (cases[debut + q] === cases[debut + r]) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Plafond de p sur la rangée cases[debut … debut + R − 1], une table par
|
||||
// tour. occupation porte o_{t,r} en [t × R + r] et fait la base du plafond
|
||||
// réalisé ; null fait celle des capacités.
|
||||
function plafondSurRangee(instance, p, cases, debut, occupation) {
|
||||
const { R, capacite } = instance;
|
||||
const ancrage = ancrageDe(instance, p);
|
||||
let ancres = 0;
|
||||
let sieges = 0;
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const t = cases[debut + r];
|
||||
if (t === RESERVE) continue;
|
||||
const a = ancresVus(instance, t, ancrage);
|
||||
const occupants = occupation === null ? capacite[t] : occupation[t * R + r];
|
||||
sieges += occupants - 1 - a;
|
||||
if (!dejaVisitee(cases, debut, r)) ancres += a;
|
||||
}
|
||||
return borne(instance, mobilesRencontrables(instance, p), ancres, sieges);
|
||||
}
|
||||
|
||||
// Lève RangeError quand une case de l'itinéraire n'est ni RESERVE ni un index
|
||||
// de table. Un plan indexé passe par exigerPlanIndexe (configuration.js).
|
||||
function exigerCases(itineraire, T) {
|
||||
for (let i = 0; i < itineraire.length; i += 1) {
|
||||
const t = itineraire[i];
|
||||
if (t !== RESERVE && !(Number.isInteger(t) && t >= 0 && t < T)) {
|
||||
throw new RangeError(`itinéraire, case ${i} : index de table ${decrire(t)} hors de −1..${T - 1}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plafond d'une personne sur un itinéraire (index de table par tour, −1 en
|
||||
* réserve), calculé sur les capacités :
|
||||
* min(N − 1, Σ_{t∈D} a_t + min(n_p, Σ_{t∈D} m_t·v_t)), v_t = c_t − 1 − a_t,
|
||||
* a_t excluant p ; n_p = n − 1 pour un mobile ou un partiel, n pour un ancré.
|
||||
*
|
||||
* L'itinéraire n'est pas confronté aux réservations : la formule vaut pour
|
||||
* tout itinéraire, et le plafond a priori en prend le maximum sur les
|
||||
* itinéraires admissibles. Lève RangeError quand p n'est pas un index de
|
||||
* personne, ou que l'itinéraire n'a pas R cases, chacune −1 ou un index de
|
||||
* table.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @param {number} p index de la personne
|
||||
* @param {ArrayLike<number>} itineraire
|
||||
* @returns {number}
|
||||
*/
|
||||
export function plafondItineraire(instance, p, itineraire) {
|
||||
const { N, T, R } = instance;
|
||||
if (!Number.isInteger(p) || p < 0 || p >= N) {
|
||||
throw new RangeError(`personne d'index ${String(p)} hors de 0..${N - 1}`);
|
||||
}
|
||||
if (itineraire.length !== R) {
|
||||
throw new RangeError(`itinéraire de ${itineraire.length} tours, ${R} attendus`);
|
||||
}
|
||||
exigerCases(itineraire, T);
|
||||
return plafondSurRangee(instance, p, itineraire, 0, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plafond réalisé de chacun, sur l'occupation : v_{t,r} = o_{t,r} − 1 − a_t,
|
||||
* somme sur les tours assis ; Σ a_t reste sur les tables distinctes. Un tour
|
||||
* en réserve n'entre pas dans le plafond de la personne, ni dans
|
||||
* l'occupation d'aucune table.
|
||||
*
|
||||
* Lève ce que lève exigerPlanIndexe (configuration.js).
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @param {ArrayLike<number>} tableDe plan indexé, [p × R + r]
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function plafondsRealises(instance, tableDe) {
|
||||
exigerPlanIndexe(instance, tableDe);
|
||||
const { N, T, R } = instance;
|
||||
const occupation = new Int32Array(T * R);
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const t = tableDe[p * R + r];
|
||||
if (t !== RESERVE) occupation[t * R + r] += 1;
|
||||
}
|
||||
}
|
||||
const plafonds = [];
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
plafonds.push(plafondSurRangee(instance, p, tableDe, p * R, occupation));
|
||||
}
|
||||
return plafonds;
|
||||
}
|
||||
|
||||
// Clé de signature de p : statut, table d'ancrage, multiensemble trié des
|
||||
// tables fixées. Le plafond a priori ne dépend que d'elle : la formule ne lit
|
||||
// ni l'ordre des tours ni l'identité de p, et les tables admissibles sont les
|
||||
// mêmes pour tous.
|
||||
function signature(instance, p) {
|
||||
const { R, fixe, statut } = instance;
|
||||
const fixees = [];
|
||||
for (let r = 0; r < R; r += 1) if (fixe[p * R + r] !== LIBRE) fixees.push(fixe[p * R + r]);
|
||||
fixees.sort((x, y) => x - y);
|
||||
return `${statut[p]}|${ancrageDe(instance, p)}|${fixees.join(',')}`;
|
||||
}
|
||||
|
||||
// Plafond a priori de p, par programmation dynamique. Les tours fixés donnent
|
||||
// F, ses tables distinctes D₀, ancres0 = Σ_{t∈D₀} a_t et sieges0 =
|
||||
// Σ_{t∈F} v_t ; restent f tours libres. Un état (s, w) — s tours libres
|
||||
// attribués, w = min(cap, sièges ajoutés), cap = max(0, n_p − sieges0) —
|
||||
// porte la plus grande somme d'ancrés ajoutée qui l'atteint, −1 s'il est
|
||||
// inatteignable. Chaque table admissible, prise une fois, reçoit une
|
||||
// multiplicité m de 0 à f − s : ses ancrés s'ajoutent si m > 0 et qu'elle
|
||||
// n'est pas dans D₀, ses sièges m fois. Seuls les états à s = f concluent.
|
||||
// Plafonner w à cap ne perd rien, la formule bornant les sièges à n_p ; et
|
||||
// la plus grande somme d'ancrés suffit par état, la formule croissant avec
|
||||
// elle.
|
||||
function maximiser(instance, p, admissibles) {
|
||||
const { T, R, fixe, capacite } = instance;
|
||||
const ancrage = ancrageDe(instance, p);
|
||||
const nP = mobilesRencontrables(instance, p);
|
||||
const sieges = (t) => capacite[t] - 1 - ancresVus(instance, t, ancrage);
|
||||
const dansD0 = new Uint8Array(T);
|
||||
let ancres0 = 0;
|
||||
let sieges0 = 0;
|
||||
let f = 0;
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const t = fixe[p * R + r];
|
||||
if (t === LIBRE) {
|
||||
f += 1;
|
||||
continue;
|
||||
}
|
||||
sieges0 += sieges(t);
|
||||
if (dansD0[t] === 0) {
|
||||
dansD0[t] = 1;
|
||||
ancres0 += ancresVus(instance, t, ancrage);
|
||||
}
|
||||
}
|
||||
// Sans tour libre, l'itinéraire est imposé ; sans table admissible, les
|
||||
// tours libres restent en réserve.
|
||||
if (f === 0 || admissibles.length === 0) return borne(instance, nP, ancres0, sieges0);
|
||||
|
||||
// Les sièges ajoutés ne dépassent jamais f fois le plus grand v_t : au-delà
|
||||
// de cette valeur, w n'a pas de case à remplir.
|
||||
let vMax = 0;
|
||||
for (const t of admissibles) vMax = Math.max(vMax, sieges(t));
|
||||
const W = Math.min(Math.max(0, nP - sieges0), f * vMax);
|
||||
const largeur = W + 1;
|
||||
let meilleur = new Int32Array((f + 1) * largeur).fill(-1);
|
||||
meilleur[0] = 0;
|
||||
for (const t of admissibles) {
|
||||
const gain = dansD0[t] === 1 ? 0 : ancresVus(instance, t, ancrage);
|
||||
const v = sieges(t);
|
||||
const suivant = meilleur.slice();
|
||||
for (let s = 0; s < f; s += 1) {
|
||||
for (let w = 0; w <= W; w += 1) {
|
||||
const atteint = meilleur[s * largeur + w];
|
||||
if (atteint < 0) continue;
|
||||
for (let m = 1; s + m <= f; m += 1) {
|
||||
const cible = (s + m) * largeur + Math.min(W, w + m * v);
|
||||
if (atteint + gain > suivant[cible]) suivant[cible] = atteint + gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
meilleur = suivant;
|
||||
}
|
||||
let plafond = -1;
|
||||
for (let w = 0; w <= W; w += 1) {
|
||||
const ajout = meilleur[f * largeur + w];
|
||||
if (ajout >= 0) plafond = Math.max(plafond, borne(instance, nP, ancres0 + ajout, sieges0 + w));
|
||||
}
|
||||
return plafond;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plafond a priori de chacun, maximisé sur les itinéraires admissibles :
|
||||
* tours fixés imposés, tours libres sur toute table non entièrement ancrée.
|
||||
* Exact, par programmation dynamique ; mémoïsé par signature.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function plafondsAPriori(instance) {
|
||||
const admissibles = tablesAdmissibles(instance);
|
||||
const parSignature = new Map();
|
||||
const plafonds = [];
|
||||
for (let p = 0; p < instance.N; p += 1) {
|
||||
const cle = signature(instance, p);
|
||||
let plafond = parSignature.get(cle);
|
||||
if (plafond === undefined) {
|
||||
plafond = maximiser(instance, p, admissibles);
|
||||
parSignature.set(cle, plafond);
|
||||
}
|
||||
plafonds.push(plafond);
|
||||
}
|
||||
return plafonds;
|
||||
}
|
||||
|
||||
// Vrai quand C(A + f − 1, f), le nombre de multiensembles de f tables parmi
|
||||
// A, dépasse limite. Le produit avance par C(A − 1 + i, i), entier et
|
||||
// croissant en i : il s'arrête dès qu'il dépasse, avant d'excéder les
|
||||
// entiers qu'un nombre représente exactement.
|
||||
function multiensemblesAuDela(A, f, limite) {
|
||||
let compte = 1;
|
||||
for (let i = 1; i <= f; i += 1) {
|
||||
compte = (compte * (A - 1 + i)) / i;
|
||||
if (compte > limite) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Plafond a priori de p par énumération : chaque multiensemble de tables
|
||||
// admissibles, de la taille du nombre de tours libres, se pose sur ces tours
|
||||
// dans l'ordre, et plafondItineraire en donne la valeur. La formule ne lisant
|
||||
// pas l'ordre des tours, un multiensemble suffit pour chaque ensemble
|
||||
// d'itinéraires qui ne diffèrent que par cet ordre.
|
||||
function enumerer(instance, p, admissibles) {
|
||||
const { R, fixe, ids } = instance;
|
||||
const itineraire = Int32Array.from(fixe.subarray(p * R, (p + 1) * R));
|
||||
const libres = [];
|
||||
for (let r = 0; r < R; r += 1) if (itineraire[r] === LIBRE) libres.push(r);
|
||||
const f = libres.length;
|
||||
const A = admissibles.length;
|
||||
// Sans table admissible, les tours libres restent à −1, en réserve.
|
||||
if (f === 0 || A === 0) return plafondItineraire(instance, p, itineraire);
|
||||
if (multiensemblesAuDela(A, f, LIMITE_ENUMERATION)) {
|
||||
throw new ErreurConfiguration('ENUMERATION_TROP_GRANDE', {
|
||||
participant: ids[p],
|
||||
tablesAdmissibles: A,
|
||||
toursLibres: f,
|
||||
limite: LIMITE_ENUMERATION,
|
||||
});
|
||||
}
|
||||
// choix[i] : rang, dans admissibles, de la table du i-ème tour libre. Les
|
||||
// suites non décroissantes se suivent dans l'ordre lexicographique, de
|
||||
// (0, …, 0) à (A − 1, …, A − 1).
|
||||
const choix = new Int32Array(f);
|
||||
let plafond = -1;
|
||||
for (;;) {
|
||||
for (let i = 0; i < f; i += 1) itineraire[libres[i]] = admissibles[choix[i]];
|
||||
plafond = Math.max(plafond, plafondItineraire(instance, p, itineraire));
|
||||
let i = f - 1;
|
||||
while (i >= 0 && choix[i] === A - 1) i -= 1;
|
||||
if (i < 0) return plafond;
|
||||
choix[i] += 1;
|
||||
for (let j = i + 1; j < f; j += 1) choix[j] = choix[i];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Oracle : même résultat par énumération des multiensembles de tables des
|
||||
* tours libres. Lève ErreurConfiguration('ENUMERATION_TROP_GRANDE') au-delà de
|
||||
* 300 000 multiensembles pour une signature, avec pour détails le
|
||||
* participant, par identifiant, le premier dans l'ordre des index dont
|
||||
* l'énumération dépasse, le nombre de tables admissibles, celui de ses tours
|
||||
* libres, et la limite.
|
||||
*
|
||||
* L'énumération ne partage avec plafondsAPriori que la formule et la règle
|
||||
* d'admissibilité. Elle regroupe les personnes par rangée de tours fixés,
|
||||
* plus fine que la signature : si la signature confondait deux personnes de
|
||||
* plafonds distincts, les deux fonctions divergeraient sur l'une d'elles.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function plafondsAPrioriParEnumeration(instance) {
|
||||
const { N, R, fixe } = instance;
|
||||
const admissibles = tablesAdmissibles(instance);
|
||||
const parRangee = new Map();
|
||||
const plafonds = [];
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
const cle = fixe.subarray(p * R, (p + 1) * R).join(',');
|
||||
let plafond = parRangee.get(cle);
|
||||
if (plafond === undefined) {
|
||||
plafond = enumerer(instance, p, admissibles);
|
||||
parRangee.set(cle, plafond);
|
||||
}
|
||||
plafonds.push(plafond);
|
||||
}
|
||||
return plafonds;
|
||||
}
|
||||
199
src/moteur/plafond.long.test.js
Normal file
199
src/moteur/plafond.long.test.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves lourdes des plafonds (§ 14.4, § 14.12) : l'accord de la
|
||||
// programmation dynamique et de l'énumération sur une grille plus large que
|
||||
// celle de la série node, jusqu'à 8 tables et 5 tours ; la forme de la grande
|
||||
// démonstration et sa variante, arbitrées par l'énumération ; la limite de
|
||||
// l'énumération à sa frontière exacte.
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { LIBRE, STATUT, normaliser } from './configuration.js';
|
||||
import { ErreurConfiguration } from './erreurs.js';
|
||||
import { plafondsAPriori, plafondsAPrioriParEnumeration } from './plafond.js';
|
||||
|
||||
const SANS_CONTRAINTE = {
|
||||
separerAppartenances: false,
|
||||
nouveauxVoisins: false,
|
||||
nouvelleTable: false,
|
||||
varierAppartenances: false,
|
||||
};
|
||||
|
||||
// Graine de la grille, écrite ici (§ 14.12) ; chaque case de la grille en
|
||||
// dérive la sienne.
|
||||
const GRAINE = 314_159;
|
||||
|
||||
/**
|
||||
* Instance d'une salle : capacites[t] et ancres[t] décrivent la table
|
||||
* d'index t ; partiels[j][r] est la table imposée au j-ème partiellement
|
||||
* fixé au tour r, LIBRE pour un tour libre. Identifiants 1 à N : les ancrés,
|
||||
* table par table, puis les partiels, puis les mobiles ; la table d'index t
|
||||
* a pour identifiant 100 + t. Lève ce que normaliser lève.
|
||||
*/
|
||||
function instanceDe({ capacites, ancres, mobiles, tours, partiels = [] }) {
|
||||
const participants = [];
|
||||
const reservations = [];
|
||||
const nouveau = () => {
|
||||
const id = participants.length + 1;
|
||||
participants.push({ id, nom: `P${id}`, appartenance: null });
|
||||
return id;
|
||||
};
|
||||
ancres.forEach((a, t) => {
|
||||
for (let i = 0; i < a; i += 1) {
|
||||
reservations.push({ participant: nouveau(), table: 100 + t, portee: 'tous' });
|
||||
}
|
||||
});
|
||||
for (const tables of partiels) {
|
||||
const id = nouveau();
|
||||
tables.forEach((t, r) => {
|
||||
if (t === LIBRE) return;
|
||||
reservations.push({ participant: id, table: 100 + t, portee: 'tour', tour: r + 1 });
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < mobiles; i += 1) nouveau();
|
||||
return normaliser({
|
||||
participants,
|
||||
tables: capacites.map((capacite, t) => ({ id: 100 + t, numero: t + 1, capacite })),
|
||||
tours,
|
||||
reservations,
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
});
|
||||
}
|
||||
|
||||
// Une case de la grille : T tables et R tours fixés, capacités de 2 à 6.
|
||||
// Une table est entièrement ancrée une fois sur quatre, et garde sinon de 1 à
|
||||
// c sièges libres. Chaque tour d'un partiel est libre une fois sur deux, ou
|
||||
// désigne par son rang l'une des tables qui gardent un siège, rang pris
|
||||
// modulo leur nombre : seul l'empilement de partiels à une même table, au
|
||||
// même tour, fait encore refuser une instance.
|
||||
function arbitraireCase(T, R) {
|
||||
const table = fc
|
||||
.integer({ min: 2, max: 6 })
|
||||
.chain((c) =>
|
||||
fc.tuple(
|
||||
fc.constant(c),
|
||||
fc.oneof(
|
||||
{ arbitrary: fc.integer({ min: 0, max: c - 1 }), weight: 3 },
|
||||
{ arbitrary: fc.constant(c), weight: 1 },
|
||||
),
|
||||
),
|
||||
);
|
||||
const tour = fc.oneof(fc.constant(LIBRE), fc.integer({ min: 0, max: T - 1 }));
|
||||
return fc
|
||||
.record({
|
||||
salle: fc.array(table, { minLength: T, maxLength: T }),
|
||||
mobiles: fc.integer({ min: 0, max: 10 }),
|
||||
rangs: fc.array(fc.array(tour, { minLength: R, maxLength: R }), { maxLength: 4 }),
|
||||
})
|
||||
.map(({ salle, mobiles, rangs }) => {
|
||||
const ouvertes = [];
|
||||
salle.forEach(([c, a], t) => {
|
||||
if (a < c) ouvertes.push(t);
|
||||
});
|
||||
const partiels = rangs.map((tours) =>
|
||||
tours.map((rang) => (rang === LIBRE || ouvertes.length === 0 ? LIBRE : ouvertes[rang % ouvertes.length])),
|
||||
);
|
||||
return {
|
||||
capacites: salle.map(([c]) => c),
|
||||
ancres: salle.map(([, a]) => a),
|
||||
mobiles,
|
||||
tours: R,
|
||||
partiels,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("plafond a priori : la programmation dynamique contre l'énumération, grille large", () => {
|
||||
test('de 2 à 8 tables et de 2 à 5 tours, chaque case de la grille : le même plafond pour chacun', () => {
|
||||
const parCase = [];
|
||||
// Une valeur de STATUT et son compte ; la Map ne sert qu'à retrouver un
|
||||
// statut.
|
||||
const parStatut = new Map();
|
||||
for (let T = 2; T <= 8; T += 1) {
|
||||
for (let R = 2; R <= 5; R += 1) {
|
||||
let acceptees = 0;
|
||||
fc.assert(
|
||||
fc.property(arbitraireCase(T, R), (description) => {
|
||||
let instance;
|
||||
try {
|
||||
instance = instanceDe(description);
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION') return;
|
||||
throw erreur;
|
||||
}
|
||||
if (instance.N === 0) return;
|
||||
acceptees += 1;
|
||||
for (const statut of instance.statut) parStatut.set(statut, (parStatut.get(statut) ?? 0) + 1);
|
||||
assert.deepEqual(plafondsAPriori(instance), plafondsAPrioriParEnumeration(instance));
|
||||
}),
|
||||
{ seed: GRAINE + 10 * T + R, numRuns: 300 },
|
||||
);
|
||||
parCase.push([T, R, acceptees]);
|
||||
}
|
||||
}
|
||||
// Chaque case compte ses instances acceptées : une case que le
|
||||
// générateur viderait n'éprouverait rien.
|
||||
assert.equal(parCase.length, 28);
|
||||
const maigres = parCase
|
||||
.filter(([, , acceptees]) => acceptees < 200)
|
||||
.map(([T, R, n]) => `${T} tables, ${R} tours : ${n} acceptées sur 300, 200 au moins`);
|
||||
assert.deepEqual(maigres, []);
|
||||
const partiels = parStatut.get(STATUT.PARTIELLEMENT_FIXE) ?? 0;
|
||||
const ancres = parStatut.get(STATUT.ANCRE) ?? 0;
|
||||
assert.ok(partiels >= 5000, `${partiels} partiellement fixés comparés, 5000 au moins`);
|
||||
assert.ok(ancres >= 5000, `${ancres} ancrés comparés, 5000 au moins`);
|
||||
});
|
||||
|
||||
test("forme de la grande démonstration et sa variante : la programmation dynamique égale l'énumération", () => {
|
||||
// Principale : 29 tables de 8 puis 4 de 7, un ancré par table. Variante :
|
||||
// 33 tables de 8, 4 places libres. 227 mobiles, 4 tours ; un mobile
|
||||
// énumère C(36, 4) = 58 905 multiensembles.
|
||||
const principale = instanceDe({
|
||||
capacites: [...Array(29).fill(8), ...Array(4).fill(7)],
|
||||
ancres: Array(33).fill(1),
|
||||
mobiles: 227,
|
||||
tours: 4,
|
||||
});
|
||||
// Les ancrés portent les identifiants 1 à 33, table par table.
|
||||
const attendu = new Array(260).fill(28);
|
||||
for (let t = 29; t < 33; t += 1) attendu[t] = 24;
|
||||
assert.deepEqual(plafondsAPriori(principale), attendu);
|
||||
assert.deepEqual(plafondsAPrioriParEnumeration(principale), attendu);
|
||||
|
||||
const variante = instanceDe({
|
||||
capacites: Array(33).fill(8),
|
||||
ancres: Array(33).fill(1),
|
||||
mobiles: 227,
|
||||
tours: 4,
|
||||
});
|
||||
const vingtHuit = new Array(260).fill(28);
|
||||
assert.deepEqual(plafondsAPriori(variante), vingtHuit);
|
||||
assert.deepEqual(plafondsAPrioriParEnumeration(variante), vingtHuit);
|
||||
});
|
||||
|
||||
test("la limite de l'énumération : 300 000 multiensembles passent, un de plus est refusé", () => {
|
||||
// Un tour libre : le compte est celui des tables admissibles.
|
||||
const unTour = (tables) =>
|
||||
instanceDe({ capacites: Array(tables).fill(2), ancres: [], mobiles: 1, tours: 1 });
|
||||
assert.deepEqual(plafondsAPrioriParEnumeration(unTour(300_000)), [0]);
|
||||
assert.throws(
|
||||
() => plafondsAPrioriParEnumeration(unTour(300_001)),
|
||||
(erreur) =>
|
||||
erreur instanceof ErreurConfiguration
|
||||
&& erreur.code === 'ENUMERATION_TROP_GRANDE'
|
||||
&& erreur.details.tablesAdmissibles === 300_001
|
||||
&& erreur.details.toursLibres === 1,
|
||||
);
|
||||
// Deux tours libres : C(775, 2) = 299 925 passent, là où 774² le
|
||||
// refuserait ; C(776, 2) = 300 700 est refusé.
|
||||
const deuxTours = (tables) =>
|
||||
instanceDe({ capacites: Array(tables).fill(2), ancres: [], mobiles: 2, tours: 2 });
|
||||
const passe = deuxTours(774);
|
||||
assert.deepEqual(plafondsAPrioriParEnumeration(passe), plafondsAPriori(passe));
|
||||
assert.throws(
|
||||
() => plafondsAPrioriParEnumeration(deuxTours(775)),
|
||||
(erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'ENUMERATION_TROP_GRANDE',
|
||||
);
|
||||
});
|
||||
});
|
||||
537
src/moteur/plafond.test.js
Normal file
537
src/moteur/plafond.test.js
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des plafonds (§ 5.5) : la formule sur un itinéraire, le plafond
|
||||
// réalisé sur l'occupation, le plafond a priori maximisé sur les itinéraires
|
||||
// admissibles, et l'accord de la programmation dynamique avec l'énumération
|
||||
// exhaustive qui l'arbitre (§ 14.11, § 14.12). Les valeurs attendues des
|
||||
// formes nommées sont celles du § 5.5, du § 14.10 et du § 15 ; les autres se
|
||||
// calculent à la main, table par table, dans le commentaire qui les précède.
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import {
|
||||
LIBRE,
|
||||
RESERVE,
|
||||
STATUT,
|
||||
indexerPlan,
|
||||
normaliser,
|
||||
planDepuisIndex,
|
||||
} from './configuration.js';
|
||||
import { ErreurConfiguration } from './erreurs.js';
|
||||
import {
|
||||
plafondItineraire,
|
||||
plafondsAPriori,
|
||||
plafondsAPrioriParEnumeration,
|
||||
plafondsRealises,
|
||||
} from './plafond.js';
|
||||
|
||||
const SANS_CONTRAINTE = {
|
||||
separerAppartenances: false,
|
||||
nouveauxVoisins: false,
|
||||
nouvelleTable: false,
|
||||
varierAppartenances: false,
|
||||
};
|
||||
|
||||
// Graine de la propriété, écrite ici pour que chaque exécution tire les
|
||||
// mêmes instances (§ 14.12).
|
||||
const GRAINE = 271_828;
|
||||
|
||||
// Entiers de debut à fin, inclus.
|
||||
const suite = (debut, fin) => Array.from({ length: fin - debut + 1 }, (_, i) => debut + i);
|
||||
|
||||
/**
|
||||
* Instance d'une forme de salle. capacites[t] et ancres[t] décrivent la
|
||||
* table d'index t ; autres compte les participants non ancrés, dont les
|
||||
* premiers sont partiellement fixés selon partiels : partiels[j][r] est
|
||||
* l'index de table imposé au tour r, LIBRE pour un tour libre. Les ancrés
|
||||
* prennent les identifiants pairs tant qu'il reste des non-ancrés, et les
|
||||
* identifiants de table décroissent quand l'index croît : un index lu comme
|
||||
* un identifiant désigne une autre personne ou une autre table. Rend
|
||||
* l'instance et les index de personne de chaque rôle ; lève ce que
|
||||
* normaliser lève.
|
||||
*/
|
||||
function forme({ capacites, ancres, autres, tours, partiels = [] }) {
|
||||
const T = capacites.length;
|
||||
const idTable = (t) => 10 * (T - t);
|
||||
const K = ancres.reduce((somme, a) => somme + a, 0);
|
||||
const idsAncres = [];
|
||||
const idsAutres = [];
|
||||
for (const id of suite(1, K + autres)) {
|
||||
const resteAutres = autres - idsAutres.length;
|
||||
if (idsAncres.length < K && (id % 2 === 0 || resteAutres === 0)) idsAncres.push(id);
|
||||
else idsAutres.push(id);
|
||||
}
|
||||
const reservations = [];
|
||||
const ancresParTable = [];
|
||||
let premier = 0;
|
||||
for (let t = 0; t < T; t += 1) {
|
||||
const ids = idsAncres.slice(premier, premier + ancres[t]);
|
||||
premier += ancres[t];
|
||||
ancresParTable.push(ids);
|
||||
for (const id of ids) reservations.push({ participant: id, table: idTable(t), portee: 'tous' });
|
||||
}
|
||||
partiels.forEach((tables, j) => {
|
||||
tables.forEach((t, r) => {
|
||||
if (t === LIBRE) return;
|
||||
reservations.push({
|
||||
participant: idsAutres[j],
|
||||
table: idTable(t),
|
||||
portee: 'tour',
|
||||
tour: r + 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
const instance = normaliser({
|
||||
participants: suite(1, K + autres).map((id) => ({ id, nom: `P${id}`, appartenance: null })),
|
||||
tables: capacites.map((capacite, t) => ({ id: idTable(t), numero: t + 1, capacite })),
|
||||
tours,
|
||||
reservations,
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
});
|
||||
const index = (id) => instance.indexDe.get(id);
|
||||
return {
|
||||
instance,
|
||||
ancres: ancresParTable.map((ids) => ids.map(index)),
|
||||
partiels: idsAutres.slice(0, partiels.length).map(index),
|
||||
mobiles: idsAutres.slice(partiels.length).map(index),
|
||||
};
|
||||
}
|
||||
|
||||
// Forme de la grande démonstration (§ 15.1) : 29 tables de 8 puis 4 de 7,
|
||||
// un ancré par table, 227 non-ancrés, 4 tours. Les tables de 7 portent les
|
||||
// index 29 à 32.
|
||||
const grandeForme = (partiels = []) =>
|
||||
forme({
|
||||
capacites: [...Array(29).fill(8), ...Array(4).fill(7)],
|
||||
ancres: Array(33).fill(1),
|
||||
autres: 227,
|
||||
tours: 4,
|
||||
partiels,
|
||||
});
|
||||
|
||||
// Plan indexé d'un plan écrit table par table : listes[r][t] porte les
|
||||
// identifiants assis à la table d'index t au tour r, reserves[r] ceux du
|
||||
// tour r en réserve.
|
||||
const planIndexe = (instance, listes, reserves = listes.map(() => [])) =>
|
||||
indexerPlan(instance, { tables: [...instance.idsTables], tours: listes, reserves });
|
||||
|
||||
// Plan valide quelconque, que la graine brasse : à chaque tour, chaque
|
||||
// personne fixée s'assied à sa table ; les autres, dans un ordre brassé,
|
||||
// prennent tour à tour un siège à la table suivante d'un cycle brassé des
|
||||
// tables qui en ont encore ; sans siège, une personne va en réserve.
|
||||
function planBrasse(instance, graine) {
|
||||
const { N, T, R, fixe, capacite } = instance;
|
||||
const tableDe = new Int32Array(N * R).fill(RESERVE);
|
||||
const brassage = (i, r) => ((i + 1) * 2_654_435_761 + (r + 1) * 40_503 + graine * 97) % 1_000_003;
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const libres = Int32Array.from(capacite);
|
||||
const aPlacer = [];
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
const t = fixe[p * R + r];
|
||||
if (t !== LIBRE) {
|
||||
tableDe[p * R + r] = t;
|
||||
libres[t] -= 1;
|
||||
} else {
|
||||
aPlacer.push(p);
|
||||
}
|
||||
}
|
||||
aPlacer.sort((x, y) => brassage(x, r) - brassage(y, r) || x - y);
|
||||
const cycle = suite(0, T - 1).sort((x, y) => brassage(N + x, r) - brassage(N + y, r) || x - y);
|
||||
let rang = 0;
|
||||
for (const p of aPlacer) {
|
||||
let essais = 0;
|
||||
while (essais < T && libres[cycle[rang % T]] === 0) {
|
||||
rang += 1;
|
||||
essais += 1;
|
||||
}
|
||||
if (essais === T) break;
|
||||
const t = cycle[rang % T];
|
||||
tableDe[p * R + r] = t;
|
||||
libres[t] -= 1;
|
||||
rang += 1;
|
||||
}
|
||||
}
|
||||
return tableDe;
|
||||
}
|
||||
|
||||
// Ancrés d'une table de capacité c : de 0 à c − 1, ou c une fois sur
|
||||
// quatre, pour une table entièrement ancrée.
|
||||
const ancresDe = (c) =>
|
||||
fc.oneof(
|
||||
{ arbitrary: fc.integer({ min: 0, max: c - 1 }), weight: 3 },
|
||||
{ arbitrary: fc.constant(c), weight: 1 },
|
||||
);
|
||||
|
||||
// Un tour d'un non-ancré : libre (LIBRE) une fois sur deux, fixé sinon à
|
||||
// l'une des T tables.
|
||||
const tourDe = (T) => fc.oneof(fc.constant(LIBRE), fc.integer({ min: 0, max: T - 1 }));
|
||||
|
||||
// Description d'une instance tirée dans les bornes données : chaque table
|
||||
// tire sa capacité puis ses ancrés, chaque partiel un tour par tour. Un
|
||||
// partiel dont aucun tour n'est fixé est un mobile ; un partiel fixé à la
|
||||
// même table à chaque tour, un ancré (§ 4.2).
|
||||
function arbitraireForme({ tables, tours, capacites, mobiles, partiels }) {
|
||||
const table = fc
|
||||
.integer({ min: capacites[0], max: capacites[1] })
|
||||
.chain((c) => fc.tuple(fc.constant(c), ancresDe(c)));
|
||||
return fc
|
||||
.tuple(
|
||||
fc.integer({ min: tables[0], max: tables[1] }),
|
||||
fc.integer({ min: tours[0], max: tours[1] }),
|
||||
)
|
||||
.chain(([T, R]) =>
|
||||
fc.record({
|
||||
salle: fc.array(table, { minLength: T, maxLength: T }),
|
||||
tours: fc.constant(R),
|
||||
mobiles: fc.integer({ min: 0, max: mobiles }),
|
||||
partiels: fc.array(fc.array(tourDe(T), { minLength: R, maxLength: R }), {
|
||||
maxLength: partiels,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Compare les deux calculs du plafond a priori sur chaque instance tirée.
|
||||
// Une instance que normaliser refuse — une table qui reçoit plus de
|
||||
// personnes fixées que de sièges — ou sans participant n'est pas comptée ;
|
||||
// une autre l'est, avec ses personnes par statut et ses tables pleines
|
||||
// d'ancrés. Rend ces comptes ; parStatut associe une valeur de STATUT à son
|
||||
// compte, et la Map ne sert qu'à retrouver un statut.
|
||||
function eprouverAccord(arbitraire, parametres) {
|
||||
const bilan = { acceptees: 0, parStatut: new Map(), tablesPleines: 0 };
|
||||
fc.assert(
|
||||
fc.property(arbitraire, ({ salle, tours, mobiles, partiels }) => {
|
||||
let instance;
|
||||
try {
|
||||
({ instance } = forme({
|
||||
capacites: salle.map(([c]) => c),
|
||||
ancres: salle.map(([, a]) => a),
|
||||
autres: partiels.length + mobiles,
|
||||
tours,
|
||||
partiels,
|
||||
}));
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION') return;
|
||||
throw erreur;
|
||||
}
|
||||
if (instance.N === 0) return;
|
||||
bilan.acceptees += 1;
|
||||
for (const statut of instance.statut) {
|
||||
bilan.parStatut.set(statut, (bilan.parStatut.get(statut) ?? 0) + 1);
|
||||
}
|
||||
if (salle.some(([c, a]) => a === c)) bilan.tablesPleines += 1;
|
||||
assert.deepEqual(plafondsAPriori(instance), plafondsAPrioriParEnumeration(instance));
|
||||
}),
|
||||
parametres,
|
||||
);
|
||||
return bilan;
|
||||
}
|
||||
|
||||
describe('plafonds : valeurs exactes (§ 5.5, § 15)', () => {
|
||||
test('4 tables de 5, un ancré chacune, 5 tours : un ancré 16, un mobile 19', () => {
|
||||
const { instance, ancres, mobiles } = forme({
|
||||
capacites: [5, 5, 5, 5],
|
||||
ancres: [1, 1, 1, 1],
|
||||
autres: 16,
|
||||
tours: 5,
|
||||
});
|
||||
assert.equal(instance.N, 20);
|
||||
assert.equal(instance.k, 4);
|
||||
assert.equal(instance.n, 16);
|
||||
// L'écart entre les deux valeurs est l'objet du test. Un ancré ne
|
||||
// rencontre jamais les ancrés des autres tables : ses 5 tours × 4
|
||||
// sièges ne livrent que les 16 mobiles. Sans le terme min(n_p, …), il
|
||||
// monterait à 19 avec le mobile.
|
||||
const attendu = new Array(20).fill(19);
|
||||
for (const [p] of ancres) attendu[p] = 16;
|
||||
assert.deepEqual(plafondsAPriori(instance), attendu);
|
||||
assert.deepEqual(plafondsAPrioriParEnumeration(instance), attendu);
|
||||
assert.equal(plafondItineraire(instance, ancres[0][0], [0, 0, 0, 0, 0]), 16);
|
||||
assert.equal(plafondItineraire(instance, mobiles[0], [0, 1, 2, 3, 0]), 19);
|
||||
});
|
||||
|
||||
test('forme de la grande démonstration : mobile 28, animateur 28 à une table de 8, 24 à une table de 7', () => {
|
||||
const { instance, ancres, mobiles } = grandeForme();
|
||||
assert.equal(instance.N, 260);
|
||||
assert.equal(instance.k, 33);
|
||||
assert.equal(instance.n, 227);
|
||||
const attendu = new Array(260).fill(28);
|
||||
for (let t = 29; t < 33; t += 1) attendu[ancres[t][0]] = 24;
|
||||
assert.deepEqual(plafondsAPriori(instance), attendu);
|
||||
// Un mobile qui passe une fois par une table de 7 : 4 ancrés, 3 × 6 + 5
|
||||
// sièges, 27. Par quatre tables de 8 : 28.
|
||||
assert.equal(plafondItineraire(instance, mobiles[0], [0, 1, 2, 29]), 27);
|
||||
assert.equal(plafondItineraire(instance, mobiles[0], [0, 1, 2, 3]), 28);
|
||||
});
|
||||
|
||||
test('petite démonstration : 8 pour chacun, que le plan parfait atteint (§ 15.3)', () => {
|
||||
const { instance } = forme({
|
||||
capacites: [3, 3, 3, 3],
|
||||
ancres: [0, 0, 0, 0],
|
||||
autres: 12,
|
||||
tours: 4,
|
||||
});
|
||||
const huit = new Array(12).fill(8);
|
||||
assert.deepEqual(plafondsAPriori(instance), huit);
|
||||
assert.deepEqual(plafondsAPrioriParEnumeration(instance), huit);
|
||||
// Le plan parfait du § 15.3, la table k du tableau à l'index k − 1. Les
|
||||
// tables y sont pleines à chaque tour : le réalisé égale l'a priori.
|
||||
const parfait = planIndexe(instance, [
|
||||
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
|
||||
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
|
||||
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
|
||||
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
|
||||
]);
|
||||
assert.deepEqual(plafondsRealises(instance, parfait), huit);
|
||||
});
|
||||
|
||||
test("plafond réalisé sur l'occupation : 3 tables de 4, un ancré chacune, N = 10, 2 tours", () => {
|
||||
const { instance } = forme({ capacites: [4, 4, 4], ancres: [1, 1, 1], autres: 7, tours: 2 });
|
||||
assert.equal(instance.n, 7);
|
||||
// Ancrés 2, 4 et 6 aux tables d'index 0, 1 et 2 ; les mobiles 1 et 3
|
||||
// passent les deux tours à la table d'index 0, occupée par 3.
|
||||
const tableDe = planIndexe(instance, [
|
||||
[[1, 2, 3], [4, 5, 7, 8], [6, 9, 10]],
|
||||
[[1, 2, 3], [4, 9, 10], [5, 6, 7, 8]],
|
||||
]);
|
||||
// Par identifiant, l'index valant id − 1 ; N − 1 = 9, n_p = 6 pour un
|
||||
// mobile et 7 pour un ancré. Un tour à une table d'occupation o apporte
|
||||
// o − 1 − a_t sièges, a_t = 1 pour un mobile et 0 pour l'ancré de la
|
||||
// table :
|
||||
// 1, 3 table 0 deux fois, o = 3 : 1 ancré + 1 + 1 = 3
|
||||
// 5, 7, 8 tables 1 puis 2, o = 4 : 2 ancrés + 2 + 2 = 6
|
||||
// 9, 10 tables 2 puis 1, o = 3 : 2 ancrés + 1 + 1 = 4
|
||||
// 2 sa table, o = 3 puis 3 : 2 + 2 = 4
|
||||
// 4 sa table, o = 4 puis 3 : 3 + 2 = 5
|
||||
// 6 sa table, o = 3 puis 4 : 2 + 3 = 5
|
||||
const realises = plafondsRealises(instance, tableDe);
|
||||
assert.deepEqual(realises, [3, 4, 3, 5, 6, 5, 6, 6, 4, 4]);
|
||||
// A priori, sur les capacités : un mobile visite deux tables, 2 + 2 × 2 ;
|
||||
// un ancré tient sa table, 2 × 3.
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
assert.deepEqual(aPriori, new Array(10).fill(6));
|
||||
const p = instance.indexDe.get(1);
|
||||
assert.equal(realises[p], 3);
|
||||
assert.equal(aPriori[p], 6);
|
||||
// Écart d'itinéraire du mobile 1 : 3.
|
||||
assert.equal(aPriori[p] - realises[p], 3);
|
||||
});
|
||||
|
||||
test('partiellement fixé : sa table fixée entre dans F, seuls ses tours libres varient', () => {
|
||||
// Un non-ancré de la grande forme, fixé au tour 1 à une table de 7.
|
||||
const { instance, partiels, mobiles, ancres } = grandeForme([[29, LIBRE, LIBRE, LIBRE]]);
|
||||
const [p] = partiels;
|
||||
assert.equal(instance.statut[p], STATUT.PARTIELLEMENT_FIXE);
|
||||
assert.equal(instance.k, 33);
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
// La table de 7 entre dans F : 1 ancré et 5 sièges ; ses trois tours
|
||||
// libres prennent trois tables de 8 : 3 ancrés et 18 sièges. 27.
|
||||
assert.equal(aPriori[p], 27);
|
||||
assert.equal(aPriori[mobiles[0]], 28);
|
||||
assert.equal(aPriori[ancres[29][0]], 24);
|
||||
// Libéré de son tour fixé, l'itinéraire atteindrait 28 : le maximum ne
|
||||
// déplace pas ce tour.
|
||||
assert.equal(plafondItineraire(instance, p, [0, 1, 2, 3]), 28);
|
||||
assert.equal(plafondItineraire(instance, p, [29, 0, 1, 2]), 27);
|
||||
assert.equal(plafondsAPrioriParEnumeration(instance)[p], 27);
|
||||
});
|
||||
|
||||
test("une table pleine d'ancrés n'est pas admissible ; un partiellement fixé compte parmi les mobiles", () => {
|
||||
// Table d'index 0 : 5 sièges, aucun ancré. Table d'index 1 : 2 sièges,
|
||||
// 2 ancrés, pleine. Le partiellement fixé est fixé au tour 1 à la table
|
||||
// 0 ; N = 5, k = 2, n = 3, 2 tours.
|
||||
const { instance, ancres, partiels, mobiles } = forme({
|
||||
capacites: [5, 2],
|
||||
ancres: [0, 2],
|
||||
autres: 3,
|
||||
tours: 2,
|
||||
partiels: [[0, LIBRE]],
|
||||
});
|
||||
assert.equal(instance.n, 3);
|
||||
// Partiel et mobiles : seule la table 0 est admissible, 0 ancré et
|
||||
// 4 + 4 sièges, bornés par n_p = n − 1 = 2. La table pleine, admise, leur
|
||||
// donnerait 4 : 2 ancrés, et v = 2 − 1 − 2 = −1 ; n_p = n en donnerait 3.
|
||||
// Ancrés : 1 ancré voisin, 0 siège qui change d'occupant. 1.
|
||||
const attendu = new Array(5).fill(-1);
|
||||
for (const p of [...partiels, ...mobiles]) attendu[p] = 2;
|
||||
for (const p of ancres[1]) attendu[p] = 1;
|
||||
assert.deepEqual(plafondsAPriori(instance), attendu);
|
||||
assert.deepEqual(plafondsAPrioriParEnumeration(instance), attendu);
|
||||
});
|
||||
|
||||
test('réserve : une personne en réserve à un tour a un plafond réalisé sur ses seuls tours assis', () => {
|
||||
// Deux tables de 3, le participant 2 ancré à la table d'index 0 ;
|
||||
// 4 mobiles, 2 tours. Le mobile 3 est en réserve au tour 2.
|
||||
const { instance } = forme({ capacites: [3, 3], ancres: [1, 0], autres: 4, tours: 2 });
|
||||
const tableDe = planIndexe(
|
||||
instance,
|
||||
[
|
||||
[[1, 2, 3], [4, 5]],
|
||||
[[2, 4], [1, 5]],
|
||||
],
|
||||
[[], [3]],
|
||||
);
|
||||
// N − 1 = 4, n_p = 3 pour un mobile et 4 pour l'ancré :
|
||||
// 1 table 0 (3 occupants, 1 ancré) puis 1 (2) : 1 ancré + 1 + 1 = 3
|
||||
// 2 sa table, occupée par 3 puis 2 : 2 + 1 = 3
|
||||
// 3 table 0 au seul tour 1 : 1 ancré + 1 = 2
|
||||
// 4 table 1 (2) puis 0 (2, 1 ancré) : 1 ancré + 1 + 0 = 2
|
||||
// 5 table 1, occupée par 2 aux deux tours : 1 + 1 = 2
|
||||
assert.deepEqual(plafondsRealises(instance, tableDe), [3, 3, 2, 2, 2]);
|
||||
const p = instance.indexDe.get(3);
|
||||
assert.equal(plafondItineraire(instance, p, [0, RESERVE]), 2);
|
||||
// L'écart d'itinéraire porte ce que le manque ne porte pas : 4 − 2.
|
||||
assert.deepEqual(plafondsAPriori(instance), [4, 4, 4, 4, 4]);
|
||||
});
|
||||
|
||||
test('sans table admissible, les tours libres restent en réserve', () => {
|
||||
// Deux tables entièrement ancrées et un mobile, qui ne peut s'asseoir
|
||||
// nulle part. Ancrés de la table de 2 : 1 voisin, 0 siège. Ancrés de la
|
||||
// table de 3 : 2 voisins, 0 siège. Le mobile : 0.
|
||||
const { instance, ancres, mobiles } = forme({
|
||||
capacites: [2, 3],
|
||||
ancres: [2, 3],
|
||||
autres: 1,
|
||||
tours: 2,
|
||||
});
|
||||
const attendu = new Array(6).fill(-1);
|
||||
for (const p of ancres[0]) attendu[p] = 1;
|
||||
for (const p of ancres[1]) attendu[p] = 2;
|
||||
attendu[mobiles[0]] = 0;
|
||||
assert.deepEqual(plafondsAPriori(instance), attendu);
|
||||
assert.deepEqual(plafondsAPrioriParEnumeration(instance), attendu);
|
||||
assert.equal(plafondItineraire(instance, mobiles[0], [RESERVE, RESERVE]), 0);
|
||||
});
|
||||
|
||||
test('plafond a priori ≥ plafond réalisé pour chacun, sur trois plans quelconques de chaque forme', () => {
|
||||
const formes = [
|
||||
forme({ capacites: [5, 5, 5, 5], ancres: [1, 1, 1, 1], autres: 16, tours: 5 }),
|
||||
grandeForme(),
|
||||
grandeForme([[29, LIBRE, LIBRE, LIBRE], [LIBRE, 30, 30, LIBRE]]),
|
||||
forme({ capacites: [3, 3, 3, 3], ancres: [0, 0, 0, 0], autres: 12, tours: 4 }),
|
||||
forme({ capacites: [4, 4, 4], ancres: [1, 1, 1], autres: 7, tours: 2 }),
|
||||
forme({ capacites: [5, 2], ancres: [0, 2], autres: 3, tours: 2, partiels: [[0, LIBRE]] }),
|
||||
// Une place de moins que de participants : chaque plan met quelqu'un
|
||||
// en réserve.
|
||||
forme({ capacites: [3, 3], ancres: [1, 0], autres: 6, tours: 3, partiels: [[LIBRE, 1, LIBRE]] }),
|
||||
];
|
||||
const ecarts = [];
|
||||
let comparaisons = 0;
|
||||
for (let rang = 0; rang < formes.length; rang += 1) {
|
||||
const { instance } = formes[rang];
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
for (const graine of [1, 2, 3]) {
|
||||
const realises = plafondsRealises(instance, planBrasse(instance, graine));
|
||||
for (let p = 0; p < instance.N; p += 1) {
|
||||
comparaisons += 1;
|
||||
if (realises[p] > aPriori[p]) {
|
||||
ecarts.push(
|
||||
`forme ${rang}, graine ${graine}, index ${p} : `
|
||||
+ `réalisé ${realises[p]} > a priori ${aPriori[p]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(comparaisons, 3 * formes.reduce((somme, { instance }) => somme + instance.N, 0));
|
||||
assert.ok(comparaisons > 0, 'aucune comparaison');
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test("un itinéraire ou un plan indexé hors de la forme de l'instance lève RangeError", () => {
|
||||
const { instance } = forme({ capacites: [3, 3], ancres: [0, 0], autres: 4, tours: 2 });
|
||||
const cas = [
|
||||
['itinéraire de 1 tour sur 2', () => plafondItineraire(instance, 0, [0])],
|
||||
["table d'index 2 sur 2 tables", () => plafondItineraire(instance, 0, [0, 2])],
|
||||
["table d'index −2", () => plafondItineraire(instance, 0, [-2, 0])],
|
||||
["table d'index non entier", () => plafondItineraire(instance, 0, [0, 0.5])],
|
||||
["personne d'index 4 sur 4", () => plafondItineraire(instance, 4, [0, 0])],
|
||||
["personne d'index −1", () => plafondItineraire(instance, -1, [0, 0])],
|
||||
['plan de 7 cases sur 8', () => plafondsRealises(instance, new Int32Array(7))],
|
||||
['plan à une case −2', () => plafondsRealises(instance, Int32Array.of(0, 0, 0, 0, 1, 1, 1, -2))],
|
||||
['plan à une case 2', () => plafondsRealises(instance, Int32Array.of(0, 0, 0, 0, 1, 1, 1, 2))],
|
||||
];
|
||||
const ecarts = cas
|
||||
.map(([libelle, fonction]) => {
|
||||
try {
|
||||
fonction();
|
||||
} catch (erreur) {
|
||||
if (erreur instanceof RangeError) return null;
|
||||
return `${libelle} : ${erreur?.name} au lieu de RangeError`;
|
||||
}
|
||||
return `${libelle} : aucun refus`;
|
||||
})
|
||||
.filter((ecart) => ecart !== null);
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('un plan indexé hors forme : plafondsRealises nomme le participant, le tour et la valeur, comme planDepuisIndex', () => {
|
||||
// Quatre participants d'identifiants 1 à 4, deux tables, deux tours. La
|
||||
// dernière case est celle de l'identifiant 4 au tour 2. La chaîne "-1"
|
||||
// n'est pas la réserve : le message la cite entre guillemets, pour
|
||||
// qu'elle ne se lise pas comme la valeur qu'elle contient.
|
||||
const { instance } = forme({ capacites: [3, 3], ancres: [0, 0], autres: 4, tours: 2 });
|
||||
const fautif = [0, 0, 0, 0, 1, 1, 1, '-1'];
|
||||
const attendu = {
|
||||
name: 'RangeError',
|
||||
message: 'plan indexé : participant 4, tour 2, index de table "-1" hors de −1..1',
|
||||
};
|
||||
assert.throws(() => planDepuisIndex(instance, fautif), attendu);
|
||||
assert.throws(() => plafondsRealises(instance, fautif), attendu);
|
||||
});
|
||||
|
||||
test('un itinéraire hors forme cite la valeur comme la garde du plan indexé : la chaîne "-1" entre guillemets', () => {
|
||||
const { instance } = forme({ capacites: [3, 3], ancres: [0, 0], autres: 4, tours: 2 });
|
||||
assert.throws(() => plafondItineraire(instance, 0, [0, '-1']), {
|
||||
name: 'RangeError',
|
||||
message: 'itinéraire, case 1 : index de table "-1" hors de −1..1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("plafond a priori : la programmation dynamique contre l'énumération (§ 14.11, § 14.12)", () => {
|
||||
test("au-delà de 300 000 multiensembles, l'énumération refuse ; la programmation dynamique répond", () => {
|
||||
// 775 tables admissibles et 2 tours libres : C(776, 2) = 300 700
|
||||
// multiensembles pour la signature des mobiles. Le premier d'entre eux
|
||||
// dans l'ordre des index est nommé.
|
||||
const { instance, mobiles } = forme({
|
||||
capacites: Array(775).fill(2),
|
||||
ancres: Array(775).fill(0),
|
||||
autres: 2,
|
||||
tours: 2,
|
||||
});
|
||||
assert.throws(
|
||||
() => plafondsAPrioriParEnumeration(instance),
|
||||
(erreur) => {
|
||||
assert.ok(erreur instanceof ErreurConfiguration, `${erreur?.name} : ${erreur?.message}`);
|
||||
assert.equal(erreur.code, 'ENUMERATION_TROP_GRANDE');
|
||||
assert.deepEqual(erreur.details, {
|
||||
participant: instance.ids[mobiles[0]],
|
||||
tablesAdmissibles: 775,
|
||||
toursLibres: 2,
|
||||
limite: 300_000,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
// N − 1 = 1 borne les deux mobiles.
|
||||
assert.deepEqual(plafondsAPriori(instance), [1, 1]);
|
||||
});
|
||||
|
||||
test('2 à 5 tables, 2 à 4 tours, capacités 2 à 5, ancrés et partiels : le même plafond pour chacun', () => {
|
||||
const bilan = eprouverAccord(
|
||||
arbitraireForme({ tables: [2, 5], tours: [2, 4], capacites: [2, 5], mobiles: 6, partiels: 3 }),
|
||||
{ seed: GRAINE, numRuns: 500 },
|
||||
);
|
||||
// Une instance que normaliser refuse n'éprouve rien : la boucle compte
|
||||
// les acceptées, et les personnes comparées de chaque statut.
|
||||
const { acceptees, parStatut, tablesPleines } = bilan;
|
||||
assert.ok(acceptees >= 200, `${acceptees} instances acceptées, 200 au moins`);
|
||||
const partiels = parStatut.get(STATUT.PARTIELLEMENT_FIXE) ?? 0;
|
||||
const ancres = parStatut.get(STATUT.ANCRE) ?? 0;
|
||||
assert.ok(partiels >= 100, `${partiels} partiellement fixés comparés, 100 au moins`);
|
||||
assert.ok(ancres >= 100, `${ancres} ancrés comparés, 100 au moins`);
|
||||
assert.ok(tablesPleines >= 50, `${tablesPleines} instances à table pleine d'ancrés, 50 au moins`);
|
||||
});
|
||||
});
|
||||
768
src/moteur/recherche.js
Normal file
768
src/moteur/recherche.js
Normal file
|
|
@ -0,0 +1,768 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Recherche reproductible (§ 5.7, § 5.10). Une génération rend plusieurs
|
||||
// propositions ; chacune sort d'une descente par acceptation tardive (late
|
||||
// acceptance hill climbing) sur le plan complet, les N participants et les R
|
||||
// tours, ancrés compris : l'appartenance d'un ancré pèse sur ses voisins de
|
||||
// table comme celle de tout autre (§ 5.2).
|
||||
//
|
||||
// Graines. creerPcg32(graine, FLUX.GRAINES) tire une graine dérivée par
|
||||
// proposition, la i-ème au i-ème tirage. La proposition i porte l'identifiant
|
||||
// i et tire tout le reste, placements et mouvements, de
|
||||
// creerPcg32(graine dérivée, FLUX.RECHERCHE). Elle ne dépend donc que de la
|
||||
// configuration, de sa graine dérivée, du compte d'arrêt et de la longueur de
|
||||
// l'historique, trois réglages qu'elle porte : la proposition 2 d'une demande
|
||||
// de 3 est celle d'une demande de 5, quel que soit l'ordre dans lequel les
|
||||
// propositions s'achèvent, et regenerer en rend le plan à partir de ces trois
|
||||
// champs seuls (§ 8.9). Les deux passent par la même descente, proposer.
|
||||
//
|
||||
// Sièges. Chaque tour offre S = Σ c_t sièges, rangés table par table dans
|
||||
// l'ordre des tables. Les personnes que instance.fixe impose à un tour
|
||||
// occupent les premiers sièges de leur table, par index croissant, et n'en
|
||||
// bougent jamais. Les autres sièges sont mobiles : chacun porte une personne
|
||||
// libre à ce tour, ou une place fantôme, siège vide. Le placement initial
|
||||
// mélange les personnes libres de chaque tour par melanger, tour après tour,
|
||||
// et les assied dans les sièges mobiles pris dans l'ordre ; les sièges qui
|
||||
// restent sont des places fantômes. Sans place manquante, personne n'est en
|
||||
// réserve.
|
||||
//
|
||||
// Mouvement. Un tour tiré par borne(R), puis deux sièges mobiles de ce tour,
|
||||
// chacun par borne(m), m le nombre de ses sièges mobiles ; un tour de moins
|
||||
// de deux sièges mobiles ne tire rien de plus. Deux sièges de la même table,
|
||||
// ou deux places fantômes, font un mouvement évalué et rejeté, qui ne change
|
||||
// rien. Sinon les deux occupants s'échangent : un échange avec une place
|
||||
// fantôme déplace une personne vers le siège vide d'une autre table.
|
||||
//
|
||||
// Score. Un n-uplet de six entiers comparé dans l'ordre lexicographique, la
|
||||
// forme exacte d'une pondération strictement hiérarchique, sans poids ni
|
||||
// perte de précision :
|
||||
// 0. le plus grand écart au plafond a priori, plafond a priori − rencontres ;
|
||||
// 1. la somme des carrés de ces écarts ;
|
||||
// 2. l'excédent de collisions, si separerAppartenances ;
|
||||
// 3. les collisions cumulées, si separerAppartenances ;
|
||||
// 4. Σ_paires max(0, M − 1), M les rencontres de la paire, si
|
||||
// nouveauxVoisins, plus les retours choisis, si nouvelleTable ;
|
||||
// 5. Σ_p r(p), la redondance d'appartenance, si varierAppartenances.
|
||||
// Une composante désactivée vaut 0 ; aucune n'est négative, et le n-uplet
|
||||
// nul ne s'améliore plus.
|
||||
//
|
||||
// Incrémental. L'état tient à jour ce dont le score dépend : la matrice des
|
||||
// rencontres par paire ; rencontres(p) ; l'histogramme des écarts au plafond
|
||||
// a priori, d'où leur maximum, et la somme de leurs carrés ; les collisions
|
||||
// cumulées de même appartenance et leur excédent ; Σ_paires max(0, M − 1) ;
|
||||
// les visites par (personne, table), d'où les retours choisis ; par
|
||||
// personne, le nombre d'affiliés rencontrés de chaque appartenance, d'où
|
||||
// |F(p)|, A(p) et Σ r(p). Un échange ne touche que les paires des deux tables
|
||||
// concernées, au tour concerné : O(capacité). Ce score sert la boucle de
|
||||
// recherche et rien d'autre ; scoreComplet recompose le même n-uplet à partir
|
||||
// de la mesure qui fait foi, dont il lit chaque grandeur au lieu de la
|
||||
// refaire (§ 5.10, § 13.2).
|
||||
//
|
||||
// Acceptation tardive. L'historique H, de longueur L, part du score initial.
|
||||
// À l'itération i, le candidat est gardé s'il ne dépasse ni le score courant
|
||||
// ni H[i mod L], défait sinon ; puis H[i mod L] reçoit le score courant. La
|
||||
// meilleure affectation vue, dans l'ordre du score, est conservée. La
|
||||
// recherche s'arrête après arret mouvements évalués, ou dès que le score
|
||||
// courant est le n-uplet nul : sur un compte, jamais sur une durée (§ 5.10).
|
||||
//
|
||||
// Deux ordres d'acceptation. L'acceptation compare d'abord dans l'ordre du
|
||||
// score, rencontres en tête. Quand la meilleure affectation porte un écart
|
||||
// maximal nul, chacun atteint son plafond a priori : les deux premières
|
||||
// composantes sont à leur minimum prouvé (§ 5.5), et une meilleure
|
||||
// affectation ne peut plus différer que sur les suivantes. L'acceptation
|
||||
// compare alors les composantes 2, 3 et 4, celles des contraintes, avant les
|
||||
// deux premières ; l'historique garde son contenu. L'ordre des contraintes
|
||||
// laisse perdre une rencontre le temps de défaire une collision. L'ordre du
|
||||
// score le refuse dès que l'historique ne porte plus que des scores à écart
|
||||
// nul : collisions, voisins et tables n'y progressent plus que par les rares
|
||||
// échanges qui conservent toutes les rencontres, et s'arrêtent loin de leur
|
||||
// minimum. La meilleure affectation, jugée dans l'ordre du score, ne retient
|
||||
// que des affectations à écart nul. Une instance dont aucun plan n'annule
|
||||
// tous les écarts garde l'ordre du score de bout en bout.
|
||||
//
|
||||
// Relance. Dans l'ordre des contraintes, un score courant inchangé pendant
|
||||
// PATIENCE × L itérations fait relancer la descente : le reste du compte
|
||||
// sert une nouvelle descente, depuis un nouveau placement tiré du même
|
||||
// générateur, l'historique au score de ce placement ; la meilleure
|
||||
// affectation, et l'ordre des contraintes qu'elle fixe, sont conservés. Une
|
||||
// descente de cet ordre s'arrête parfois sur un minimum local des voisins et
|
||||
// des tables, des rencontres en deçà de leur plafond ; une descente neuve en
|
||||
// est une tentative indépendante. Dans l'ordre du score, la relance ne joue
|
||||
// pas : les échanges de même score y sont acceptés, un score courant
|
||||
// inchangé y marque aussi bien un plateau que la descente parcourt, et une
|
||||
// relance couperait ce parcours, d'autant plus souvent que L est court.
|
||||
import { FLUX, creerPcg32 } from '../demo/prng.js';
|
||||
import {
|
||||
LIBRE,
|
||||
RESERVE,
|
||||
SANS_GROUPE,
|
||||
nombrePlacesManquantes,
|
||||
normaliser,
|
||||
planDepuisIndex,
|
||||
} from './configuration.js';
|
||||
import { ErreurAnnulee, ErreurConfiguration } from './erreurs.js';
|
||||
import { mesurer } from './indicateurs.js';
|
||||
import { ecartsAuPlafondAPriori } from './manque.js';
|
||||
import { plafondsAPriori } from './plafond.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ReglagesGeneration
|
||||
* @property {number} graine entier 0 ≤ g < 2^32
|
||||
* @property {number} arret mouvements évalués par proposition, ≥ 1
|
||||
* @property {number} nombre propositions demandées, ≥ 1
|
||||
* @property {number} [historique=1000] longueur de l'historique d'acceptation
|
||||
*
|
||||
* @typedef {Object} Proposition
|
||||
* @property {number} id 1..nombre, place dans la suite des graines dérivées
|
||||
* @property {number} graine graine dérivée de cette proposition
|
||||
* @property {number} arret
|
||||
* @property {number} historique longueur de l'historique d'acceptation
|
||||
* @property {import('./types.js').Plan} plan
|
||||
*
|
||||
* @typedef {Object} EtatRecherche
|
||||
* Rendu par creerEtat ; les champs qui suivent se lisent, aucun ne s'écrit
|
||||
* hors de ce module. Un champ qui tient une grandeur que mesurer rend en
|
||||
* porte le nom.
|
||||
* @property {Int32Array} tableDe plan indexé courant, N × R
|
||||
* @property {Int32Array} rencontres |met(p)|, ordre canonique
|
||||
* @property {Int32Array} affilies |F(p)|
|
||||
* @property {Int32Array} appartenancesVues A(p)
|
||||
* @property {number} collisionsCumulees
|
||||
* @property {number} excedentCollisions collisions cumulées − paires distinctes
|
||||
* @property {number} totalRetoursChoisis
|
||||
* @property {number} totalRedondance Σ_p r(p)
|
||||
*
|
||||
* @typedef {{tour: number, a: number, b: number}} Mouvement
|
||||
* l'échange des occupants des sièges a et b au tour d'index tour
|
||||
*/
|
||||
|
||||
// Siège sans occupant : la place fantôme.
|
||||
const FANTOME = -1;
|
||||
const TAILLE_SCORE = 6;
|
||||
// Ordres de comparaison du n-uplet, par rang de composante : celui du score,
|
||||
// et celui des contraintes, qui passe les composantes 2, 3 et 4 devant les
|
||||
// deux premières.
|
||||
const ORDRE_SCORE = Object.freeze([0, 1, 2, 3, 4, 5]);
|
||||
const ORDRE_CONTRAINTES = Object.freeze([2, 3, 4, 0, 1, 5]);
|
||||
// Itérations entre deux appels de progression, et entre deux lectures du
|
||||
// signal.
|
||||
const CADENCE = 1_000;
|
||||
const HISTORIQUE_PAR_DEFAUT = 1_000;
|
||||
// Longueurs d'historique sans changement du score courant au-delà desquelles
|
||||
// une descente se relance, dans l'ordre des contraintes seulement.
|
||||
const PATIENCE = 10;
|
||||
const MOT_MAX = 0xffffffff;
|
||||
// Plus grand compte de rencontres d'une paire que tient un Uint16Array : une
|
||||
// paire se réunit au plus une fois par tour.
|
||||
const UINT16_MAX = 0xffff;
|
||||
|
||||
// Valeur citée dans un message : un nombre tel quel, toute autre valeur
|
||||
// suivie de son type.
|
||||
const decrire = (valeur) =>
|
||||
typeof valeur === 'number' ? String(valeur) : `${String(valeur)} (${typeof valeur})`;
|
||||
|
||||
// Lève une RangeError qui nomme le réglage quand la valeur n'est pas un
|
||||
// entier de min à max.
|
||||
function exigerReglage(nom, valeur, min, max) {
|
||||
if (!Number.isInteger(valeur) || valeur < min || valeur > max) {
|
||||
throw new RangeError(`${nom} : entier de ${min} à ${max} attendu, reçu ${decrire(valeur)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Lève une RangeError qui nomme le premier des trois réglages qu'une
|
||||
// proposition porte à sortir de son domaine, contrôlés dans cet ordre :
|
||||
// graine de 0 à 2^32 − 1, arret et historique ≥ 1. rechercher et regenerer
|
||||
// l'appellent tous deux : ce que l'un admet, l'autre l'admet, et regenerer
|
||||
// accepte les champs de toute proposition que rechercher rend.
|
||||
function exigerChampsProposition({ graine, arret, historique }) {
|
||||
exigerReglage('graine', graine, 0, MOT_MAX);
|
||||
exigerReglage('arret', arret, 1, Number.MAX_SAFE_INTEGER);
|
||||
exigerReglage('historique', historique, 1, Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
// Ce que toutes les propositions d'une instance partagent : la disposition
|
||||
// des sièges, les sièges fixés et les sièges mobiles de chaque tour, les
|
||||
// personnes libres de chaque tour par index croissant, le plafond a priori
|
||||
// de chacun et, par (personne, table), le seuil de visites au-delà duquel
|
||||
// une visite est un retour choisi. Lève PLACES_MANQUANTES quand les sièges
|
||||
// d'un tour ne suffisent pas à asseoir tout le monde (§ 5.9).
|
||||
function preparer(instance) {
|
||||
const placesManquantes = nombrePlacesManquantes(instance);
|
||||
if (placesManquantes > 0) throw new ErreurConfiguration('PLACES_MANQUANTES', { placesManquantes });
|
||||
const { N, T, R, capacite, fixe } = instance;
|
||||
|
||||
const debut = new Int32Array(T + 1);
|
||||
for (let t = 0; t < T; t += 1) debut[t + 1] = debut[t] + capacite[t];
|
||||
const S = debut[T];
|
||||
const tableDuSiege = new Int32Array(S);
|
||||
for (let t = 0; t < T; t += 1) tableDuSiege.fill(t, debut[t], debut[t + 1]);
|
||||
|
||||
// siegesFixes[r × S + s] : la personne fixée au siège s du tour r,
|
||||
// FANTOME pour un siège mobile. mobiles[r × S + i], i < nombreMobiles[r] :
|
||||
// le i-ème siège mobile du tour r, dans l'ordre des sièges.
|
||||
const siegesFixes = new Int32Array(R * S).fill(FANTOME);
|
||||
const mobiles = new Int32Array(R * S);
|
||||
const nombreMobiles = new Int32Array(R);
|
||||
const libres = [];
|
||||
const occupes = new Int32Array(T);
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
occupes.fill(0);
|
||||
const libresDuTour = [];
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
const t = fixe[p * R + r];
|
||||
if (t === LIBRE) {
|
||||
libresDuTour.push(p);
|
||||
} else {
|
||||
siegesFixes[r * S + debut[t] + occupes[t]] = p;
|
||||
occupes[t] += 1;
|
||||
}
|
||||
}
|
||||
libres.push(libresDuTour);
|
||||
let m = 0;
|
||||
for (let s = 0; s < S; s += 1) {
|
||||
if (siegesFixes[r * S + s] === FANTOME) {
|
||||
mobiles[r * S + m] = s;
|
||||
m += 1;
|
||||
}
|
||||
}
|
||||
nombreMobiles[r] = m;
|
||||
}
|
||||
|
||||
// Pour une table visitée m fois, dont f à un tour où la personne y est
|
||||
// fixée, les retours choisis valent m − 1 − max(0, f − 1) (indicateurs.js),
|
||||
// soit max(0, m − max(1, f)) : le seuil vaut max(1, f).
|
||||
const seuilRetour = new Int32Array(N * T);
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const t = fixe[p * R + r];
|
||||
if (t !== LIBRE) seuilRetour[p * T + t] += 1;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < seuilRetour.length; i += 1) {
|
||||
if (seuilRetour[i] === 0) seuilRetour[i] = 1;
|
||||
}
|
||||
|
||||
const aPriori = Int32Array.from(plafondsAPriori(instance));
|
||||
let plusGrand = 0;
|
||||
for (let p = 0; p < N; p += 1) plusGrand = Math.max(plusGrand, aPriori[p]);
|
||||
|
||||
return {
|
||||
instance,
|
||||
N,
|
||||
T,
|
||||
R,
|
||||
S,
|
||||
debut,
|
||||
tableDuSiege,
|
||||
siegesFixes,
|
||||
mobiles,
|
||||
nombreMobiles,
|
||||
libres,
|
||||
seuilRetour,
|
||||
aPriori,
|
||||
largeurHistogramme: plusGrand + 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Les écarts au plafond a priori restent dans 0..aPriori[p] : sur un plan
|
||||
// qui honore fixe et les capacités, rencontres ≤ plafond réalisé ≤ plafond a
|
||||
// priori (plafond.js). Une hausse des rencontres ne fait baisser l'écart que
|
||||
// d'un : quand le maximum perd son dernier porteur, e − 1 en a un.
|
||||
function changerRencontres(etat, p, sens) {
|
||||
const { rencontres, histogramme } = etat;
|
||||
const avant = etat.aPriori[p] - rencontres[p];
|
||||
const apres = avant - sens;
|
||||
rencontres[p] += sens;
|
||||
histogramme[avant] -= 1;
|
||||
histogramme[apres] += 1;
|
||||
etat.carresEcartsAuPlafondAPriori += apres * apres - avant * avant;
|
||||
if (apres > etat.ecartAuPlafondAPrioriMax) {
|
||||
etat.ecartAuPlafondAPrioriMax = apres;
|
||||
} else if (avant === etat.ecartAuPlafondAPrioriMax && histogramme[avant] === 0) {
|
||||
etat.ecartAuPlafondAPrioriMax = apres;
|
||||
}
|
||||
}
|
||||
|
||||
// p commence (sens = 1) ou cesse (sens = −1) de compter parmi ses
|
||||
// rencontres un affilié du groupe g : |F(p)| suit, A(p) quand c'est le
|
||||
// premier ou le dernier de ce groupe, et Σ r(p) = Σ (|F(p)| − A(p)) avec eux.
|
||||
function changerAffilie(etat, p, g, sens) {
|
||||
const i = p * etat.G + g;
|
||||
const avant = etat.parGroupe[i];
|
||||
etat.parGroupe[i] = avant + sens;
|
||||
etat.affilies[p] += sens;
|
||||
let vues = 0;
|
||||
if (avant === (sens > 0 ? 0 : 1)) {
|
||||
etat.appartenancesVues[p] += sens;
|
||||
vues = sens;
|
||||
}
|
||||
etat.totalRedondance += sens - vues;
|
||||
}
|
||||
|
||||
// Ajoute sens, 1 ou −1, aux rencontres de la paire (a, b), a ≠ b, et
|
||||
// propage : les collisions cumulées pour deux personnes de la même
|
||||
// appartenance. Quand la paire était réunie avant un ajout, ou le reste
|
||||
// après un retrait, la rencontre ajoutée ou retirée est une répétition :
|
||||
// elle compte dans Σ max(0, M − 1) et, entre collègues, dans l'excédent de
|
||||
// collisions, qui vaut Σ max(0, M − 1) sur les paires de même appartenance.
|
||||
// Sinon la paire devient réunie ou cesse de l'être : les rencontres et les
|
||||
// affiliés des deux suivent.
|
||||
function modifierPaire(etat, a, b, sens) {
|
||||
const k = a < b ? a * etat.N + b : b * etat.N + a;
|
||||
const avant = etat.paires[k];
|
||||
const apres = avant + sens;
|
||||
etat.paires[k] = apres;
|
||||
const ga = etat.groupe[a];
|
||||
const gb = etat.groupe[b];
|
||||
const memeGroupe = ga === gb && etat.porteAppartenance[a] === 1;
|
||||
if (memeGroupe) etat.collisionsCumulees += sens;
|
||||
if ((sens > 0 ? avant : apres) !== 0) {
|
||||
etat.excedentPaires += sens;
|
||||
if (memeGroupe) etat.excedentCollisions += sens;
|
||||
return;
|
||||
}
|
||||
changerRencontres(etat, a, sens);
|
||||
changerRencontres(etat, b, sens);
|
||||
if (etat.porteAppartenance[b] === 1) changerAffilie(etat, a, gb, sens);
|
||||
if (etat.porteAppartenance[a] === 1) changerAffilie(etat, b, ga, sens);
|
||||
}
|
||||
|
||||
// Ajoute sens aux visites de p à la table t, et ajuste les retours choisis.
|
||||
function changerVisite(etat, p, t, sens) {
|
||||
const i = p * etat.T + t;
|
||||
const avant = etat.visites[i];
|
||||
const apres = avant + sens;
|
||||
etat.visites[i] = apres;
|
||||
const seuil = etat.seuilRetour[i];
|
||||
etat.totalRetoursChoisis += Math.max(0, apres - seuil) - Math.max(0, avant - seuil);
|
||||
}
|
||||
|
||||
// Ajoute sens aux rencontres de p avec chaque occupant de la table t au tour
|
||||
// dont les sièges commencent à base, le siège exclu mis à part.
|
||||
function voisiner(etat, p, base, t, exclu, sens) {
|
||||
const { siege, debut } = etat;
|
||||
for (let s = debut[t], fin = debut[t + 1]; s < fin; s += 1) {
|
||||
if (s === exclu) continue;
|
||||
const q = siege[base + s];
|
||||
if (q !== FANTOME) modifierPaire(etat, p, q, sens);
|
||||
}
|
||||
}
|
||||
|
||||
// Échange les occupants des sièges a et b, de deux tables différentes, au
|
||||
// tour r. Chaque occupant quitte d'abord ses voisins, puis rejoint ceux de
|
||||
// l'autre table : l'échange est sa propre réciproque, et le refaire rend
|
||||
// l'état d'avant, compteurs compris.
|
||||
function echanger(etat, r, a, b) {
|
||||
const { siege, tableDe, tableDuSiege, R } = etat;
|
||||
const base = r * etat.S;
|
||||
const x = siege[base + a];
|
||||
const y = siege[base + b];
|
||||
const ta = tableDuSiege[a];
|
||||
const tb = tableDuSiege[b];
|
||||
if (x !== FANTOME) voisiner(etat, x, base, ta, a, -1);
|
||||
if (y !== FANTOME) voisiner(etat, y, base, tb, b, -1);
|
||||
siege[base + a] = y;
|
||||
siege[base + b] = x;
|
||||
if (x !== FANTOME) {
|
||||
tableDe[x * R + r] = tb;
|
||||
changerVisite(etat, x, ta, -1);
|
||||
changerVisite(etat, x, tb, 1);
|
||||
voisiner(etat, x, base, tb, b, 1);
|
||||
}
|
||||
if (y !== FANTOME) {
|
||||
tableDe[y * R + r] = ta;
|
||||
changerVisite(etat, y, tb, -1);
|
||||
changerVisite(etat, y, ta, 1);
|
||||
voisiner(etat, y, base, ta, a, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Placement initial d'une instance préparée : les personnes fixées à leurs
|
||||
// sièges, les libres de chaque tour mélangées puis assises dans l'ordre des
|
||||
// sièges mobiles. Les compteurs partent de zéro et suivent chaque paire et
|
||||
// chaque visite par les mêmes fonctions que les mouvements ; les écarts
|
||||
// partent du plafond a priori, aucune rencontre n'étant encore comptée.
|
||||
function etatInitial(structure, rng) {
|
||||
const { instance, N, T, R, S, mobiles, libres, aPriori, tableDuSiege, debut } = structure;
|
||||
const G = instance.groupes.length;
|
||||
const etat = {
|
||||
instance,
|
||||
contraintes: instance.contraintes,
|
||||
groupe: instance.groupe,
|
||||
// porteAppartenance[p] vaut 1 quand p porte une appartenance, 0 pour
|
||||
// SANS_GROUPE.
|
||||
// modifierPaire le lit au lieu de comparer un groupe à SANS_GROUPE : sous
|
||||
// le lanceur d'épreuves, chaque lecture d'une valeur importée passe par
|
||||
// un accesseur, que la boucle de recherche paierait à chaque paire.
|
||||
porteAppartenance: Uint8Array.from(instance.groupe, (g) => (g === SANS_GROUPE ? 0 : 1)),
|
||||
N,
|
||||
T,
|
||||
R,
|
||||
S,
|
||||
G,
|
||||
debut,
|
||||
tableDuSiege,
|
||||
mobiles,
|
||||
nombreMobiles: structure.nombreMobiles,
|
||||
seuilRetour: structure.seuilRetour,
|
||||
aPriori,
|
||||
siege: Int32Array.from(structure.siegesFixes),
|
||||
tableDe: new Int32Array(N * R),
|
||||
paires: R <= UINT16_MAX ? new Uint16Array(N * N) : new Uint32Array(N * N),
|
||||
rencontres: new Int32Array(N),
|
||||
affilies: new Int32Array(N),
|
||||
appartenancesVues: new Int32Array(N),
|
||||
parGroupe: new Int32Array(N * G),
|
||||
visites: new Int32Array(N * T),
|
||||
histogramme: new Int32Array(structure.largeurHistogramme),
|
||||
ecartAuPlafondAPrioriMax: 0,
|
||||
carresEcartsAuPlafondAPriori: 0,
|
||||
collisionsCumulees: 0,
|
||||
excedentCollisions: 0,
|
||||
excedentPaires: 0,
|
||||
totalRetoursChoisis: 0,
|
||||
totalRedondance: 0,
|
||||
};
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
etat.histogramme[aPriori[p]] += 1;
|
||||
etat.carresEcartsAuPlafondAPriori += aPriori[p] * aPriori[p];
|
||||
etat.ecartAuPlafondAPrioriMax = Math.max(etat.ecartAuPlafondAPrioriMax, aPriori[p]);
|
||||
}
|
||||
const { siege, tableDe } = etat;
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const melanges = rng.melanger(Int32Array.from(libres[r]));
|
||||
for (let i = 0; i < melanges.length; i += 1) siege[r * S + mobiles[r * S + i]] = melanges[i];
|
||||
}
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const base = r * S;
|
||||
for (let s = 0; s < S; s += 1) {
|
||||
const p = siege[base + s];
|
||||
if (p === FANTOME) continue;
|
||||
const t = tableDuSiege[s];
|
||||
tableDe[p * R + r] = t;
|
||||
changerVisite(etat, p, t, 1);
|
||||
for (let voisin = debut[t]; voisin < s; voisin += 1) {
|
||||
const q = siege[base + voisin];
|
||||
if (q !== FANTOME) modifierPaire(etat, p, q, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return etat;
|
||||
}
|
||||
|
||||
// Écrit dans sortie le n-uplet du score, à partir des grandeurs nommées de
|
||||
// q, et le rend. Une seule composition sert le score tenu à jour et le score
|
||||
// recalculé ; une grandeur que mesurer rend y porte son nom, et la
|
||||
// composition n'en refait aucune.
|
||||
function composer(contraintes, q, sortie) {
|
||||
const separer = contraintes.separerAppartenances;
|
||||
sortie[0] = q.ecartAuPlafondAPrioriMax;
|
||||
sortie[1] = q.carresEcartsAuPlafondAPriori;
|
||||
sortie[2] = separer ? q.excedentCollisions : 0;
|
||||
sortie[3] = separer ? q.collisionsCumulees : 0;
|
||||
sortie[4] = (contraintes.nouveauxVoisins ? q.excedentPaires : 0)
|
||||
+ (contraintes.nouvelleTable ? q.totalRetoursChoisis : 0);
|
||||
sortie[5] = contraintes.varierAppartenances ? q.totalRedondance : 0;
|
||||
return sortie;
|
||||
}
|
||||
|
||||
// Signe de la comparaison lexicographique des n-uplets a[ia …] et b[ib …],
|
||||
// composantes prises dans l'ordre que donne la liste ordre.
|
||||
function comparer(ordre, a, ia, b, ib) {
|
||||
for (let j = 0; j < TAILLE_SCORE; j += 1) {
|
||||
const k = ordre[j];
|
||||
const difference = a[ia + k] - b[ib + k];
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Ordre d'acceptation que fixe la meilleure affectation : celui des
|
||||
// contraintes dès que son écart maximal est nul (en-tête du module). Cet
|
||||
// écart ne fait que baisser d'une meilleure affectation à la suivante : une
|
||||
// fois nul, il le reste.
|
||||
const ordreAccepte = (meilleurScore) =>
|
||||
meilleurScore[0] === 0 ? ORDRE_CONTRAINTES : ORDRE_SCORE;
|
||||
|
||||
function estNul(score) {
|
||||
for (let k = 0; k < TAILLE_SCORE; k += 1) if (score[k] !== 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Lit signal.aborted une fois, et lève ErreurAnnulee s'il est vrai.
|
||||
function lireSignal(signal) {
|
||||
if (signal?.aborted) throw new ErreurAnnulee('recherche annulée');
|
||||
}
|
||||
|
||||
// Descente par acceptation tardive depuis un placement initial tiré de rng ;
|
||||
// rend la meilleure affectation vue, en plan indexé. L'ordre d'acceptation
|
||||
// suit la meilleure affectation (ordreAccepte). Dans l'ordre des
|
||||
// contraintes, un score courant inchangé pendant PATIENCE × historique
|
||||
// itérations fait relancer la descente (en-tête du module). La bascule vers
|
||||
// cet ordre suit une meilleure affectation, donc un changement du score
|
||||
// courant, qui remet immobile à zéro. fait compte les mouvements évalués
|
||||
// depuis le début de la génération, à partir de dejaFaits, ceux des
|
||||
// propositions précédentes : à chaque multiple de CADENCE,
|
||||
// progression(fait, total), puis une lecture du signal.
|
||||
function descendre(structure, rng, { arret, historique, dejaFaits, total, signal, progression }) {
|
||||
const { contraintes } = structure.instance;
|
||||
let etat = etatInitial(structure, rng);
|
||||
const courant = composer(contraintes, etat, new Float64Array(TAILLE_SCORE));
|
||||
const candidat = new Float64Array(TAILLE_SCORE);
|
||||
const meilleurScore = Float64Array.from(courant);
|
||||
const meilleurPlan = Int32Array.from(etat.tableDe);
|
||||
const memoire = new Float64Array(historique * TAILLE_SCORE);
|
||||
const remplirMemoire = () => {
|
||||
for (let v = 0; v < memoire.length; v += TAILLE_SCORE) memoire.set(courant, v);
|
||||
};
|
||||
// Garde l'état courant comme meilleure affectation s'il la dépasse dans
|
||||
// l'ordre du score, et rend l'ordre d'acceptation qui en découle.
|
||||
const retenir = (ordre) => {
|
||||
if (comparer(ORDRE_SCORE, courant, 0, meilleurScore, 0) >= 0) return ordre;
|
||||
meilleurScore.set(courant);
|
||||
meilleurPlan.set(etat.tableDe);
|
||||
return ordreAccepte(meilleurScore);
|
||||
};
|
||||
remplirMemoire();
|
||||
let ordre = ordreAccepte(meilleurScore);
|
||||
const patience = PATIENCE * historique;
|
||||
let immobile = 0;
|
||||
for (let i = 0; i < arret && !estNul(courant); i += 1) {
|
||||
if (immobile >= patience && ordre === ORDRE_CONTRAINTES) {
|
||||
etat = etatInitial(structure, rng);
|
||||
composer(contraintes, etat, courant);
|
||||
remplirMemoire();
|
||||
ordre = retenir(ordre);
|
||||
immobile = 0;
|
||||
}
|
||||
const mouvement = proposerEtAppliquer(etat, rng);
|
||||
const v = (i % historique) * TAILLE_SCORE;
|
||||
let change = false;
|
||||
if (mouvement !== null) {
|
||||
composer(contraintes, etat, candidat);
|
||||
if (comparer(ordre, candidat, 0, courant, 0) <= 0
|
||||
|| comparer(ordre, candidat, 0, memoire, v) <= 0) {
|
||||
change = comparer(ORDRE_SCORE, candidat, 0, courant, 0) !== 0;
|
||||
courant.set(candidat);
|
||||
ordre = retenir(ordre);
|
||||
} else {
|
||||
defaire(etat, mouvement);
|
||||
}
|
||||
}
|
||||
memoire.set(courant, v);
|
||||
immobile = change ? 0 : immobile + 1;
|
||||
const fait = dejaFaits + i + 1;
|
||||
if (fait % CADENCE === 0) {
|
||||
progression?.(fait, total);
|
||||
lireSignal(signal);
|
||||
}
|
||||
}
|
||||
return meilleurPlan;
|
||||
}
|
||||
|
||||
// Le plan de la proposition de graine dérivée graineDerivee : la descente
|
||||
// depuis creerPcg32(graineDerivee, FLUX.RECHERCHE), rendue par identifiants.
|
||||
// rechercher et regenerer l'appellent tous deux. À instance égale, le plan ne
|
||||
// dépend que de la graine dérivée, d'options.arret et d'options.historique :
|
||||
// les autres options ne servent qu'à rapporter l'avancement et à lire le
|
||||
// signal.
|
||||
function proposer(instance, structure, graineDerivee, options) {
|
||||
const rng = creerPcg32(graineDerivee, FLUX.RECHERCHE);
|
||||
return planDepuisIndex(instance, descendre(structure, rng, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Propositions de placement d'une configuration (§ 5.7) : nombre
|
||||
* propositions, d'identifiants 1 à nombre, chacune la meilleure affectation
|
||||
* vue en arret mouvements évalués, relances comprises (en-tête du module).
|
||||
* Chacune porte sa graine dérivée, arret et historique, défaut résolu : les
|
||||
* trois décident de son plan, que regenerer rend à partir d'eux. À
|
||||
* configuration et réglages égaux, le résultat est identique. Ne modifie pas
|
||||
* la configuration.
|
||||
*
|
||||
* Toutes les 1 000 itérations, comptées sur la génération entière,
|
||||
* progression(fait, total) reçoit le rang du mouvement en cours parmi
|
||||
* total = nombre × arret ; une proposition arrêtée tôt sur un score nul
|
||||
* laisse la suivante partir de son propre rang, (id − 1) × arret. Le signal
|
||||
* se lit à chacun de ces instants et avant chaque proposition : vrai, il
|
||||
* fait lever ErreurAnnulee, et aucune proposition n'est rendue (§ 5.10).
|
||||
*
|
||||
* Lève RangeError quand un réglage n'est pas un entier de son domaine —
|
||||
* graine de 0 à 2^32 − 1, arret, nombre et historique ≥ 1 ;
|
||||
* ErreurConfiguration de normaliser ; ErreurConfiguration
|
||||
* ('PLACES_MANQUANTES', { placesManquantes }) quand les tables n'offrent pas
|
||||
* assez de sièges, placesManquantes étant le nombre de places qui manquent à
|
||||
* chaque tour, celui que rend nombrePlacesManquantes.
|
||||
*
|
||||
* @param {import('./types.js').Configuration} configuration
|
||||
* @param {ReglagesGeneration} reglages
|
||||
* @param {{signal?: {aborted: boolean}, progression?: function(number, number): void}} [options]
|
||||
* @returns {Proposition[]}
|
||||
*/
|
||||
export function rechercher(configuration, reglages, { signal, progression } = {}) {
|
||||
const { graine, arret, nombre, historique = HISTORIQUE_PAR_DEFAUT } = reglages;
|
||||
exigerChampsProposition({ graine, arret, historique });
|
||||
exigerReglage('nombre', nombre, 1, Number.MAX_SAFE_INTEGER);
|
||||
const instance = normaliser(configuration);
|
||||
const structure = preparer(instance);
|
||||
const graines = creerPcg32(graine, FLUX.GRAINES);
|
||||
const total = nombre * arret;
|
||||
const propositions = [];
|
||||
for (let id = 1; id <= nombre; id += 1) {
|
||||
lireSignal(signal);
|
||||
const graineDerivee = graines.suivant();
|
||||
const plan = proposer(instance, structure, graineDerivee, {
|
||||
arret,
|
||||
historique,
|
||||
dejaFaits: (id - 1) * arret,
|
||||
total,
|
||||
signal,
|
||||
progression,
|
||||
});
|
||||
propositions.push({ id, graine: graineDerivee, arret, historique, plan });
|
||||
}
|
||||
return propositions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le plan d'une proposition, régénéré à partir de ses seuls champs (§ 5.7,
|
||||
* § 8.9) : la descente part de proposition.graine, la graine dérivée qu'elle
|
||||
* porte, et compte proposition.arret mouvements sous un historique de
|
||||
* proposition.historique cases. Ces trois champs décident du plan : à
|
||||
* configuration égale, regenerer rend le plan que rechercher a rendu pour
|
||||
* cette proposition, quel que soit son rang dans sa génération. La descente
|
||||
* elle-même fait aussi partie du résultat, ordre des tirages, PATIENCE et
|
||||
* ordres d'acceptation : une proposition rendue par une autre version de ce
|
||||
* module n'est pas garantie régénérable (§ 8.9). Aucun autre champ n'est lu,
|
||||
* ni id ni plan. Ne modifie ni la configuration ni la proposition.
|
||||
*
|
||||
* Lève RangeError quand l'un des trois champs n'est pas un entier de son
|
||||
* domaine, graine de 0 à 2^32 − 1, arret et historique ≥ 1 : un champ absent
|
||||
* ne reçoit pas la valeur par défaut de rechercher, qui ferait régénérer une
|
||||
* autre proposition sans le dire. Lève ce que rechercher lève pour la
|
||||
* configuration : ErreurConfiguration de normaliser, PLACES_MANQUANTES.
|
||||
*
|
||||
* @param {import('./types.js').Configuration} configuration
|
||||
* @param {{graine: number, arret: number, historique: number}} proposition
|
||||
* @returns {import('./types.js').Plan}
|
||||
*/
|
||||
export function regenerer(configuration, { graine, arret, historique }) {
|
||||
exigerChampsProposition({ graine, arret, historique });
|
||||
const instance = normaliser(configuration);
|
||||
return proposer(instance, preparer(instance), graine, {
|
||||
arret,
|
||||
historique,
|
||||
dejaFaits: 0,
|
||||
total: arret,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* État de recherche au placement initial (§ 5.10) : fixés à leurs tables,
|
||||
* libres mélangés par rng.melanger, tour après tour, puis assis table après
|
||||
* table dans l'ordre des tables. Lève ErreurConfiguration('PLACES_MANQUANTES',
|
||||
* { placesManquantes }) comme rechercher.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @param {ReturnType<typeof creerPcg32>} rng
|
||||
* @returns {EtatRecherche}
|
||||
*/
|
||||
export function creerEtat(instance, rng) {
|
||||
return etatInitial(preparer(instance), rng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le n-uplet du score, tel que l'état le tient à jour.
|
||||
*
|
||||
* @param {EtatRecherche} etat
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function scoreIncremental(etat) {
|
||||
return composer(etat.contraintes, etat, new Array(TAILLE_SCORE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le même n-uplet, recalculé de zéro sur etat.tableDe à partir de la mesure
|
||||
* qui fait foi, dont chaque grandeur se lit au lieu de se refaire : les
|
||||
* écarts au plafond a priori par ecartsAuPlafondAPriori (manque.js), sur les
|
||||
* plafonds de plafondsAPriori ; l'excédent de collisions, les collisions
|
||||
* cumulées, les retours choisis et Σ r(p) par mesurer. Une redéfinition de
|
||||
* l'une d'elles dans son module change donc ce score, et son accord avec le
|
||||
* score tenu à jour tombe.
|
||||
*
|
||||
* Seule Σ_paires max(0, M − 1), que la mesure ne rend pas, se calcule ici :
|
||||
* elle vaut Σ_paires M − Σ_paires min(M, 1). Le premier terme compte les
|
||||
* paires réunies à chaque table de chaque tour, o(o − 1)/2 pour o
|
||||
* occupants ; le second, les paires réunies au moins une fois,
|
||||
* Σ_p rencontres(p) / 2.
|
||||
*
|
||||
* @param {EtatRecherche} etat
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function scoreComplet(etat) {
|
||||
const { instance, tableDe } = etat;
|
||||
const { N, T, R } = instance;
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
let ecartAuPlafondAPrioriMax = 0;
|
||||
let carresEcartsAuPlafondAPriori = 0;
|
||||
for (const ecart of ecartsAuPlafondAPriori(mesures, plafondsAPriori(instance))) {
|
||||
ecartAuPlafondAPrioriMax = Math.max(ecartAuPlafondAPrioriMax, ecart);
|
||||
carresEcartsAuPlafondAPriori += ecart * ecart;
|
||||
}
|
||||
const occupation = new Int32Array(T * R);
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const t = tableDe[p * R + r];
|
||||
if (t !== RESERVE) occupation[t * R + r] += 1;
|
||||
}
|
||||
}
|
||||
let reunions = 0;
|
||||
for (const o of occupation) reunions += (o * (o - 1)) / 2;
|
||||
let sommeRencontres = 0;
|
||||
for (const rencontres of mesures.rencontres) sommeRencontres += rencontres;
|
||||
const grandeurs = {
|
||||
ecartAuPlafondAPrioriMax,
|
||||
carresEcartsAuPlafondAPriori,
|
||||
excedentCollisions: mesures.excedentCollisions,
|
||||
collisionsCumulees: mesures.collisionsCumulees,
|
||||
excedentPaires: reunions - sommeRencontres / 2,
|
||||
totalRetoursChoisis: mesures.totalRetoursChoisis,
|
||||
totalRedondance: mesures.totalRedondance,
|
||||
};
|
||||
return composer(instance.contraintes, grandeurs, new Array(TAILLE_SCORE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Un mouvement (en-tête du module), appliqué à l'état. Rend de quoi le
|
||||
* défaire, ou null pour un mouvement évalué et rejeté, qui n'a rien changé.
|
||||
*
|
||||
* @param {EtatRecherche} etat
|
||||
* @param {ReturnType<typeof creerPcg32>} rng
|
||||
* @returns {Mouvement|null}
|
||||
*/
|
||||
export function proposerEtAppliquer(etat, rng) {
|
||||
const { R, S, mobiles, nombreMobiles, tableDuSiege, siege } = etat;
|
||||
const r = rng.borne(R);
|
||||
const m = nombreMobiles[r];
|
||||
if (m < 2) return null;
|
||||
const base = r * S;
|
||||
const a = mobiles[base + rng.borne(m)];
|
||||
const b = mobiles[base + rng.borne(m)];
|
||||
if (tableDuSiege[a] === tableDuSiege[b]) return null;
|
||||
if (siege[base + a] === FANTOME && siege[base + b] === FANTOME) return null;
|
||||
echanger(etat, r, a, b);
|
||||
return { tour: r, a, b };
|
||||
}
|
||||
|
||||
/**
|
||||
* Défait mouvement, le dernier appliqué à l'état : l'état revient à
|
||||
* l'identique, compteurs compris. Une suite de mouvements se défait à
|
||||
* rebours, le dernier d'abord. Un mouvement null ne fait rien.
|
||||
*
|
||||
* @param {EtatRecherche} etat
|
||||
* @param {Mouvement|null} mouvement
|
||||
*/
|
||||
export function defaire(etat, mouvement) {
|
||||
if (mouvement !== null) echanger(etat, mouvement.tour, mouvement.a, mouvement.b);
|
||||
}
|
||||
156
src/moteur/recherche.long.test.js
Normal file
156
src/moteur/recherche.long.test.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves lourdes de la recherche (§ 5.10, § 14.10, § 15.3). Sur chacune
|
||||
// des quatre démonstrations, une suite déterministe de mouvements, acceptés
|
||||
// ou défaits, confronte l'état tenu à jour au recalcul complet à 1, 10, 100,
|
||||
// 1 000 et 10 000 mouvements appliqués, et non au seul terme : une dérive qui
|
||||
// se compense passe inaperçue à la fin, et le palier où un désaccord affleure
|
||||
// est le chiffre que l'épreuve rend. Le relevé liste chaque désaccord, palier
|
||||
// et grandeur, au lieu de s'arrêter au premier. La petite démonstration et sa
|
||||
// variante conflit atteignent ensuite leur optimum sur vingt graines, trois
|
||||
// propositions chacune ; une recherche longue sur la grande démonstration
|
||||
// rend des plans que jugent verifierInvariants et verifierIndicateurs.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CATALOGUE } from '../demo/catalogue.js';
|
||||
import { FLUX, creerPcg32 } from '../demo/prng.js';
|
||||
import { indexerPlan, normaliser } from './configuration.js';
|
||||
import { mesurer } from './indicateurs.js';
|
||||
import { plafondsAPriori, plafondsRealises } from './plafond.js';
|
||||
import {
|
||||
creerEtat,
|
||||
defaire,
|
||||
proposerEtAppliquer,
|
||||
rechercher,
|
||||
scoreComplet,
|
||||
scoreIncremental,
|
||||
} from './recherche.js';
|
||||
import { verifierIndicateurs, verifierInvariants } from './verification.js';
|
||||
|
||||
const PALIERS = [1, 10, 100, 1_000, 10_000];
|
||||
// Graine des suites de mouvements, écrite ici.
|
||||
const GRAINE = 271_828;
|
||||
// Un mouvement appliqué sur trois est défait : la suite passe par les deux
|
||||
// chemins, l'acceptation et le retour en arrière.
|
||||
const DEFAIT_TOUS_LES = 3;
|
||||
// Propositions de mouvement au-delà desquelles une suite renonce : une
|
||||
// instance où aucun mouvement ne s'applique n'atteindrait jamais le dernier
|
||||
// palier.
|
||||
const ESSAIS_MAX = 100 * PALIERS.at(-1);
|
||||
|
||||
// Vrai quand deux listes de nombres ont la même longueur et les mêmes
|
||||
// valeurs, rang par rang, chacune un nombre fini : deux champs absents, deux
|
||||
// undefined, ne s'accordent pas.
|
||||
const egales = (a, b) =>
|
||||
a.length === b.length && a.every((valeur, i) => Number.isFinite(valeur) && valeur === b[i]);
|
||||
|
||||
// Ajoute à releve un libellé « palier : grandeur » pour chaque grandeur où
|
||||
// l'état tenu à jour diffère du recalcul complet.
|
||||
function relever(etat, instance, palier, releve) {
|
||||
const mesures = mesurer(instance, etat.tableDe);
|
||||
const confronter = (grandeur, tenue, recalculee) => {
|
||||
if (!egales(tenue, recalculee)) releve.push(`${palier} : ${grandeur}`);
|
||||
};
|
||||
confronter('score', scoreIncremental(etat), scoreComplet(etat));
|
||||
confronter('rencontres', Array.from(etat.rencontres), mesures.rencontres);
|
||||
confronter('affilies', Array.from(etat.affilies), mesures.affilies);
|
||||
confronter('appartenancesVues', Array.from(etat.appartenancesVues), mesures.appartenancesVues);
|
||||
confronter('collisionsCumulees', [etat.collisionsCumulees], [mesures.collisionsCumulees]);
|
||||
confronter('excedentCollisions', [etat.excedentCollisions], [mesures.excedentCollisions]);
|
||||
confronter('totalRetoursChoisis', [etat.totalRetoursChoisis], [mesures.totalRetoursChoisis]);
|
||||
confronter('totalRedondance', [etat.totalRedondance], [mesures.totalRedondance]);
|
||||
}
|
||||
|
||||
describe('recherche : incrémental contre recalcul complet (§ 5.10, § 14.10)', () => {
|
||||
test('le catalogue fournit les quatre démonstrations éprouvées ci-dessous', () => {
|
||||
assert.equal(CATALOGUE.length, 4);
|
||||
});
|
||||
|
||||
for (const { cle, construire } of CATALOGUE) {
|
||||
test(`${cle} : accord à 1, 10, 100, 1 000 et 10 000 mouvements`, () => {
|
||||
const instance = normaliser(construire());
|
||||
const rng = creerPcg32(GRAINE, FLUX.RECHERCHE);
|
||||
const etat = creerEtat(instance, rng);
|
||||
const releve = [];
|
||||
relever(etat, instance, 0, releve);
|
||||
let appliques = 0;
|
||||
let defaits = 0;
|
||||
let palier = 0;
|
||||
for (let essai = 0; palier < PALIERS.length && essai < ESSAIS_MAX; essai += 1) {
|
||||
const mouvement = proposerEtAppliquer(etat, rng);
|
||||
if (mouvement === null) continue;
|
||||
appliques += 1;
|
||||
if (appliques % DEFAIT_TOUS_LES === 0) {
|
||||
defaire(etat, mouvement);
|
||||
defaits += 1;
|
||||
}
|
||||
if (appliques === PALIERS[palier]) {
|
||||
relever(etat, instance, appliques, releve);
|
||||
palier += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(appliques, PALIERS.at(-1), `${cle} : mouvements appliqués`);
|
||||
assert.equal(defaits, Math.floor(PALIERS.at(-1) / DEFAIT_TOUS_LES));
|
||||
assert.deepEqual(releve, [], cle);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Graines 0 à GRAINES − 1, trois propositions chacune : l'optimum se juge sur
|
||||
// une suite de graines, et non sur une seule, qu'un réglage de l'algorithme
|
||||
// pourrait servir par hasard.
|
||||
const GRAINES = 20;
|
||||
|
||||
describe('recherche : la petite démonstration et sa variante, sur vingt graines (§ 15.3)', () => {
|
||||
// Plancher de collisions cumulées : aucune dans la petite démonstration ;
|
||||
// une par tour dans la variante, dont le groupe de cinq ne se répartit pas
|
||||
// sur quatre tables, chacune d'une paire différente au mieux.
|
||||
for (const [cle, plancher] of [['petite', 0], ['petite-conflit', 4]]) {
|
||||
test(`${cle} : chaque proposition rencontre 8 pour les douze, sans répétition, ${plancher} collisions sur ${plancher} paires`, () => {
|
||||
const configuration = CATALOGUE.find((entree) => entree.cle === cle).construire();
|
||||
const instance = normaliser(configuration);
|
||||
const manquees = [];
|
||||
let examinees = 0;
|
||||
for (let graine = 0; graine < GRAINES; graine += 1) {
|
||||
for (const { id, plan } of rechercher(configuration, { graine, arret: 200_000, nombre: 3 })) {
|
||||
examinees += 1;
|
||||
const m = mesurer(instance, indexerPlan(instance, plan));
|
||||
const optimale = m.rencontres.every((rencontres) => rencontres === 8)
|
||||
&& m.maxRencontresPaire === 1
|
||||
&& m.collisionsCumulees === plancher
|
||||
&& m.pairesDistinctes === plancher;
|
||||
if (!optimale) {
|
||||
manquees.push(
|
||||
`graine ${graine}, proposition ${id} : minimum ${m.aggRencontres.tous.min}, `
|
||||
+ `collisions ${m.collisionsCumulees} sur ${m.pairesDistinctes} paires, `
|
||||
+ `paire la plus revue ${m.maxRencontresPaire} fois`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(examinees, 3 * GRAINES);
|
||||
assert.deepEqual(manquees, []);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('recherche : recherche longue sur la grande démonstration (§ 14.12)', () => {
|
||||
test('arret 200 000 : plans valides, et rencontres ≤ plafond réalisé ≤ plafond a priori ≤ N − 1', () => {
|
||||
const configuration = CATALOGUE.find((entree) => entree.cle === 'grande').construire();
|
||||
const instance = normaliser(configuration);
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
const propositions = rechercher(configuration, { graine: 31, arret: 200_000, nombre: 2 });
|
||||
assert.equal(propositions.length, 2);
|
||||
for (const { id, plan } of propositions) {
|
||||
assert.deepEqual(verifierInvariants(instance, plan), [], `proposition ${id}`);
|
||||
const tableDe = indexerPlan(instance, plan);
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
assert.deepEqual(
|
||||
verifierIndicateurs(mesures, aPriori, plafondsRealises(instance, tableDe)),
|
||||
[],
|
||||
`proposition ${id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
931
src/moteur/recherche.test.js
Normal file
931
src/moteur/recherche.test.js
Normal file
|
|
@ -0,0 +1,931 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves de la recherche (§ 5.2, § 5.7, § 5.9, § 5.10, § 14.10) : à graine
|
||||
// et entrée égales, propositions identiques ; l'identifiant d'une proposition
|
||||
// est son rang dans la suite des graines dérivées, et son plan sort de sa
|
||||
// graine dérivée, de son compte d'arrêt et de son historique, trois champs à
|
||||
// partir desquels regenerer le rend ; chaque plan rendu tient les invariants
|
||||
// et les réservations, et l'appartenance d'un ancré y pèse comme une autre ;
|
||||
// une salle trop petite est refusée avec le nombre de places manquantes ;
|
||||
// l'avancement se rapporte toutes les 1 000 itérations, une descente
|
||||
// s'arrête sur un score nul, une annulation ne rend rien ; le placement
|
||||
// initial égale celui que décrit le paragraphe « Sièges » de l'en-tête de
|
||||
// recherche.js, récrit ici à la lettre ; un échange avec une place fantôme
|
||||
// fait passer une personne à une autre table, comme le dit son paragraphe
|
||||
// « Mouvement » ; la descente égale l'acceptation tardive de son paragraphe
|
||||
// « Acceptation tardive », récrite de même, là où ni bascule ni relance ne
|
||||
// jouent, et sinon augmentée de la bascule et de la relance de son
|
||||
// paragraphe « Deux ordres d'acceptation » ; le score tenu à jour et le score
|
||||
// recalculé égalent un n-uplet recalculé ici par énumération des paires ; la
|
||||
// petite démonstration atteint son plan parfait sans cas particulier. Un plan
|
||||
// rendu se juge par verifierInvariants et se mesure par mesurer, deux modules
|
||||
// distincts du module éprouvé.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CATALOGUE } from '../demo/catalogue.js';
|
||||
import { FLUX, creerPcg32 } from '../demo/prng.js';
|
||||
import {
|
||||
LIBRE,
|
||||
RESERVE,
|
||||
SANS_GROUPE,
|
||||
indexerPlan,
|
||||
normaliser,
|
||||
planDepuisIndex,
|
||||
} from './configuration.js';
|
||||
import { ErreurAnnulee } from './erreurs.js';
|
||||
import { mesurer } from './indicateurs.js';
|
||||
import { plafondsAPriori } from './plafond.js';
|
||||
import {
|
||||
creerEtat,
|
||||
defaire,
|
||||
proposerEtAppliquer,
|
||||
rechercher,
|
||||
regenerer,
|
||||
scoreComplet,
|
||||
scoreIncremental,
|
||||
} from './recherche.js';
|
||||
import { verifierInvariants } from './verification.js';
|
||||
|
||||
const SANS_CONTRAINTE = {
|
||||
separerAppartenances: false,
|
||||
nouveauxVoisins: false,
|
||||
nouvelleTable: false,
|
||||
varierAppartenances: false,
|
||||
};
|
||||
const TOUTES_CONTRAINTES = {
|
||||
separerAppartenances: true,
|
||||
nouveauxVoisins: true,
|
||||
nouvelleTable: true,
|
||||
varierAppartenances: true,
|
||||
};
|
||||
const NOMS_CONTRAINTES = [
|
||||
'separerAppartenances',
|
||||
'nouveauxVoisins',
|
||||
'nouvelleTable',
|
||||
'varierAppartenances',
|
||||
];
|
||||
// Longueur d'historique que rechercher prend quand les réglages n'en donnent
|
||||
// pas (plan, ReglagesGeneration).
|
||||
const HISTORIQUE_PAR_DEFAUT = 1_000;
|
||||
|
||||
// Configuration neuve d'une démonstration du catalogue.
|
||||
const demo = (cle) => CATALOGUE.find((entree) => entree.cle === cle).construire();
|
||||
|
||||
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
|
||||
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
|
||||
|
||||
// Configuration dont le participant d'id i + 1 porte appartenances[i] ; les
|
||||
// tables reçoivent leurs numéros dans l'ordre de la liste ; un id cité dans
|
||||
// exclus est exclu.
|
||||
function configurationDe({
|
||||
appartenances,
|
||||
tables,
|
||||
tours,
|
||||
reservations = [],
|
||||
contraintes = SANS_CONTRAINTE,
|
||||
exclus = [],
|
||||
}) {
|
||||
return {
|
||||
participants: appartenances.map((appartenance, i) => ({
|
||||
id: i + 1,
|
||||
nom: `P${i + 1}`,
|
||||
appartenance,
|
||||
exclu: exclus.includes(i + 1),
|
||||
})),
|
||||
tables: tables.map(({ id, capacite }, i) => ({ id, numero: i + 1, capacite })),
|
||||
tours,
|
||||
reservations,
|
||||
contraintes: { ...contraintes },
|
||||
};
|
||||
}
|
||||
|
||||
// Trois tables, d'ids 10, 20 et 30 et de 4, 3 et 3 sièges, pour neuf
|
||||
// participants sur trois tours : une place reste vide à chaque tour. Le
|
||||
// participant 1 est ancré à la table 20 ; le 2 n'est réservé qu'au tour 2, à
|
||||
// la table 10 ; le 3 l'est aux tours 1 et 3, à la table 30 ; le 4 au tour 1
|
||||
// à la table 10 et au tour 3 à la table 20. Huit couples (réservation, tour).
|
||||
const salleReservee = () =>
|
||||
configurationDe({
|
||||
appartenances: ['A', 'B', 'A', null, 'B', 'A', null, 'B', null],
|
||||
tables: [
|
||||
{ id: 10, capacite: 4 },
|
||||
{ id: 20, capacite: 3 },
|
||||
{ id: 30, capacite: 3 },
|
||||
],
|
||||
tours: 3,
|
||||
reservations: [
|
||||
tous(1, 20),
|
||||
auTour(2, 10, 2),
|
||||
auTour(3, 30, 1),
|
||||
auTour(3, 30, 3),
|
||||
auTour(4, 10, 1),
|
||||
auTour(4, 20, 3),
|
||||
],
|
||||
contraintes: TOUTES_CONTRAINTES,
|
||||
});
|
||||
|
||||
// Réservations qu'un plan n'honore pas, chacune « participant@table, tour
|
||||
// n », et le nombre de couples (réservation, tour) examinés.
|
||||
function reservationsRompues(configuration, plan) {
|
||||
const rompues = [];
|
||||
let examines = 0;
|
||||
for (const { participant, table, portee, tour } of configuration.reservations) {
|
||||
const numeros = portee === 'tous' ? plan.tours.map((_, r) => r + 1) : [tour];
|
||||
for (const numero of numeros) {
|
||||
examines += 1;
|
||||
const liste = plan.tours[numero - 1][plan.tables.indexOf(table)];
|
||||
if (!liste.includes(participant)) rompues.push(`${participant}@${table}, tour ${numero}`);
|
||||
}
|
||||
}
|
||||
return { rompues, examines };
|
||||
}
|
||||
|
||||
// Six participants sans appartenance, trois tables de 2, un tour, aucune
|
||||
// contrainte : chacun rencontre son seul voisin, qui est son plafond a
|
||||
// priori, quel que soit le placement. Le score est nul dès le placement
|
||||
// initial, et chaque descente rend ce placement.
|
||||
const troisTablesDeDeux = () =>
|
||||
configurationDe({
|
||||
appartenances: Array(6).fill(null),
|
||||
tables: [
|
||||
{ id: 1, capacite: 2 },
|
||||
{ id: 2, capacite: 2 },
|
||||
{ id: 3, capacite: 2 },
|
||||
],
|
||||
tours: 1,
|
||||
});
|
||||
|
||||
// Placement initial du paragraphe « Sièges » de recherche.js, récrit à la
|
||||
// lettre : tour après
|
||||
// tour, les fixés à leur table ; les autres, par index croissant, mélangés
|
||||
// par rng.melanger, puis assis table après table dans l'ordre des tables,
|
||||
// aux places que les fixés laissent libres. Rend le plan indexé.
|
||||
function placementLitteral(instance, rng) {
|
||||
const { N, R, capacite, fixe } = instance;
|
||||
const tableDe = new Int32Array(N * R);
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
const restantes = Array.from(capacite);
|
||||
const libres = [];
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
const t = fixe[p * R + r];
|
||||
if (t === LIBRE) {
|
||||
libres.push(p);
|
||||
} else {
|
||||
tableDe[p * R + r] = t;
|
||||
restantes[t] -= 1;
|
||||
}
|
||||
}
|
||||
let t = 0;
|
||||
for (const p of rng.melanger(libres)) {
|
||||
while (restantes[t] === 0) t += 1;
|
||||
tableDe[p * R + r] = t;
|
||||
restantes[t] -= 1;
|
||||
}
|
||||
}
|
||||
return tableDe;
|
||||
}
|
||||
|
||||
// Le n-uplet du paragraphe « Score » de recherche.js, recalculé par
|
||||
// énumération directe des
|
||||
// paires du plan indexé, sans mesurer ni le module éprouvé ; les plafonds a
|
||||
// priori sont la seule grandeur reçue. Rend aussi les sept grandeurs brutes,
|
||||
// contraintes ignorées : écart maximal au plafond a priori, somme des carrés
|
||||
// des écarts, excédent de collisions, collisions cumulées, Σ_paires
|
||||
// max(0, M − 1), retours choisis, Σ_p r(p).
|
||||
function scoreOracle(instance, tableDe, aPriori) {
|
||||
const { N, T, R, groupe, fixe, contraintes } = instance;
|
||||
// M[a × N + b], a < b : le nombre de tours où a et b partagent une table.
|
||||
const M = new Int32Array(N * N);
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
for (let a = 0; a < N; a += 1) {
|
||||
for (let b = a + 1; b < N; b += 1) {
|
||||
const t = tableDe[a * R + r];
|
||||
if (t !== RESERVE && t === tableDe[b * R + r]) M[a * N + b] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const rencontres = Array(N).fill(0);
|
||||
const affilies = Array(N).fill(0);
|
||||
const appartenances = Array.from({ length: N }, () => new Set());
|
||||
let cumulees = 0;
|
||||
let distinctes = 0;
|
||||
let excedentPaires = 0;
|
||||
for (let a = 0; a < N; a += 1) {
|
||||
for (let b = a + 1; b < N; b += 1) {
|
||||
const m = M[a * N + b];
|
||||
if (m === 0) continue;
|
||||
excedentPaires += m - 1;
|
||||
for (const [p, q] of [[a, b], [b, a]]) {
|
||||
rencontres[p] += 1;
|
||||
if (groupe[q] !== SANS_GROUPE) {
|
||||
affilies[p] += 1;
|
||||
appartenances[p].add(groupe[q]);
|
||||
}
|
||||
}
|
||||
if (groupe[a] !== SANS_GROUPE && groupe[a] === groupe[b]) {
|
||||
cumulees += m;
|
||||
distinctes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Une table visitée m fois, dont f à un tour où la personne y est
|
||||
// réservée, compte m − 1 − max(0, f − 1) retours choisis (§ 5.4).
|
||||
let retours = 0;
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
for (let t = 0; t < T; t += 1) {
|
||||
let visites = 0;
|
||||
let reservees = 0;
|
||||
for (let r = 0; r < R; r += 1) {
|
||||
if (tableDe[p * R + r] !== t) continue;
|
||||
visites += 1;
|
||||
if (fixe[p * R + r] === t) reservees += 1;
|
||||
}
|
||||
if (visites > 0) retours += visites - 1 - Math.max(0, reservees - 1);
|
||||
}
|
||||
}
|
||||
const ecarts = aPriori.map((plafond, p) => plafond - rencontres[p]);
|
||||
const brutes = [
|
||||
Math.max(...ecarts),
|
||||
ecarts.reduce((somme, ecart) => somme + ecart * ecart, 0),
|
||||
cumulees - distinctes,
|
||||
cumulees,
|
||||
excedentPaires,
|
||||
retours,
|
||||
affilies.reduce((somme, f, p) => somme + f - appartenances[p].size, 0),
|
||||
];
|
||||
const c = contraintes;
|
||||
const nUplet = [
|
||||
brutes[0],
|
||||
brutes[1],
|
||||
c.separerAppartenances ? brutes[2] : 0,
|
||||
c.separerAppartenances ? brutes[3] : 0,
|
||||
(c.nouveauxVoisins ? brutes[4] : 0) + (c.nouvelleTable ? brutes[5] : 0),
|
||||
c.varierAppartenances ? brutes[6] : 0,
|
||||
];
|
||||
return { nUplet, brutes };
|
||||
}
|
||||
|
||||
// Ordres de comparaison du n-uplet, par rang de composante : celui du score,
|
||||
// et celui des contraintes, composantes 2, 3 et 4 devant les deux premières
|
||||
// (en-tête de recherche.js).
|
||||
const ORDRE_SCORE = [0, 1, 2, 3, 4, 5];
|
||||
const ORDRE_CONTRAINTES = [2, 3, 4, 0, 1, 5];
|
||||
// Longueurs d'historique sans changement du score courant au-delà desquelles
|
||||
// une descente se relance (en-tête de recherche.js).
|
||||
const PATIENCE = 10;
|
||||
|
||||
// Signe de la comparaison lexicographique de deux n-uplets, composantes
|
||||
// prises dans l'ordre donné.
|
||||
function comparerDans(ordre, a, b) {
|
||||
for (const k of ordre) if (a[k] !== b[k]) return a[k] - b[k];
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Une descente selon les paragraphes « Mouvement » à « Deux ordres
|
||||
// d'acceptation » de recherche.js, écrite avec les primitives
|
||||
// publiques : placement initial par creerEtat ; à l'itération i, un
|
||||
// mouvement de proposerEtAppliquer, gardé si son score est ≤ courant ou
|
||||
// ≤ H[i mod L] dans l'ordre d'acceptation, défait sinon ; puis H[i mod L]
|
||||
// reçoit le score courant. La meilleure affectation change sur un score
|
||||
// strictement meilleur dans l'ordre du score. Arrêt après arret mouvements
|
||||
// évalués, ou sur le n-uplet nul.
|
||||
//
|
||||
// Sans bascule, l'ordre d'acceptation reste celui du score : c'est
|
||||
// l'acceptation tardive seule, à la lettre (§ 5.10). Avec, il devient l'ordre des contraintes dès que la
|
||||
// meilleure affectation porte un écart maximal nul ; dans cet ordre, un
|
||||
// score courant inchangé pendant PATIENCE × L itérations fait repartir la
|
||||
// descente d'un placement neuf, tiré du même générateur, l'historique à son
|
||||
// score (en-tête de recherche.js). Rend le plan et trois comptes : bascules,
|
||||
// relances, et meilleures affectations trouvées après une relance.
|
||||
function descendreALaLettre(instance, graineDerivee, arret, L, { bascule }) {
|
||||
const rng = creerPcg32(graineDerivee, FLUX.RECHERCHE);
|
||||
let etat = creerEtat(instance, rng);
|
||||
let courant = scoreIncremental(etat);
|
||||
let meilleur = courant;
|
||||
let meilleurPlan = Int32Array.from(etat.tableDe);
|
||||
let H = Array(L).fill(courant);
|
||||
let ordre = ORDRE_SCORE;
|
||||
let immobile = 0;
|
||||
const comptes = { bascules: 0, relances: 0, apresRelance: 0 };
|
||||
const basculer = () => {
|
||||
if (!bascule || ordre === ORDRE_CONTRAINTES || meilleur[0] !== 0) return;
|
||||
ordre = ORDRE_CONTRAINTES;
|
||||
comptes.bascules += 1;
|
||||
};
|
||||
const retenir = () => {
|
||||
if (comparerDans(ORDRE_SCORE, courant, meilleur) >= 0) return;
|
||||
meilleur = courant;
|
||||
meilleurPlan = Int32Array.from(etat.tableDe);
|
||||
if (comptes.relances > 0) comptes.apresRelance += 1;
|
||||
basculer();
|
||||
};
|
||||
basculer();
|
||||
for (let i = 0; i < arret && courant.some((composante) => composante !== 0); i += 1) {
|
||||
if (immobile >= PATIENCE * L && ordre === ORDRE_CONTRAINTES) {
|
||||
etat = creerEtat(instance, rng);
|
||||
courant = scoreIncremental(etat);
|
||||
H = Array(L).fill(courant);
|
||||
comptes.relances += 1;
|
||||
retenir();
|
||||
immobile = 0;
|
||||
}
|
||||
const mouvement = proposerEtAppliquer(etat, rng);
|
||||
let change = false;
|
||||
if (mouvement !== null) {
|
||||
const candidat = scoreIncremental(etat);
|
||||
if (comparerDans(ordre, candidat, courant) <= 0 || comparerDans(ordre, candidat, H[i % L]) <= 0) {
|
||||
change = comparerDans(ORDRE_SCORE, candidat, courant) !== 0;
|
||||
courant = candidat;
|
||||
retenir();
|
||||
} else {
|
||||
defaire(etat, mouvement);
|
||||
}
|
||||
}
|
||||
H[i % L] = courant;
|
||||
immobile = change ? 0 : immobile + 1;
|
||||
}
|
||||
return { plan: planDepuisIndex(instance, meilleurPlan), ...comptes };
|
||||
}
|
||||
|
||||
// Les descentes à la lettre pour ces réglages, historique compris : la
|
||||
// i-ème part de la i-ème graine que FLUX.GRAINES tire de graine (paragraphe
|
||||
// « Graines » de recherche.js, § 5.7).
|
||||
function descentesALaLettre(configuration, { graine, arret, nombre, historique }, options) {
|
||||
const instance = normaliser(configuration);
|
||||
const graines = creerPcg32(graine, FLUX.GRAINES);
|
||||
return Array.from({ length: nombre }, () =>
|
||||
descendreALaLettre(instance, graines.suivant(), arret, historique, options));
|
||||
}
|
||||
|
||||
// Les plans de l'acceptation tardive seule, à la lettre : sans bascule ni
|
||||
// relance.
|
||||
const plansLitteraux = (configuration, reglages) =>
|
||||
descentesALaLettre(configuration, reglages, { bascule: false }).map(({ plan }) => plan);
|
||||
|
||||
describe('rechercher : reproductibilité et identifiants (§ 5.7, § 19.4)', () => {
|
||||
test('mêmes configuration et réglages : propositions identiques ; une autre graine change au moins un plan', () => {
|
||||
const reglages = { graine: 11, arret: 2_000, nombre: 2 };
|
||||
const premieres = rechercher(demo('grande'), reglages);
|
||||
assert.equal(premieres.length, 2);
|
||||
assert.deepStrictEqual(rechercher(demo('grande'), reglages), premieres);
|
||||
const autres = rechercher(demo('grande'), { ...reglages, graine: 12 });
|
||||
assert.notDeepStrictEqual(
|
||||
autres.map((proposition) => proposition.plan),
|
||||
premieres.map((proposition) => proposition.plan),
|
||||
);
|
||||
});
|
||||
|
||||
test("l'identifiant est le rang dans la suite des graines dérivées ; la proposition 2 de 3 égale celle de 5", () => {
|
||||
const trois = rechercher(demo('petite'), { graine: 7, arret: 3_000, nombre: 3 });
|
||||
const cinq = rechercher(demo('petite'), { graine: 7, arret: 3_000, nombre: 5 });
|
||||
const graines = creerPcg32(7, FLUX.GRAINES);
|
||||
const derivees = Array.from({ length: 5 }, () => graines.suivant());
|
||||
assert.deepEqual(cinq.map((proposition) => proposition.id), [1, 2, 3, 4, 5]);
|
||||
assert.deepEqual(cinq.map((proposition) => proposition.graine), derivees);
|
||||
assert.deepEqual(cinq.map((proposition) => proposition.arret), [3_000, 3_000, 3_000, 3_000, 3_000]);
|
||||
assert.deepStrictEqual(trois[1], cinq[1]);
|
||||
assert.deepStrictEqual(trois, cinq.slice(0, 3));
|
||||
});
|
||||
|
||||
test("chaque proposition porte la longueur d'historique qui l'a produite (§ 5.7, § 8.9)", () => {
|
||||
// Graine, arret et historique décident ensemble du plan : une
|
||||
// proposition qui ne porterait pas le troisième ne se régénérerait que
|
||||
// sous la valeur par défaut.
|
||||
const parDefaut = rechercher(demo('petite'), { graine: 7, arret: 500, nombre: 2 });
|
||||
assert.deepEqual(parDefaut.map((proposition) => proposition.historique), [
|
||||
HISTORIQUE_PAR_DEFAUT,
|
||||
HISTORIQUE_PAR_DEFAUT,
|
||||
]);
|
||||
const reglee = rechercher(demo('petite'), { graine: 7, arret: 500, nombre: 2, historique: 37 });
|
||||
assert.deepEqual(reglee.map((proposition) => proposition.historique), [37, 37]);
|
||||
});
|
||||
|
||||
test('chaque proposition est le placement que sa graine dérivée tire par le flux de la recherche', () => {
|
||||
// Score nul dès le placement : chaque descente rend son placement
|
||||
// initial, que creerEtat redonne à partir de la seule graine de la
|
||||
// proposition. Les quatre placements diffèrent : un générateur partagé
|
||||
// entre les propositions, ou tiré d'une autre graine ou d'un autre flux,
|
||||
// en donnerait d'autres.
|
||||
const configuration = troisTablesDeDeux();
|
||||
const instance = normaliser(configuration);
|
||||
const propositions = rechercher(configuration, { graine: 21, arret: 1_000, nombre: 4 });
|
||||
const attendus = propositions.map(({ graine }) =>
|
||||
planDepuisIndex(instance, creerEtat(instance, creerPcg32(graine, FLUX.RECHERCHE)).tableDe));
|
||||
assert.deepStrictEqual(propositions.map(({ plan }) => plan), attendus);
|
||||
assert.equal(new Set(attendus.map((plan) => JSON.stringify(plan))).size, 4);
|
||||
});
|
||||
|
||||
test('rechercher ne modifie pas la configuration reçue', () => {
|
||||
const configuration = salleReservee();
|
||||
rechercher(configuration, { graine: 1, arret: 500, nombre: 1 });
|
||||
assert.deepStrictEqual(configuration, salleReservee());
|
||||
});
|
||||
|
||||
test('réglage hors de son domaine : RangeError qui le nomme', () => {
|
||||
const cas = [
|
||||
[{ graine: -1, arret: 10, nombre: 1 }, 'graine'],
|
||||
[{ graine: 2 ** 32, arret: 10, nombre: 1 }, 'graine'],
|
||||
[{ graine: 1.5, arret: 10, nombre: 1 }, 'graine'],
|
||||
[{ graine: 1, arret: 0, nombre: 1 }, 'arret'],
|
||||
[{ graine: 1, arret: 2.5, nombre: 1 }, 'arret'],
|
||||
[{ graine: 1, arret: '10', nombre: 1 }, 'arret'],
|
||||
[{ graine: 1, arret: 10, nombre: 0 }, 'nombre'],
|
||||
[{ graine: 1, arret: 10, nombre: 1, historique: 0 }, 'historique'],
|
||||
];
|
||||
for (const [reglages, nom] of cas) {
|
||||
assert.throws(
|
||||
() => rechercher(demo('petite'), reglages),
|
||||
(erreur) => erreur instanceof RangeError && erreur.message.startsWith(`${nom} :`),
|
||||
JSON.stringify(reglages),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('regenerer : une proposition depuis ses seuls champs (§ 5.7, § 8.9)', () => {
|
||||
test("pour chaque proposition d'une génération, regenerer rend le même plan à partir de ses trois champs, sans lire ni son plan ni son rang", () => {
|
||||
// arret 5 000 n'est pas un multiple de l'historique 37 : une descente
|
||||
// dont le plan dépendrait de son rang dans la génération, par le compte
|
||||
// des mouvements que la génération a déjà faits, ne se régénérerait pas
|
||||
// au-delà de la première proposition. Les propositions se régénèrent de
|
||||
// la dernière à la première, et regenerer ne reçoit que leur graine
|
||||
// dérivée, leur compte d'arrêt et leur historique, ni leur plan ni leur
|
||||
// identifiant : chacune sort de ces trois champs, et non de son rang ou
|
||||
// d'un état que la précédente aurait laissé. salleReservee porte des
|
||||
// places fantômes et des partiellement fixés.
|
||||
const reglages = { graine: 5, arret: 5_000, nombre: 3, historique: 37 };
|
||||
let regenerees = 0;
|
||||
let conflit = null;
|
||||
for (const [nom, construire] of [
|
||||
['salleReservee', salleReservee],
|
||||
['petite-conflit', () => demo('petite-conflit')],
|
||||
]) {
|
||||
const configuration = construire();
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
for (const { id, graine, arret, historique, plan } of [...propositions].reverse()) {
|
||||
assert.deepStrictEqual(
|
||||
regenerer(configuration, { graine, arret, historique }),
|
||||
plan,
|
||||
`${nom}, proposition ${id}`,
|
||||
);
|
||||
regenerees += 1;
|
||||
}
|
||||
assert.deepStrictEqual(configuration, construire(), nom);
|
||||
// L'historique décide du plan : sous la longueur par défaut, les mêmes
|
||||
// graines donnent d'autres plans, ceux que rendrait une régénération
|
||||
// qui ignorerait ce champ.
|
||||
assert.notDeepStrictEqual(
|
||||
rechercher(configuration, { ...reglages, historique: HISTORIQUE_PAR_DEFAUT }).map(({ plan }) => plan),
|
||||
propositions.map(({ plan }) => plan),
|
||||
nom,
|
||||
);
|
||||
conflit = propositions;
|
||||
}
|
||||
assert.equal(regenerees, 6);
|
||||
// Dans la variante conflit, chaque descente atteint un écart nul,
|
||||
// bascule, puis relance : la réplique littérale rend les mêmes plans, et
|
||||
// compte bascules et relances. La régénération rejoue donc aussi le
|
||||
// placement neuf qu'une relance tire.
|
||||
const repliques = descentesALaLettre(demo('petite-conflit'), reglages, { bascule: true });
|
||||
assert.deepStrictEqual(repliques.map(({ plan }) => plan), conflit.map(({ plan }) => plan));
|
||||
assert.deepEqual(
|
||||
repliques.map(({ bascules, relances }) => [bascules, relances > 0]),
|
||||
[
|
||||
[1, true],
|
||||
[1, true],
|
||||
[1, true],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("le compte d'arrêt décide du plan : à chaque compte de 1 à 32, regenerer rend chaque plan, et un compte au moins change un plan du précédent ; à 100, regenerer rend chaque plan, et la moitié comme le double de ce compte en donnent d'autres", () => {
|
||||
// Là où la descente ne progresse plus, comme à 5 000 mouvements dans
|
||||
// l'épreuve précédente, la moitié ou le double du compte rendent les
|
||||
// mêmes plans, et une régénération qui compterait autrement passerait.
|
||||
//
|
||||
// Sur ses premiers mouvements, la descente change souvent de meilleure
|
||||
// affectation. À chaque compte de 1 à COMPTES_COURTS, regenerer rend le
|
||||
// plan de chaque proposition à partir de ses trois champs, et l'épreuve
|
||||
// exige qu'un compte au moins de la boucle change un plan par rapport au
|
||||
// compte précédent : une régénération qui compterait un mouvement de
|
||||
// moins rend un autre plan à ce compte, et une qui en compterait un de
|
||||
// plus, au compte précédent.
|
||||
//
|
||||
// À 100 mouvements, la descente progresse encore. Une descente plus
|
||||
// courte refait, mouvement pour mouvement, le début de celle-ci, et la
|
||||
// meilleure affectation ne cède qu'à une meilleure : des plans qui
|
||||
// changent à arret / 2 et à 2 × arret changent à tout compte hors de cet
|
||||
// intervalle.
|
||||
const COMPTES_COURTS = 32;
|
||||
const configuration = demo('petite-conflit');
|
||||
const reglages = { graine: 5, arret: 100, nombre: 3, historique: 37 };
|
||||
let precedents = null;
|
||||
let changements = 0;
|
||||
for (let compte = 1; compte <= COMPTES_COURTS; compte += 1) {
|
||||
const courtes = rechercher(configuration, { ...reglages, arret: compte });
|
||||
for (const { id, graine, arret, historique, plan } of courtes) {
|
||||
assert.deepStrictEqual(
|
||||
regenerer(configuration, { graine, arret, historique }),
|
||||
plan,
|
||||
`arret ${compte}, proposition ${id}`,
|
||||
);
|
||||
}
|
||||
const plans = courtes.map(({ plan }) => JSON.stringify(plan));
|
||||
if (precedents !== null && plans.some((plan, i) => plan !== precedents[i])) changements += 1;
|
||||
precedents = plans;
|
||||
}
|
||||
assert.ok(changements > 0, `aucun compte de 2 à ${COMPTES_COURTS} ne change un plan du compte précédent`);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
for (const { id, graine, arret, historique, plan } of [...propositions].reverse()) {
|
||||
assert.deepStrictEqual(regenerer(configuration, { graine, arret, historique }), plan, `proposition ${id}`);
|
||||
}
|
||||
for (const arret of [reglages.arret / 2, 2 * reglages.arret]) {
|
||||
assert.notDeepStrictEqual(
|
||||
rechercher(configuration, { ...reglages, arret }).map(({ plan }) => plan),
|
||||
propositions.map(({ plan }) => plan),
|
||||
`arret ${arret}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('champ hors de son domaine ou absent : RangeError qui le nomme ; salle trop petite : PLACES_MANQUANTES, comme rechercher', () => {
|
||||
// Un champ absent ne prend pas la valeur par défaut de rechercher : une
|
||||
// proposition qui ne porte pas son historique ne se régénère pas sous une
|
||||
// longueur supposée. Les deux bornes de la graine sont admises.
|
||||
const champs = { graine: 1, arret: 10, historique: 5 };
|
||||
for (const graine of [0, 2 ** 32 - 1]) {
|
||||
assert.equal(regenerer(demo('petite'), { ...champs, graine }).tours.length, 4, `graine ${graine}`);
|
||||
}
|
||||
const cas = [
|
||||
[{ ...champs, graine: -1 }, 'graine'],
|
||||
[{ ...champs, graine: 2 ** 32 }, 'graine'],
|
||||
[{ ...champs, graine: 1.5 }, 'graine'],
|
||||
[{ arret: 10, historique: 5 }, 'graine'],
|
||||
[{ ...champs, arret: 0 }, 'arret'],
|
||||
[{ ...champs, arret: '10' }, 'arret'],
|
||||
[{ ...champs, historique: 0 }, 'historique'],
|
||||
[{ graine: 1, arret: 10 }, 'historique'],
|
||||
];
|
||||
for (const [proposition, nom] of cas) {
|
||||
assert.throws(
|
||||
() => regenerer(demo('petite'), proposition),
|
||||
(erreur) => erreur instanceof RangeError && erreur.message.startsWith(`${nom} :`),
|
||||
JSON.stringify(proposition),
|
||||
);
|
||||
}
|
||||
const tropPetite = configurationDe({
|
||||
appartenances: Array(11).fill(null),
|
||||
tables: [
|
||||
{ id: 1, capacite: 4 },
|
||||
{ id: 2, capacite: 4 },
|
||||
],
|
||||
tours: 2,
|
||||
});
|
||||
assert.throws(() => regenerer(tropPetite, champs), {
|
||||
name: 'ErreurConfiguration',
|
||||
code: 'PLACES_MANQUANTES',
|
||||
details: { placesManquantes: 3 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rechercher : plans valides (§ 5.2, § 5.9, § 14.12)', () => {
|
||||
test('chaque proposition des quatre démonstrations tient tous les invariants, réserve interdite', () => {
|
||||
let examinees = 0;
|
||||
for (const { cle, construire } of CATALOGUE) {
|
||||
const configuration = construire();
|
||||
const instance = normaliser(configuration);
|
||||
for (const { id, plan } of rechercher(configuration, { graine: 5, arret: 2_000, nombre: 2 })) {
|
||||
assert.deepEqual(verifierInvariants(instance, plan), [], `${cle}, proposition ${id}`);
|
||||
examinees += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(examinees, 8);
|
||||
});
|
||||
|
||||
test('ancrés et partiellement fixés sont à leur table à chaque tour fixé', () => {
|
||||
let examines = 0;
|
||||
for (const configuration of [salleReservee(), demo('grande')]) {
|
||||
for (const { id, plan } of rechercher(configuration, { graine: 9, arret: 3_000, nombre: 2 })) {
|
||||
const releve = reservationsRompues(configuration, plan);
|
||||
assert.deepEqual(releve.rompues, [], `proposition ${id}`);
|
||||
examines += releve.examines;
|
||||
}
|
||||
}
|
||||
// Deux propositions de chacune : huit couples dans la salle, 33 ancrés
|
||||
// sur 4 tours dans la grande démonstration.
|
||||
assert.equal(examines, 2 * 8 + 2 * 33 * 4);
|
||||
});
|
||||
|
||||
test("l'appartenance d'un ancré compte : son collègue mobile n'est jamais assis à sa table", () => {
|
||||
// Trois tables de 3, trois tours. 1 et 2 portent l'appartenance X, 1 est
|
||||
// ancré à la table 1. Chacun peut rencontrer six personnes sans croiser
|
||||
// personne deux fois, 2 compris, sans jamais s'asseoir à la table 1 :
|
||||
// trois des quatre classes parallèles du plan affine d'ordre 3, celle où
|
||||
// 1 et 2 se retrouvent écartée. Éviter la table 1 coûte à 2 au moins un
|
||||
// retour, que la séparation fait accepter. Une recherche qui ignorerait
|
||||
// l'appartenance de l'ancré l'y assiérait pour épargner ce retour.
|
||||
const configuration = configurationDe({
|
||||
appartenances: ['X', 'X', null, null, null, null, null, null, null],
|
||||
tables: [
|
||||
{ id: 1, capacite: 3 },
|
||||
{ id: 2, capacite: 3 },
|
||||
{ id: 3, capacite: 3 },
|
||||
],
|
||||
tours: 3,
|
||||
reservations: [tous(1, 1)],
|
||||
contraintes: { ...SANS_CONTRAINTE, separerAppartenances: true, nouvelleTable: true },
|
||||
});
|
||||
const instance = normaliser(configuration);
|
||||
const propositions = rechercher(configuration, { graine: 4, arret: 20_000, nombre: 3 });
|
||||
assert.equal(propositions.length, 3);
|
||||
for (const { id, plan } of propositions) {
|
||||
const mesures = mesurer(instance, indexerPlan(instance, plan));
|
||||
assert.equal(mesures.collisionsCumulees, 0, `proposition ${id}`);
|
||||
assert.deepEqual(
|
||||
plan.tours.map((listes) => listes[plan.tables.indexOf(1)].includes(2)),
|
||||
[false, false, false],
|
||||
`proposition ${id}`,
|
||||
);
|
||||
assert.ok(mesures.retoursChoisis[instance.indexDe.get(2)] >= 1, `proposition ${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('salle trop petite : PLACES_MANQUANTES et le nombre exact de places manquantes, aucun plan', () => {
|
||||
// Douze inscrits dont un exclu, deux tables de 4 et une de 2 : onze
|
||||
// présents pour dix places. L'exclu ne compte pas, une réservation ne
|
||||
// change rien au compte (§ 5.9). Le détail se nomme placesManquantes,
|
||||
// comme dans le diagnostic : « manque » désigne plafond réalisé −
|
||||
// rencontres (§ 5.5).
|
||||
const tables = [
|
||||
{ id: 1, capacite: 4 },
|
||||
{ id: 2, capacite: 4 },
|
||||
{ id: 3, capacite: 2 },
|
||||
];
|
||||
const appartenances = Array(12).fill(null);
|
||||
const sansReservation = configurationDe({ appartenances, tables, tours: 2, exclus: [12] });
|
||||
const avecReservations = configurationDe({
|
||||
appartenances,
|
||||
tables,
|
||||
tours: 2,
|
||||
exclus: [12],
|
||||
reservations: [tous(1, 1), auTour(2, 3, 2)],
|
||||
});
|
||||
const reglages = { graine: 1, arret: 100, nombre: 2 };
|
||||
for (const configuration of [sansReservation, avecReservations]) {
|
||||
assert.throws(() => rechercher(configuration, reglages), {
|
||||
name: 'ErreurConfiguration',
|
||||
code: 'PLACES_MANQUANTES',
|
||||
details: { placesManquantes: 1 },
|
||||
});
|
||||
}
|
||||
const unDePlus = configurationDe({ appartenances: Array(13).fill(null), tables, tours: 2 });
|
||||
assert.throws(() => rechercher(unDePlus, reglages), {
|
||||
code: 'PLACES_MANQUANTES',
|
||||
details: { placesManquantes: 3 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rechercher : avancement et annulation (§ 5.10)', () => {
|
||||
test('progression toutes les 1 000 itérations, au rang du mouvement dans nombre × arret', () => {
|
||||
const etapes = [];
|
||||
const propositions = rechercher(
|
||||
demo('petite'),
|
||||
{ graine: 3, arret: 2_500, nombre: 2 },
|
||||
{ progression: (fait, total) => etapes.push([fait, total]) },
|
||||
);
|
||||
assert.equal(propositions.length, 2);
|
||||
assert.deepEqual(etapes, [
|
||||
[1_000, 5_000],
|
||||
[2_000, 5_000],
|
||||
[3_000, 5_000],
|
||||
[4_000, 5_000],
|
||||
[5_000, 5_000],
|
||||
]);
|
||||
});
|
||||
|
||||
test("score nul dès le placement : chaque descente s'arrête sans évaluer de mouvement", () => {
|
||||
// Le signal se lit une fois avant chaque proposition, et progression ne
|
||||
// reçoit rien : aucun millième mouvement n'est atteint.
|
||||
const configuration = troisTablesDeDeux();
|
||||
let lectures = 0;
|
||||
const signal = {
|
||||
get aborted() {
|
||||
lectures += 1;
|
||||
return false;
|
||||
},
|
||||
};
|
||||
const etapes = [];
|
||||
const propositions = rechercher(
|
||||
configuration,
|
||||
{ graine: 8, arret: 50_000, nombre: 2 },
|
||||
{ signal, progression: (fait, total) => etapes.push([fait, total]) },
|
||||
);
|
||||
assert.equal(propositions.length, 2);
|
||||
assert.deepEqual(etapes, []);
|
||||
assert.equal(lectures, 2);
|
||||
});
|
||||
|
||||
test('un signal levé à sa deuxième lecture fait lever ErreurAnnulee, sans rien rendre', () => {
|
||||
let lectures = 0;
|
||||
const signal = {
|
||||
get aborted() {
|
||||
lectures += 1;
|
||||
return lectures >= 2;
|
||||
},
|
||||
};
|
||||
const etapes = [];
|
||||
let rendu = 'rien';
|
||||
assert.throws(() => {
|
||||
rendu = rechercher(
|
||||
demo('petite'),
|
||||
{ graine: 3, arret: 5_000, nombre: 3 },
|
||||
{ signal, progression: (fait, total) => etapes.push([fait, total]) },
|
||||
);
|
||||
}, ErreurAnnulee);
|
||||
assert.equal(rendu, 'rien');
|
||||
assert.equal(lectures, 2);
|
||||
assert.deepEqual(etapes, [[1_000, 15_000]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('état de recherche : placement initial et score incrémental (§ 5.10)', () => {
|
||||
test("creerEtat : fixés à leur table, les autres table après table dans l'ordre des tables", () => {
|
||||
// 264 sièges pour 260 personnes : les 32 premières tables se remplissent,
|
||||
// la 33e reçoit son animateur et les trois derniers mobiles, à chaque
|
||||
// tour.
|
||||
const instance = normaliser(demo('grande-sans-exception'));
|
||||
const etat = creerEtat(instance, creerPcg32(1, FLUX.RECHERCHE));
|
||||
const plan = planDepuisIndex(instance, etat.tableDe);
|
||||
assert.equal(plan.tours.length, 4);
|
||||
for (const listes of plan.tours) {
|
||||
assert.deepEqual(listes.map((liste) => liste.length), [...Array(32).fill(8), 4]);
|
||||
}
|
||||
assert.deepEqual(verifierInvariants(instance, plan), []);
|
||||
});
|
||||
|
||||
test("creerEtat : le placement initial décrit en tête de recherche.js, à la lettre", () => {
|
||||
// La réplique tire du même flux, dans le même ordre : les deux plans
|
||||
// coïncident, et les deux générateurs restent au même point.
|
||||
for (const configuration of [salleReservee(), demo('petite'), demo('grande')]) {
|
||||
const instance = normaliser(configuration);
|
||||
const rng = creerPcg32(13, FLUX.RECHERCHE);
|
||||
const temoin = creerPcg32(13, FLUX.RECHERCHE);
|
||||
const etat = creerEtat(instance, rng);
|
||||
assert.deepStrictEqual(etat.tableDe, placementLitteral(instance, temoin));
|
||||
assert.equal(rng.suivant(), temoin.suivant());
|
||||
}
|
||||
});
|
||||
|
||||
test('scoreIncremental égale scoreComplet au départ, après 1 et 10 mouvements, et revient au départ une fois défaits', () => {
|
||||
const instance = normaliser(demo('petite'));
|
||||
const rng = creerPcg32(42, FLUX.RECHERCHE);
|
||||
const etat = creerEtat(instance, rng);
|
||||
const depart = scoreIncremental(etat);
|
||||
const planDepart = Int32Array.from(etat.tableDe);
|
||||
assert.equal(depart.length, 6);
|
||||
assert.deepStrictEqual(depart, scoreComplet(etat));
|
||||
const appliques = [];
|
||||
for (let essai = 0; appliques.length < 10 && essai < 1_000; essai += 1) {
|
||||
const mouvement = proposerEtAppliquer(etat, rng);
|
||||
if (mouvement === null) continue;
|
||||
appliques.push(mouvement);
|
||||
if (appliques.length === 1 || appliques.length === 10) {
|
||||
assert.deepStrictEqual(scoreIncremental(etat), scoreComplet(etat), `${appliques.length} mouvements`);
|
||||
}
|
||||
}
|
||||
assert.equal(appliques.length, 10);
|
||||
assert.notDeepStrictEqual(etat.tableDe, planDepart);
|
||||
for (const mouvement of appliques.reverse()) defaire(etat, mouvement);
|
||||
assert.deepStrictEqual(etat.tableDe, planDepart);
|
||||
assert.deepStrictEqual(scoreIncremental(etat), depart);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rechercher : l'échange avec une place fantôme (paragraphe « Mouvement » de recherche.js)", () => {
|
||||
test("une personne passe au siège vide d'une autre table : deux tables de 3 pour A, A, B, B, aucune collision sur vingt graines", () => {
|
||||
// Le placement initial assied trois personnes à la première table et la
|
||||
// quatrième à la seconde : deux d'une même appartenance s'y retrouvent.
|
||||
// Un échange entre deux personnes garde trois et une ; seul l'échange
|
||||
// avec une place fantôme fait passer quelqu'un à la seconde table. À
|
||||
// deux par table, chacune peut réunir un A et un B, sans collision.
|
||||
const configuration = configurationDe({
|
||||
appartenances: ['A', 'A', 'B', 'B'],
|
||||
tables: [
|
||||
{ id: 1, capacite: 3 },
|
||||
{ id: 2, capacite: 3 },
|
||||
],
|
||||
tours: 1,
|
||||
contraintes: { ...SANS_CONTRAINTE, separerAppartenances: true },
|
||||
});
|
||||
const instance = normaliser(configuration);
|
||||
const collisions = [];
|
||||
for (let graine = 0; graine < 20; graine += 1) {
|
||||
const [{ plan }] = rechercher(configuration, { graine, arret: 2_000, nombre: 1 });
|
||||
collisions.push(mesurer(instance, indexerPlan(instance, plan)).collisionsCumulees);
|
||||
}
|
||||
assert.deepEqual(collisions, Array(20).fill(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('score : composition du n-uplet (en-tête de recherche.js)', () => {
|
||||
test('scoreIncremental et scoreComplet égalent le n-uplet énuméré, pour aucune contrainte, chacune seule et toutes', () => {
|
||||
// Score tenu à jour et score recalculé passent par la même composition :
|
||||
// les comparer entre eux ne dit rien d'elle. L'oracle de ce fichier la
|
||||
// refait par un autre chemin. Chaque grandeur brute est non nulle dans au
|
||||
// moins un état de chaque jeu : une composante comptée sous une
|
||||
// contrainte désactivée, ou omise sous une contrainte active, se voit.
|
||||
const jeux = [
|
||||
['aucune', SANS_CONTRAINTE],
|
||||
...NOMS_CONTRAINTES.map((nom) => [nom, { ...SANS_CONTRAINTE, [nom]: true }]),
|
||||
['toutes', TOUTES_CONTRAINTES],
|
||||
];
|
||||
const MOUVEMENTS = 40;
|
||||
for (const [nom, contraintes] of jeux) {
|
||||
const instance = normaliser({ ...salleReservee(), contraintes });
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
const rng = creerPcg32(5, FLUX.RECHERCHE);
|
||||
const etat = creerEtat(instance, rng);
|
||||
const nonNulles = Array(7).fill(false);
|
||||
const confronter = (etiquette) => {
|
||||
const { nUplet, brutes } = scoreOracle(instance, etat.tableDe, aPriori);
|
||||
assert.deepStrictEqual(scoreComplet(etat), nUplet, `${nom}, ${etiquette} : scoreComplet`);
|
||||
assert.deepStrictEqual(scoreIncremental(etat), nUplet, `${nom}, ${etiquette} : scoreIncremental`);
|
||||
for (let k = 0; k < brutes.length; k += 1) if (brutes[k] > 0) nonNulles[k] = true;
|
||||
};
|
||||
confronter('placement initial');
|
||||
let appliques = 0;
|
||||
for (let essai = 0; appliques < MOUVEMENTS && essai < 100 * MOUVEMENTS; essai += 1) {
|
||||
if (proposerEtAppliquer(etat, rng) === null) continue;
|
||||
appliques += 1;
|
||||
confronter(`${appliques} mouvements`);
|
||||
}
|
||||
assert.equal(appliques, MOUVEMENTS, nom);
|
||||
assert.deepEqual(nonNulles, Array(7).fill(true), `${nom} : grandeurs brutes non nulles`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("rechercher : la descente par acceptation tardive, à la lettre (§ 5.10)", () => {
|
||||
// Sur la grande démonstration, au moins 24 mobiles restent sous leur
|
||||
// plafond a priori (§ 15.1) : l'écart maximal ne s'annule jamais, l'ordre
|
||||
// d'acceptation ne bascule pas, et la relance, qui suit la bascule, ne joue
|
||||
// pas non plus. rechercher y rend les plans de la réplique littérale.
|
||||
test('grande démonstration, arret 5 000 : les plans de la réplique', () => {
|
||||
const reglages = { graine: 17, arret: 5_000, nombre: 2 };
|
||||
assert.deepStrictEqual(
|
||||
rechercher(demo('grande'), reglages).map(({ plan }) => plan),
|
||||
plansLitteraux(demo('grande'), { ...reglages, historique: HISTORIQUE_PAR_DEFAUT }),
|
||||
);
|
||||
});
|
||||
|
||||
test('grande démonstration sans contrainte, arret 20 000 : les plans de la réplique', () => {
|
||||
const configuration = () => ({ ...demo('grande'), contraintes: { ...SANS_CONTRAINTE } });
|
||||
const reglages = { graine: 17, arret: 20_000, nombre: 2 };
|
||||
assert.deepStrictEqual(
|
||||
rechercher(configuration(), reglages).map(({ plan }) => plan),
|
||||
plansLitteraux(configuration(), { ...reglages, historique: HISTORIQUE_PAR_DEFAUT }),
|
||||
);
|
||||
});
|
||||
|
||||
test("historique d'une case : aucune relance hors de l'ordre des contraintes", () => {
|
||||
// À historique 1, le score courant reste inchangé sur des plateaux que
|
||||
// la descente parcourt sans être figée. Une relance dans l'ordre du
|
||||
// score couperait ces parcours ; la réplique n'en fait aucune.
|
||||
const reglages = { graine: 17, arret: 5_000, nombre: 2, historique: 1 };
|
||||
assert.deepStrictEqual(
|
||||
rechercher(demo('grande'), reglages).map(({ plan }) => plan),
|
||||
plansLitteraux(demo('grande'), reglages),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rechercher : bascule et relance, à la lettre (en-tête de recherche.js)', () => {
|
||||
test('petite démonstration en conflit, historique 100, arret 20 000 : les plans de la réplique', () => {
|
||||
// Chaque descente y atteint un écart maximal nul et bascule ; à
|
||||
// historique 100, la patience vaut 1 000 itérations, et des relances
|
||||
// suivent la bascule puis trouvent de meilleures affectations. Les
|
||||
// comptes de la réplique l'exigent : sans eux, l'épreuve passerait aussi
|
||||
// sur des descentes qui ne basculent ni ne relancent.
|
||||
const reglages = { graine: 0, arret: 20_000, nombre: 3, historique: 100 };
|
||||
const repliques = descentesALaLettre(demo('petite-conflit'), reglages, { bascule: true });
|
||||
assert.deepStrictEqual(
|
||||
rechercher(demo('petite-conflit'), reglages).map(({ plan }) => plan),
|
||||
repliques.map(({ plan }) => plan),
|
||||
);
|
||||
assert.deepEqual(
|
||||
repliques.map(({ bascules, relances, apresRelance }) => [
|
||||
bascules,
|
||||
relances > 0,
|
||||
apresRelance > 0,
|
||||
]),
|
||||
[
|
||||
[1, true, true],
|
||||
[1, true, true],
|
||||
[1, true, true],
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
86
src/moteur/types.js
Normal file
86
src/moteur/types.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Contrat de données du moteur : les formes qu'il reçoit et celles qu'il
|
||||
// rend. Ce module ne porte que des définitions JSDoc et n'exécute rien ; un
|
||||
// module cite un type par import('./types.js').Instance. Les valeurs que ces
|
||||
// formes portent — STATUT, LIBRE, RESERVE, SANS_GROUPE — sont définies et
|
||||
// exportées par configuration.js, leur seul propriétaire.
|
||||
|
||||
/**
|
||||
* @typedef {Object} Participant
|
||||
* @property {number} id entier ≥ 1, unique, jamais réutilisé
|
||||
* @property {string} nom
|
||||
* @property {string} [prenom]
|
||||
* @property {string|null} appartenance null = sans appartenance ; l'égalité
|
||||
* de chaînes fait le groupe (la
|
||||
* réconciliation est l'affaire du CSV)
|
||||
* @property {boolean} [exclu] absent = false
|
||||
*
|
||||
* @typedef {Object} Table
|
||||
* @property {number} id entier ≥ 1, unique
|
||||
* @property {number} numero affiché, ≥ 1
|
||||
* @property {number} capacite entier ≥ 2, défaut déjà résolu
|
||||
*
|
||||
* @typedef {Object} Reservation
|
||||
* @property {number} participant id
|
||||
* @property {number} table id
|
||||
* @property {'tous'|'tour'} portee
|
||||
* @property {number} [tour] 1..R, requis quand portee === 'tour'
|
||||
*
|
||||
* @typedef {Object} Contraintes
|
||||
* @property {boolean} separerAppartenances
|
||||
* @property {boolean} nouveauxVoisins
|
||||
* @property {boolean} nouvelleTable
|
||||
* @property {boolean} varierAppartenances
|
||||
*
|
||||
* @typedef {Object} Configuration
|
||||
* @property {Participant[]} participants
|
||||
* @property {Table[]} tables
|
||||
* @property {number} tours R ≥ 1
|
||||
* @property {Reservation[]} reservations
|
||||
* @property {Contraintes} contraintes
|
||||
*
|
||||
* @typedef {Object} Plan forme par identifiants (§ 8.9)
|
||||
* @property {number[]} tables ids de table, dans l'ordre des listes
|
||||
* @property {number[][][]} tours tours[r][i] = ids assis à tables[i], croissants
|
||||
* @property {number[][]} reserves reserves[r] = ids assis nulle part au tour r
|
||||
*
|
||||
* @typedef {Object} Instance forme indexée, consommée par le moteur
|
||||
* @property {number} N participants non exclus
|
||||
* @property {number} T
|
||||
* @property {number} R
|
||||
* @property {number[]} ids index → id participant, ordre croissant
|
||||
* @property {Map<number, number>} indexDe id → index
|
||||
* @property {Set<number>} exclus ids exclus (pour le vérificateur)
|
||||
* @property {number[]} idsTables index → id table, ordre de configuration.tables
|
||||
* @property {Map<number, number>} indexTableDe
|
||||
* @property {Int32Array} capacite par index de table
|
||||
* @property {Int32Array} groupe par index participant ; SANS_GROUPE (−1)
|
||||
* sans appartenance
|
||||
* @property {string[]} groupes index de groupe → libellé ; ordre de première
|
||||
* apparition en parcourant les ids croissants
|
||||
* @property {Int32Array} fixe [p * R + r] → index de table imposé (r à
|
||||
* partir de 0), LIBRE (−1) si libre
|
||||
* @property {Uint8Array} statut STATUT.MOBILE (0), STATUT.PARTIELLEMENT_FIXE
|
||||
* (1), STATUT.ANCRE (2) (§ 4.2)
|
||||
* @property {Int32Array} ancresParTable a_t
|
||||
* @property {number} k ancrés
|
||||
* @property {number} n N − k
|
||||
* @property {Contraintes} contraintes
|
||||
*
|
||||
* Un plan indexé est un Int32Array `tableDe` de longueur N × R :
|
||||
* tableDe[p * R + r] = index de table, RESERVE (−1) pour la réserve.
|
||||
* exigerPlanIndexe (configuration.js) en garde la forme.
|
||||
*
|
||||
* L'ordre canonique des tableaux par personne est celui de `instance.ids` :
|
||||
* tout tableau rendu par le moteur et indexé par personne suit cet ordre.
|
||||
*
|
||||
* Une personne que le moteur désigne sans identifiant l'est par son index
|
||||
* canonique, son rang dans `instance.ids`, et `instance.ids[index]` en donne
|
||||
* l'identifiant. C'est le cas d'une violation de verifierIndicateurs,
|
||||
* { code, index } : ses trois listes ne portent pas d'identifiant, et la
|
||||
* traduction revient à l'appelant, qui tient l'instance. Les détails d'une
|
||||
* ErreurConfiguration et les violations de verifierInvariants désignent au
|
||||
* contraire les participants par identifiant.
|
||||
*/
|
||||
261
src/moteur/verification.js
Normal file
261
src/moteur/verification.js
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les deux vérificateurs du moteur (§ 14.12), du code de l'application et non
|
||||
// du code d'épreuve (§ 14.15) : verifierInvariants juge un plan contre
|
||||
// l'instance, verifierIndicateurs les mesures et les plafonds de chacun
|
||||
// (§ 12.3). Aucun des deux ne lève sur un défaut du contenu : chaque défaut
|
||||
// est une violation de la liste rendue, et la liste vide dit que tout tient.
|
||||
// Aucun ne modifie ce qu'il reçoit. Les formes reçues sont décrites dans
|
||||
// types.js.
|
||||
import { LIBRE } from './configuration.js';
|
||||
|
||||
/**
|
||||
* @typedef {{code: string, participant?: number, table?: number, tour?: number,
|
||||
* detail?: string}} Violation ids, tours numérotés à partir de 1
|
||||
*
|
||||
* @typedef {{code: string, index: number}} ViolationIndicateur
|
||||
* index : rang de la personne dans l'ordre canonique, celui de
|
||||
* instance.ids
|
||||
*/
|
||||
|
||||
// Index de table d'une liste que plan.tables ne rattache à aucune table de
|
||||
// l'instance. Aucune case de instance.fixe ne le porte : une réservation ne
|
||||
// s'honore jamais dans une telle liste.
|
||||
const SANS_TABLE = -2;
|
||||
|
||||
// Index de table de chaque position de plan.tables : celui de l'id déclaré
|
||||
// quand c'est sa première déclaration et une table de l'instance, SANS_TABLE
|
||||
// sinon. Ajoute à violations, dans l'ordre de plan.tables, un TABLE_INCONNUE
|
||||
// 'inconnue' à la première déclaration d'un id étranger à l'instance, et un
|
||||
// 'doublon' à la deuxième déclaration d'un id de l'instance : un id de table
|
||||
// déclaré plusieurs fois se signale une seule fois, en 'doublon' s'il
|
||||
// appartient à l'instance, en 'inconnue' sinon. Ajoute ensuite un 'absente'
|
||||
// par table de l'instance non déclarée, dans l'ordre de l'instance. Les
|
||||
// ensembles ne servent qu'à retrouver un id déjà vu.
|
||||
function rattacherListes(tables, { idsTables, indexTableDe }, violations) {
|
||||
const tableDeListe = [];
|
||||
const declarees = new Set();
|
||||
const doublons = new Set();
|
||||
for (const id of tables) {
|
||||
const t = indexTableDe.get(id);
|
||||
if (!declarees.has(id)) {
|
||||
declarees.add(id);
|
||||
if (t === undefined) violations.push({ code: 'TABLE_INCONNUE', table: id, detail: 'inconnue' });
|
||||
tableDeListe.push(t ?? SANS_TABLE);
|
||||
continue;
|
||||
}
|
||||
if (t !== undefined && !doublons.has(id)) {
|
||||
doublons.add(id);
|
||||
violations.push({ code: 'TABLE_INCONNUE', table: id, detail: 'doublon' });
|
||||
}
|
||||
tableDeListe.push(SANS_TABLE);
|
||||
}
|
||||
for (const id of idsTables) {
|
||||
if (!declarees.has(id)) violations.push({ code: 'TABLE_INCONNUE', table: id, detail: 'absente' });
|
||||
}
|
||||
return tableDeListe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Violations d'un plan par identifiants (§ 8.9), jugé contre l'instance
|
||||
* (§ 14.12). Code de l'application : appelé après une génération, après un
|
||||
* retour arrière, au chargement d'un fichier. Rend la liste des violations,
|
||||
* vide si le plan tient.
|
||||
*
|
||||
* Codes :
|
||||
* - TOURS : plan.tours ou plan.reserves n'a pas R entrées ;
|
||||
* - TABLE_INCONNUE : les tables du plan ne reprennent pas celles de
|
||||
* l'instance. detail précise le cas : 'inconnue', un id de plan.tables
|
||||
* étranger à l'instance ; 'doublon', un id que plan.tables déclare plus
|
||||
* d'une fois ; 'absente', une table de l'instance que plan.tables ne
|
||||
* déclare pas ; ces trois-là portent table. 'listes', un tour qui n'a pas
|
||||
* une liste par table déclarée, porte tour. L'ordre de plan.tables est
|
||||
* libre ;
|
||||
* - INCONNU : un id ni participant ni exclu de l'instance, à une table ou en
|
||||
* réserve ;
|
||||
* - EXCLU_PLACE : un participant exclu, à une table ou en réserve ;
|
||||
* - DOUBLE_PLACE : un participant présent plus d'une fois dans un tour,
|
||||
* réserve comprise ;
|
||||
* - NON_ASSIS : un participant absent d'un tour, que la réserve soit permise
|
||||
* ou non, ou présent seulement en réserve quand reserveAutorisee est faux.
|
||||
* La réserve est une liste du plan (§ 8.9) : un participant qui ne figure
|
||||
* ni à une table ni en réserve manque au plan, il n'attend pas ;
|
||||
* - CAPACITE : une liste plus longue que la capacité de sa table. Le siège
|
||||
* est la position dans la liste : chaque entrée en occupe un, et le siège
|
||||
* au-delà de la capacité est hors de sa table ;
|
||||
* - RESERVATION : un participant que instance.fixe impose à une table à un
|
||||
* tour, placé ailleurs à ce tour, à une autre table ou en réserve ; table
|
||||
* nomme la table imposée. La règle ne dépend pas de reserveAutorisee : le
|
||||
* réservé présent seulement en réserve reçoit RESERVATION que la réserve
|
||||
* soit permise ou non, précédée de NON_ASSIS quand elle est interdite. Ce
|
||||
* sont deux règles enfreintes : l'asseoir à une autre table lèverait la
|
||||
* première et non la seconde. Un participant absent du tour, que le plan
|
||||
* ne place nulle part, ne relève que de NON_ASSIS.
|
||||
*
|
||||
* Une liste se rattache à la table que plan.tables déclare à sa position,
|
||||
* quand cette déclaration est la première d'un id de l'instance. Une liste
|
||||
* sans table de l'instance (id étranger, doublon, position au-delà de
|
||||
* plan.tables) n'entre dans aucune capacité ; ses occupants sont placés à une
|
||||
* table, donc pas NON_ASSIS, et aucune réservation ne s'y honore. Une table
|
||||
* déclarée sans liste à un tour est vide à ce tour. Seuls les tours que le
|
||||
* plan porte en entier, listes et réserve, et dont le numéro ne dépasse pas R
|
||||
* sont lus : un tour manquant ou incomplet n'est signalé que par TOURS.
|
||||
*
|
||||
* Un id étranger à l'instance se signale une fois par tour où il figure, et
|
||||
* DOUBLE_PLACE ne concerne que les participants. Un id de table déclaré
|
||||
* plusieurs fois se signale une seule fois : en 'doublon' s'il appartient à
|
||||
* l'instance, en 'inconnue' sinon.
|
||||
*
|
||||
* Les violations se rangent dans l'ordre de lecture : TOURS ; les ids de
|
||||
* plan.tables dans leur ordre, puis les tables absentes dans l'ordre de
|
||||
* l'instance ; puis chaque tour lu, par ordre croissant : son nombre de
|
||||
* listes ; les ids des listes dans l'ordre déclaré puis ceux de la réserve,
|
||||
* un id étranger à sa première apparition dans le tour et DOUBLE_PLACE à la
|
||||
* seconde ; les capacités, liste par liste ; enfin les participants par id
|
||||
* croissant, chacun avec son NON_ASSIS puis sa RESERVATION.
|
||||
*
|
||||
* @param {import('./types.js').Instance} instance
|
||||
* @param {import('./types.js').Plan} plan
|
||||
* @param {{reserveAutorisee?: boolean}} [options] reserveAutorisee, faux par
|
||||
* défaut : un participant présent seulement en réserve est NON_ASSIS
|
||||
* (§ 12.6)
|
||||
* @returns {Violation[]}
|
||||
*/
|
||||
export function verifierInvariants(instance, plan, { reserveAutorisee = false } = {}) {
|
||||
const { N, R, ids, indexDe, exclus, idsTables, capacite, fixe } = instance;
|
||||
const violations = [];
|
||||
if (plan.tours.length !== R || plan.reserves.length !== R) violations.push({ code: 'TOURS' });
|
||||
const tableDeListe = rattacherListes(plan.tables, instance, violations);
|
||||
|
||||
// Par participant, au tour lu : ses présences, réserve comprise ; sa
|
||||
// présence à une table ; sa présence à la table que fixe lui impose.
|
||||
const presences = new Int32Array(N);
|
||||
const aUneTable = new Uint8Array(N);
|
||||
const aSaTable = new Uint8Array(N);
|
||||
const lus = Math.min(R, plan.tours.length, plan.reserves.length);
|
||||
for (let r = 0; r < lus; r += 1) {
|
||||
const tour = r + 1;
|
||||
const listes = plan.tours[r];
|
||||
if (listes.length !== tableDeListe.length) {
|
||||
violations.push({ code: 'TABLE_INCONNUE', tour, detail: 'listes' });
|
||||
}
|
||||
presences.fill(0);
|
||||
aUneTable.fill(0);
|
||||
aSaTable.fill(0);
|
||||
|
||||
// Compte une présence de id à ce tour et rend son index ; −1 pour un id
|
||||
// étranger à l'instance, signalé à sa première apparition dans le tour.
|
||||
const etrangers = new Set();
|
||||
const compter = (id) => {
|
||||
const p = indexDe.get(id);
|
||||
if (p === undefined) {
|
||||
if (!etrangers.has(id)) {
|
||||
etrangers.add(id);
|
||||
violations.push({ code: exclus.has(id) ? 'EXCLU_PLACE' : 'INCONNU', participant: id, tour });
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
presences[p] += 1;
|
||||
if (presences[p] === 2) violations.push({ code: 'DOUBLE_PLACE', participant: id, tour });
|
||||
return p;
|
||||
};
|
||||
for (let i = 0; i < listes.length; i += 1) {
|
||||
const t = i < tableDeListe.length ? tableDeListe[i] : SANS_TABLE;
|
||||
for (const id of listes[i]) {
|
||||
const p = compter(id);
|
||||
if (p < 0) continue;
|
||||
aUneTable[p] = 1;
|
||||
if (fixe[p * R + r] === t) aSaTable[p] = 1;
|
||||
}
|
||||
}
|
||||
for (const id of plan.reserves[r]) compter(id);
|
||||
|
||||
for (let i = 0; i < listes.length && i < tableDeListe.length; i += 1) {
|
||||
const t = tableDeListe[i];
|
||||
if (t !== SANS_TABLE && listes[i].length > capacite[t]) {
|
||||
violations.push({ code: 'CAPACITE', table: idsTables[t], tour });
|
||||
}
|
||||
}
|
||||
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
if (!aUneTable[p] && (presences[p] === 0 || !reserveAutorisee)) {
|
||||
violations.push({ code: 'NON_ASSIS', participant: ids[p], tour });
|
||||
}
|
||||
const imposee = fixe[p * R + r];
|
||||
if (imposee !== LIBRE && presences[p] > 0 && !aSaTable[p]) {
|
||||
violations.push({ code: 'RESERVATION', participant: ids[p], table: idsTables[imposee], tour });
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
// Vrai quand gauche ≥ droite et que les deux sont des nombres : NaN, ou une
|
||||
// valeur d'un autre type, fait tomber l'inégalité au lieu d'y être convertie.
|
||||
const tient = (gauche, droite) =>
|
||||
typeof gauche === 'number' && typeof droite === 'number' && gauche >= droite;
|
||||
|
||||
// Lève TypeError quand valeurs n'a pas de longueur, n'étant pas une liste,
|
||||
// et RangeError quand elle n'a pas N cases. attendu nomme ce que le paramètre
|
||||
// nom admet.
|
||||
function exigerLongueur(valeurs, N, nom, attendu) {
|
||||
if (typeof valeurs?.length !== 'number') throw new TypeError(`${nom} : ${attendu} attendue`);
|
||||
if (valeurs.length !== N) {
|
||||
throw new RangeError(`${nom} : ${valeurs.length} cases, ${N} attendues comme mesures.rencontres`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde du § 12.3 : éprouve, pour chacun, la ligne de décomposition du
|
||||
* § 12.10.5, N − 1 ≥ plafond a priori ≥ plafond réalisé ≥ rencontres, une
|
||||
* inégalité entre deux termes voisins à la fois ; ensemble, elles ordonnent
|
||||
* toute la ligne. Une violation désigne un calcul faux, sur lequel le profil
|
||||
* ne se dessine pas.
|
||||
*
|
||||
* Les trois listes suivent l'ordre canonique, celui de instance.ids, et N est
|
||||
* la longueur de mesures.rencontres. plafondsAPriori vaut null quand le
|
||||
* plafond a priori est inconnu (§ 12.6) : la ligne perd ce terme, et seule la
|
||||
* chaîne N − 1 ≥ réalisé ≥ rencontres est éprouvée. Une inégalité ne tient
|
||||
* qu'entre deux nombres : NaN, ou une valeur d'un autre type, la fait tomber
|
||||
* au lieu d'y être convertie.
|
||||
*
|
||||
* Codes : PLAFOND_A_PRIORI (a priori > N − 1), ORDRE_PLAFONDS (a priori <
|
||||
* réalisé), PLAFOND_REALISE (réalisé > N − 1, a priori inconnu), DEPASSEMENT
|
||||
* (rencontres > réalisé). La fonction ne reçoit pas les identifiants : une
|
||||
* violation désigne la personne par index, son rang dans l'ordre canonique,
|
||||
* que instance.ids[index] traduit. Les violations se rangent par index
|
||||
* croissant, puis de gauche à droite sur la ligne.
|
||||
*
|
||||
* Lève RangeError quand une liste de plafonds n'a pas N cases : elle décrit
|
||||
* une autre population, et l'écart est une faute de code. Lève TypeError
|
||||
* quand plafondsRealises n'est pas une liste, ou plafondsAPriori ni une liste
|
||||
* ni null : seul null dit l'a priori inconnu. undefined, la valeur d'un champ
|
||||
* manquant, ne passe pas pour lui : il ôterait deux inégalités à la garde
|
||||
* sans rien signaler.
|
||||
*
|
||||
* @param {{rencontres: ArrayLike<number>}} mesures les mesures d'un plan ;
|
||||
* seule la liste rencontres est lue
|
||||
* @param {ArrayLike<number>|null} plafondsAPriori
|
||||
* @param {ArrayLike<number>} plafondsRealises
|
||||
* @returns {ViolationIndicateur[]}
|
||||
*/
|
||||
export function verifierIndicateurs(mesures, plafondsAPriori, plafondsRealises) {
|
||||
const { rencontres } = mesures;
|
||||
const N = rencontres.length;
|
||||
exigerLongueur(plafondsRealises, N, 'plafondsRealises', 'liste');
|
||||
if (plafondsAPriori !== null) exigerLongueur(plafondsAPriori, N, 'plafondsAPriori', 'liste ou null');
|
||||
const violations = [];
|
||||
for (let p = 0; p < N; p += 1) {
|
||||
const realise = plafondsRealises[p];
|
||||
if (plafondsAPriori === null) {
|
||||
if (!tient(N - 1, realise)) violations.push({ code: 'PLAFOND_REALISE', index: p });
|
||||
} else {
|
||||
const aPriori = plafondsAPriori[p];
|
||||
if (!tient(N - 1, aPriori)) violations.push({ code: 'PLAFOND_A_PRIORI', index: p });
|
||||
if (!tient(aPriori, realise)) violations.push({ code: 'ORDRE_PLAFONDS', index: p });
|
||||
}
|
||||
if (!tient(realise, rencontres[p])) violations.push({ code: 'DEPASSEMENT', index: p });
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
829
src/moteur/verification.test.js
Normal file
829
src/moteur/verification.test.js
Normal file
|
|
@ -0,0 +1,829 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des deux vérificateurs du moteur (§ 14.12), par mutation : chaque
|
||||
// épreuve part d'un état qui tient, et chaque mutation y produit la violation
|
||||
// annoncée et aucune autre. Un vérificateur qui rend toujours la liste vide
|
||||
// échoue à chacune.
|
||||
//
|
||||
// verifierInvariants part du plan parfait de la petite démonstration
|
||||
// (§ 15.3), recopié ci-dessous. La configuration ajoute à la démonstration ce
|
||||
// que deux codes demandent : un participant exclu, 13, dont la réservation
|
||||
// est suspendue (§ 4.4), et une réservation de tour désigné que le plan
|
||||
// honore, 6 à la table de numéro 1 au tour 2. Les épreuves désignent
|
||||
// participants et tables par leur numéro ; participant() et table() en
|
||||
// donnent l'identifiant. Les participants de numéro 1 à 13 portent les
|
||||
// identifiants 107 à 191, de 7 en 7 : ni contigus, ni égaux à leur index plus
|
||||
// un. Les tables de numéro 1 à 4 portent les identifiants 21 à 24, que
|
||||
// l'instance range dans l'ordre 23, 21, 24, 22 et le plan dans l'ordre des
|
||||
// numéros. Confondre identifiant, numéro, index et position, d'un participant
|
||||
// comme d'une table, fait échouer une épreuve au lieu de passer inaperçu.
|
||||
//
|
||||
// verifierIndicateurs part des chiffres de ce même plan : N = 12, et 8 pour
|
||||
// chacun en plafond a priori, en plafond réalisé et en rencontres (§ 15.3).
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { indexerPlan, normaliser } from './configuration.js';
|
||||
import { ErreurConfiguration } from './erreurs.js';
|
||||
import { verifierIndicateurs, verifierInvariants } from './verification.js';
|
||||
|
||||
const SANS_CONTRAINTE = {
|
||||
separerAppartenances: false,
|
||||
nouveauxVoisins: false,
|
||||
nouvelleTable: false,
|
||||
varierAppartenances: false,
|
||||
};
|
||||
|
||||
// Identifiant du participant de numéro donné.
|
||||
const participant = (numero) => 100 + 7 * numero;
|
||||
// Identifiants des participants de numéros donnés, dans l'ordre donné.
|
||||
const participants = (...numeros) => numeros.map(participant);
|
||||
// Identifiant de la table de numéro donné.
|
||||
const table = (numero) => 20 + numero;
|
||||
// Identifiant qui n'est celui d'aucun participant, exclu compris, ni d'aucune
|
||||
// table.
|
||||
const ETRANGER = 99;
|
||||
|
||||
// Numéros des membres de chaque appartenance de la petite démonstration
|
||||
// (§ 15.3).
|
||||
const MEMBRES = [
|
||||
['A', [1, 5, 8, 12]],
|
||||
['B', [2, 4, 9, 11]],
|
||||
['C', [3, 6, 7, 10]],
|
||||
];
|
||||
const appartenanceDe = (numero) => MEMBRES.find(([, numeros]) => numeros.includes(numero))[0];
|
||||
|
||||
const RESERVATION_HONOREE = { participant: participant(6), table: table(1), portee: 'tour', tour: 2 };
|
||||
const RESERVATION_SUSPENDUE = { participant: participant(13), table: table(2), portee: 'tous' };
|
||||
|
||||
function configuration(reservations = [RESERVATION_HONOREE, RESERVATION_SUSPENDUE]) {
|
||||
const membres = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: participant(i + 1),
|
||||
nom: `P${i + 1}`,
|
||||
appartenance: appartenanceDe(i + 1),
|
||||
}));
|
||||
return {
|
||||
participants: [...membres, { id: participant(13), nom: 'P13', appartenance: null, exclu: true }],
|
||||
tables: [3, 1, 4, 2].map((numero) => ({ id: table(numero), numero, capacite: 3 })),
|
||||
tours: 4,
|
||||
reservations,
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
};
|
||||
}
|
||||
|
||||
// Le plan parfait de la petite démonstration (§ 15.3), écrit en numéros : à
|
||||
// chaque tour, les tables de numéro 1 à 4, chacune réunissant un membre de
|
||||
// chaque appartenance ; personne en réserve. Gelé, comme chaque plan que les
|
||||
// épreuves passent à verifierInvariants : une écriture du vérificateur y lève
|
||||
// TypeError, quelle que soit la branche qui la fait.
|
||||
const PLAN_PARFAIT = gelerPlan({
|
||||
tables: [table(1), table(2), table(3), table(4)],
|
||||
tours: [
|
||||
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
|
||||
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
|
||||
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
|
||||
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
|
||||
].map((listes) => listes.map((numeros) => participants(...numeros))),
|
||||
reserves: [[], [], [], []],
|
||||
});
|
||||
|
||||
const INSTANCE = normaliser(configuration());
|
||||
|
||||
// Copie du plan parfait, mutée par modifier puis gelée.
|
||||
function planMute(modifier) {
|
||||
const plan = structuredClone(PLAN_PARFAIT);
|
||||
modifier(plan);
|
||||
return gelerPlan(plan);
|
||||
}
|
||||
|
||||
// Violations du plan parfait, une fois que modifier a muté une copie.
|
||||
function apresMutation(modifier, options) {
|
||||
return verifierInvariants(INSTANCE, planMute(modifier), options);
|
||||
}
|
||||
|
||||
// Retire id de la liste de table qui le porte.
|
||||
function retirer(listes, id) {
|
||||
for (const liste of listes) {
|
||||
if (liste.includes(id)) liste.splice(liste.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Chaque cas [libellé, calcul, attendu] dont le résultat diffère de
|
||||
// l'attendu, en une ligne lisible ; une exception est un écart.
|
||||
function ecarts(cas) {
|
||||
assert.ok(cas.length > 0, 'aucun cas examiné');
|
||||
return cas.flatMap(([libelle, calcul, attendu]) => {
|
||||
let obtenu;
|
||||
try {
|
||||
obtenu = calcul();
|
||||
} catch (erreur) {
|
||||
return [`${libelle} : ${erreur?.name} ${erreur?.message}`];
|
||||
}
|
||||
try {
|
||||
assert.deepEqual(obtenu, attendu);
|
||||
return [];
|
||||
} catch {
|
||||
return [`${libelle} : ${JSON.stringify(obtenu)}`];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Gèle un plan et chacune de ses listes, puis le rend : une écriture faite
|
||||
// depuis un module ES, toujours en mode strict, y lève TypeError.
|
||||
function gelerPlan(plan) {
|
||||
const geler = (valeur) => {
|
||||
if (Array.isArray(valeur)) valeur.forEach(geler);
|
||||
return Object.freeze(valeur);
|
||||
};
|
||||
geler(plan.tables);
|
||||
geler(plan.tours);
|
||||
geler(plan.reserves);
|
||||
return Object.freeze(plan);
|
||||
}
|
||||
|
||||
const CODES = [
|
||||
'TOURS',
|
||||
'TABLE_INCONNUE',
|
||||
'INCONNU',
|
||||
'EXCLU_PLACE',
|
||||
'DOUBLE_PLACE',
|
||||
'NON_ASSIS',
|
||||
'CAPACITE',
|
||||
'RESERVATION',
|
||||
];
|
||||
|
||||
// [libellé, mutation du plan parfait, options, violations attendues]. Chaque
|
||||
// code a au moins une mutation qui le produit seul.
|
||||
const MUTATIONS = [
|
||||
['un tour de moins', (plan) => {
|
||||
plan.tours.pop();
|
||||
plan.reserves.pop();
|
||||
}, undefined, [{ code: 'TOURS' }]],
|
||||
['un tour de trop', (plan) => {
|
||||
plan.tours.push(structuredClone(plan.tours[0]));
|
||||
plan.reserves.push([]);
|
||||
}, undefined, [{ code: 'TOURS' }]],
|
||||
['une réserve de moins', (plan) => {
|
||||
plan.reserves.pop();
|
||||
}, undefined, [{ code: 'TOURS' }]],
|
||||
|
||||
['une table inconnue, vide à chaque tour', (plan) => {
|
||||
plan.tables.push(ETRANGER);
|
||||
plan.tours.forEach((listes) => listes.push([]));
|
||||
}, undefined, [{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' }]],
|
||||
['une table inconnue déclarée deux fois, vide à chaque tour', (plan) => {
|
||||
plan.tables.push(ETRANGER, ETRANGER);
|
||||
plan.tours.forEach((listes) => listes.push([], []));
|
||||
}, undefined, [{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' }]],
|
||||
['une table déclarée deux fois, vide à sa seconde position', (plan) => {
|
||||
plan.tables.push(table(3));
|
||||
plan.tours.forEach((listes) => listes.push([]));
|
||||
}, undefined, [{ code: 'TABLE_INCONNUE', table: table(3), detail: 'doublon' }]],
|
||||
// La table de numéro 1 déclarée une seconde fois. Au tour 1, sa seconde
|
||||
// liste reçoit ses trois convives et 4 : quatre entrées, qu'aucune capacité
|
||||
// ne borne. Au tour 2, elle reçoit 6, réservé à la table de numéro 1 : la
|
||||
// réservation ne s'y honore pas.
|
||||
['une table déclarée deux fois, sa seconde liste peuplée', (plan) => {
|
||||
plan.tables.push(table(1));
|
||||
plan.tours.forEach((listes) => listes.push([]));
|
||||
retirer(plan.tours[0], participant(4));
|
||||
plan.tours[0][4] = [...plan.tours[0][0], participant(4)];
|
||||
plan.tours[0][0] = [];
|
||||
retirer(plan.tours[1], participant(6));
|
||||
plan.tours[1][4].push(participant(6));
|
||||
}, undefined, [
|
||||
{ code: 'TABLE_INCONNUE', table: table(1), detail: 'doublon' },
|
||||
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
|
||||
]],
|
||||
['une table déclarée à la place d’une autre', (plan) => {
|
||||
plan.tables[1] = table(1);
|
||||
}, undefined, [
|
||||
{ code: 'TABLE_INCONNUE', table: table(1), detail: 'doublon' },
|
||||
{ code: 'TABLE_INCONNUE', table: table(2), detail: 'absente' },
|
||||
]],
|
||||
['une table non déclarée, ses occupants en réserve permise', (plan) => {
|
||||
plan.tables.pop();
|
||||
plan.tours.forEach((listes, r) => plan.reserves[r].push(...listes.pop()));
|
||||
}, { reserveAutorisee: true }, [{ code: 'TABLE_INCONNUE', table: table(4), detail: 'absente' }]],
|
||||
['une liste de trop au tour 3', (plan) => {
|
||||
plan.tours[2].push([]);
|
||||
}, undefined, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]],
|
||||
['une liste de moins au tour 3, ses occupants en réserve permise', (plan) => {
|
||||
plan.reserves[2].push(...plan.tours[2].pop());
|
||||
}, { reserveAutorisee: true }, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]],
|
||||
// Le nombre de listes d'un tour se lit avant ses présences, dans l'ordre où
|
||||
// indexerPlan refuse : l'épreuve croisée compare les deux sur ce plan.
|
||||
['une liste de trop au tour 2, un inconnu en réserve au même tour', (plan) => {
|
||||
plan.tours[1].push([]);
|
||||
plan.reserves[1].push(ETRANGER);
|
||||
}, undefined, [
|
||||
{ code: 'TABLE_INCONNUE', tour: 2, detail: 'listes' },
|
||||
{ code: 'INCONNU', participant: ETRANGER, tour: 2 },
|
||||
]],
|
||||
|
||||
['un inconnu en réserve', (plan) => {
|
||||
plan.reserves[1].push(ETRANGER);
|
||||
}, undefined, [{ code: 'INCONNU', participant: ETRANGER, tour: 2 }]],
|
||||
|
||||
['l’exclu en réserve', (plan) => {
|
||||
plan.reserves[0].push(participant(13));
|
||||
}, undefined, [{ code: 'EXCLU_PLACE', participant: participant(13), tour: 1 }]],
|
||||
['l’exclu à la place de 11, passé en réserve permise', (plan) => {
|
||||
plan.tours[3][0] = participants(5, 7, 13);
|
||||
plan.reserves[3].push(participant(11));
|
||||
}, { reserveAutorisee: true }, [{ code: 'EXCLU_PLACE', participant: participant(13), tour: 4 }]],
|
||||
|
||||
// 6, assis à la table de numéro 1 au tour 2, occupe aussi la place de 11 à
|
||||
// la table de numéro 2 ; 11 attend en réserve.
|
||||
['à deux tables au tour 2', (plan) => {
|
||||
plan.tours[1][1] = participants(3, 6, 8);
|
||||
plan.reserves[1].push(participant(11));
|
||||
}, { reserveAutorisee: true }, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]],
|
||||
['deux sièges à la même table', (plan) => {
|
||||
plan.tours[1][0] = participants(6, 6, 12);
|
||||
plan.reserves[1].push(participant(9));
|
||||
}, { reserveAutorisee: true }, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]],
|
||||
['assis et en réserve', (plan) => {
|
||||
plan.reserves[1].push(participant(6));
|
||||
}, undefined, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]],
|
||||
|
||||
['absent d’un tour', (plan) => {
|
||||
retirer(plan.tours[2], participant(7));
|
||||
}, undefined, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
|
||||
['absent d’un tour, la réserve permise', (plan) => {
|
||||
retirer(plan.tours[2], participant(7));
|
||||
}, { reserveAutorisee: true }, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
|
||||
['en réserve, la réserve interdite par défaut', (plan) => {
|
||||
retirer(plan.tours[2], participant(7));
|
||||
plan.reserves[2].push(participant(7));
|
||||
}, undefined, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
|
||||
['en réserve, la réserve interdite nommément', (plan) => {
|
||||
retirer(plan.tours[2], participant(7));
|
||||
plan.reserves[2].push(participant(7));
|
||||
}, { reserveAutorisee: false }, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]],
|
||||
|
||||
['quatre à la table de numéro 2, de trois places', (plan) => {
|
||||
retirer(plan.tours[3], participant(11));
|
||||
plan.tours[3][1].push(participant(11));
|
||||
}, undefined, [{ code: 'CAPACITE', table: table(2), tour: 4 }]],
|
||||
|
||||
// 6, réservé à la table de numéro 1 au tour 2, échange sa place avec 3,
|
||||
// assis à la table de numéro 2.
|
||||
['le réservé échangé avec un convive d’une autre table', (plan) => {
|
||||
plan.tours[1][0] = participants(3, 9, 12);
|
||||
plan.tours[1][1] = participants(6, 8, 11);
|
||||
}, undefined, [{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }]],
|
||||
['le réservé en réserve permise', (plan) => {
|
||||
retirer(plan.tours[1], participant(6));
|
||||
plan.reserves[1].push(participant(6));
|
||||
}, { reserveAutorisee: true }, [{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }]],
|
||||
// La même réserve, interdite : 6 n'est pas assis, et sa réservation n'est
|
||||
// pas honorée. Deux règles enfreintes : NON_ASSIS, puis RESERVATION.
|
||||
['le réservé en réserve, la réserve interdite', (plan) => {
|
||||
retirer(plan.tours[1], participant(6));
|
||||
plan.reserves[1].push(participant(6));
|
||||
}, undefined, [
|
||||
{ code: 'NON_ASSIS', participant: participant(6), tour: 2 },
|
||||
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
|
||||
]],
|
||||
];
|
||||
|
||||
describe('verifierInvariants : le plan parfait de la petite démonstration (§ 15.3)', () => {
|
||||
test('ne viole rien, la réserve interdite ou permise', () => {
|
||||
assert.deepEqual(verifierInvariants(INSTANCE, PLAN_PARFAIT), []);
|
||||
assert.deepEqual(verifierInvariants(INSTANCE, PLAN_PARFAIT, { reserveAutorisee: true }), []);
|
||||
});
|
||||
|
||||
test('ne viole rien, ses tables déclarées dans un autre ordre et ses listes dans le désordre', () => {
|
||||
// L'ordre de l'instance, 23, 21, 24, 22 : chaque liste suit sa table.
|
||||
const ordre = [2, 0, 3, 1];
|
||||
const permute = gelerPlan({
|
||||
tables: ordre.map((i) => PLAN_PARFAIT.tables[i]),
|
||||
tours: PLAN_PARFAIT.tours.map((listes) => ordre.map((i) => [...listes[i]].reverse())),
|
||||
reserves: [[], [], [], []],
|
||||
});
|
||||
assert.deepEqual(verifierInvariants(INSTANCE, permute), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifierInvariants : chaque mutation, sa violation et aucune autre (§ 14.12)', () => {
|
||||
test('chaque code a sa mutation, et chaque mutation produit exactement ses violations', () => {
|
||||
const seuls = new Set(MUTATIONS
|
||||
.filter(([, , , attendu]) => attendu.length === 1)
|
||||
.map(([, , , [{ code }]]) => code));
|
||||
assert.deepEqual(CODES.filter((code) => !seuls.has(code)), [], 'code sans mutation qui le produise seul');
|
||||
const cas = MUTATIONS.map(([libelle, modifier, options, attendu]) => [
|
||||
libelle,
|
||||
() => apresMutation(modifier, options),
|
||||
attendu,
|
||||
]);
|
||||
assert.deepEqual(ecarts(cas), []);
|
||||
});
|
||||
|
||||
test('NON_ASSIS : chaque participant retiré de chaque tour, et lui seul, la réserve interdite ou permise', () => {
|
||||
// La réserve est une liste du plan (§ 8.9) : un participant qui n'y
|
||||
// figure pas plus qu'à une table n'attend pas, il manque.
|
||||
const cas = [];
|
||||
for (const reserveAutorisee of [false, true]) {
|
||||
for (let tour = 1; tour <= 4; tour += 1) {
|
||||
for (let numero = 1; numero <= 12; numero += 1) {
|
||||
const id = participant(numero);
|
||||
cas.push([
|
||||
`${numero} retiré du tour ${tour}, réserve ${reserveAutorisee ? 'permise' : 'interdite'}`,
|
||||
() => apresMutation((plan) => retirer(plan.tours[tour - 1], id), { reserveAutorisee }),
|
||||
[{ code: 'NON_ASSIS', participant: id, tour }],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(cas.length, 96);
|
||||
assert.deepEqual(ecarts(cas), []);
|
||||
});
|
||||
|
||||
test('DOUBLE_PLACE : chaque participant ajouté à la réserve de chaque tour, et lui seul', () => {
|
||||
const cas = [];
|
||||
for (let tour = 1; tour <= 4; tour += 1) {
|
||||
for (let numero = 1; numero <= 12; numero += 1) {
|
||||
const id = participant(numero);
|
||||
cas.push([
|
||||
`${numero} en réserve au tour ${tour}`,
|
||||
() => apresMutation((plan) => plan.reserves[tour - 1].push(id)),
|
||||
[{ code: 'DOUBLE_PLACE', participant: id, tour }],
|
||||
]);
|
||||
}
|
||||
}
|
||||
assert.equal(cas.length, 48);
|
||||
assert.deepEqual(ecarts(cas), []);
|
||||
});
|
||||
|
||||
test('CAPACITE : chaque table de chaque tour, nommée par son identifiant', () => {
|
||||
// Le dernier convive de la table suivante rejoint la table examinée. Le
|
||||
// dernier n'est jamais 6 au tour 2 : la réservation reste honorée.
|
||||
const cas = [];
|
||||
for (let tour = 1; tour <= 4; tour += 1) {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
cas.push([
|
||||
`tour ${tour}, table de numéro ${i + 1}`,
|
||||
() => apresMutation((plan) => {
|
||||
const listes = plan.tours[tour - 1];
|
||||
listes[i].push(listes[(i + 1) % 4].pop());
|
||||
}),
|
||||
[{ code: 'CAPACITE', table: table(i + 1), tour }],
|
||||
]);
|
||||
}
|
||||
}
|
||||
assert.equal(cas.length, 16);
|
||||
assert.deepEqual(ecarts(cas), []);
|
||||
});
|
||||
|
||||
test('RESERVATION : chaque participant, réservé à chaque tour, à sa table puis à la suivante', () => {
|
||||
// Une seule réservation de tour désigné par configuration : à la table
|
||||
// où le plan parfait assoit la personne, elle est honorée ; à la table
|
||||
// de numéro suivant, elle ne l'est pas.
|
||||
const cas = [];
|
||||
for (let tour = 1; tour <= 4; tour += 1) {
|
||||
for (let numero = 1; numero <= 12; numero += 1) {
|
||||
const id = participant(numero);
|
||||
const sienne = PLAN_PARFAIT.tours[tour - 1].findIndex((liste) => liste.includes(id)) + 1;
|
||||
const ailleurs = (sienne % 4) + 1;
|
||||
const reserve = (numeroTable) => normaliser(configuration([
|
||||
{ participant: id, table: table(numeroTable), portee: 'tour', tour },
|
||||
]));
|
||||
cas.push([
|
||||
`${numero} réservé à sa table au tour ${tour}`,
|
||||
() => verifierInvariants(reserve(sienne), PLAN_PARFAIT),
|
||||
[],
|
||||
]);
|
||||
cas.push([
|
||||
`${numero} réservé à une autre table au tour ${tour}`,
|
||||
() => verifierInvariants(reserve(ailleurs), PLAN_PARFAIT),
|
||||
[{ code: 'RESERVATION', participant: id, table: table(ailleurs), tour }],
|
||||
]);
|
||||
}
|
||||
}
|
||||
assert.equal(cas.length, 96);
|
||||
assert.deepEqual(ecarts(cas), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifierInvariants : ce que chaque code couvre', () => {
|
||||
test('une personne en réserve n’est signalée que si la réserve est interdite, une personne absente l’est toujours (point d’attention 1)', () => {
|
||||
const enReserve = (plan) => {
|
||||
retirer(plan.tours[2], participant(7));
|
||||
plan.reserves[2].push(participant(7));
|
||||
};
|
||||
const absente = (plan) => retirer(plan.tours[2], participant(7));
|
||||
const nonAssis = [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }];
|
||||
assert.deepEqual(apresMutation(enReserve), nonAssis);
|
||||
assert.deepEqual(apresMutation(enReserve, { reserveAutorisee: true }), []);
|
||||
assert.deepEqual(apresMutation(absente), nonAssis);
|
||||
assert.deepEqual(apresMutation(absente, { reserveAutorisee: true }), nonAssis);
|
||||
});
|
||||
|
||||
test('une réservation « tous les tours » se juge à chacun des tours', () => {
|
||||
// 1 est réservé à la table de numéro 1 à chaque tour ; le plan parfait
|
||||
// l'y assoit au tour 1, puis aux tables de numéro 4, 3 et 2.
|
||||
const instance = normaliser(configuration([
|
||||
RESERVATION_HONOREE,
|
||||
{ participant: participant(1), table: table(1), portee: 'tous' },
|
||||
]));
|
||||
assert.deepEqual(verifierInvariants(instance, PLAN_PARFAIT), [2, 3, 4].map((tour) => (
|
||||
{ code: 'RESERVATION', participant: participant(1), table: table(1), tour })));
|
||||
});
|
||||
|
||||
test('CAPACITE se juge contre la capacité de la table que la liste désigne', () => {
|
||||
// Deux tables de capacités différentes, que le plan déclare dans l'ordre
|
||||
// inverse de l'instance.
|
||||
const instance = normaliser({
|
||||
participants: [1, 2, 3, 4, 5, 6].map((id) => ({ id, nom: `P${id}`, appartenance: null })),
|
||||
tables: [{ id: 7, numero: 1, capacite: 2 }, { id: 5, numero: 2, capacite: 4 }],
|
||||
tours: 1,
|
||||
reservations: [],
|
||||
contraintes: SANS_CONTRAINTE,
|
||||
});
|
||||
const plan = (listes) => gelerPlan({ tables: [5, 7], tours: [listes], reserves: [[]] });
|
||||
assert.deepEqual(verifierInvariants(instance, plan([[1, 2, 3, 4], [5, 6]])), []);
|
||||
assert.deepEqual(
|
||||
verifierInvariants(instance, plan([[1, 2, 3], [4, 5, 6]])),
|
||||
[{ code: 'CAPACITE', table: 7, tour: 1 }],
|
||||
);
|
||||
});
|
||||
|
||||
test('une table inconnue : ses occupants sont placés, hors capacité, sans réservation honorée', () => {
|
||||
// La table de numéro 1 déclarée sous un identifiant étranger : il est
|
||||
// inconnu, 21 absente. Ses occupants restent placés ; au tour 1, elle en
|
||||
// reçoit quatre ; au tour 2, 6 y est assis alors que sa réservation le
|
||||
// fixe à la table 21.
|
||||
const obtenu = apresMutation((plan) => {
|
||||
plan.tables[0] = ETRANGER;
|
||||
retirer(plan.tours[0], participant(4));
|
||||
plan.tours[0][0].push(participant(4));
|
||||
});
|
||||
assert.deepEqual(obtenu, [
|
||||
{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' },
|
||||
{ code: 'TABLE_INCONNUE', table: table(1), detail: 'absente' },
|
||||
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('les occupants d’une liste au-delà des tables déclarées sont placés', () => {
|
||||
const obtenu = apresMutation((plan) => {
|
||||
retirer(plan.tours[2], participant(7));
|
||||
plan.tours[2].push([participant(7)]);
|
||||
});
|
||||
assert.deepEqual(obtenu, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]);
|
||||
});
|
||||
|
||||
test('CAPACITE compte chaque entrée de la liste, étrangère ou répétée', () => {
|
||||
// Le siège est la position dans la liste : un id étranger ou répété
|
||||
// occupe un siège comme un autre.
|
||||
const obtenu = apresMutation((plan) => {
|
||||
plan.tours[0][0].push(ETRANGER);
|
||||
plan.tours[1][0].push(participant(12));
|
||||
});
|
||||
assert.deepEqual(obtenu, [
|
||||
{ code: 'INCONNU', participant: ETRANGER, tour: 1 },
|
||||
{ code: 'CAPACITE', table: table(1), tour: 1 },
|
||||
{ code: 'DOUBLE_PLACE', participant: participant(12), tour: 2 },
|
||||
{ code: 'CAPACITE', table: table(1), tour: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('un participant présent trois fois dans un tour se signale une fois', () => {
|
||||
// 6 occupe aussi la place de 11 à la table de numéro 2 et figure en
|
||||
// réserve ; 11 attend en réserve.
|
||||
const obtenu = apresMutation((plan) => {
|
||||
plan.tours[1][1] = participants(3, 6, 8);
|
||||
plan.reserves[1].push(participant(6), participant(11));
|
||||
}, { reserveAutorisee: true });
|
||||
assert.deepEqual(obtenu, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]);
|
||||
});
|
||||
|
||||
test('un identifiant étranger à l’instance se signale une fois par tour où il figure', () => {
|
||||
const obtenu = apresMutation((plan) => {
|
||||
plan.reserves[0].push(ETRANGER, participant(13), ETRANGER);
|
||||
plan.tours[0][0].push(participant(13));
|
||||
retirer(plan.tours[0], participant(3));
|
||||
plan.reserves[0].push(participant(3));
|
||||
plan.reserves[2].push(ETRANGER);
|
||||
}, { reserveAutorisee: true });
|
||||
assert.deepEqual(obtenu, [
|
||||
{ code: 'EXCLU_PLACE', participant: participant(13), tour: 1 },
|
||||
{ code: 'INCONNU', participant: ETRANGER, tour: 1 },
|
||||
{ code: 'INCONNU', participant: ETRANGER, tour: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('une table déclarée trois fois se signale une fois', () => {
|
||||
const obtenu = apresMutation((plan) => {
|
||||
plan.tables.push(table(2), table(2));
|
||||
plan.tours.forEach((listes) => listes.push([], []));
|
||||
});
|
||||
assert.deepEqual(obtenu, [{ code: 'TABLE_INCONNUE', table: table(2), detail: 'doublon' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifierInvariants : la liste rendue', () => {
|
||||
test('les violations se rangent dans l’ordre de lecture du plan', () => {
|
||||
// Un tour de moins : seuls les tours 1 à 3 sont lus. Une table inconnue,
|
||||
// vide. Au tour 2 : une liste de trop, 6 échangé avec 3, 5 retiré, 2
|
||||
// passé de la table de numéro 3 à la table de numéro 4, et en réserve un
|
||||
// étranger, l'exclu et 12. Au tour 3, une liste de trop encore. Le nombre
|
||||
// de listes ouvre les violations de son tour : celle du tour 2 précède ses
|
||||
// présences, celle du tour 3 suit tout le tour 2.
|
||||
const obtenu = apresMutation((plan) => {
|
||||
plan.tours.pop();
|
||||
plan.reserves.pop();
|
||||
plan.tables.push(ETRANGER);
|
||||
plan.tours.forEach((listes) => listes.push([]));
|
||||
plan.tours[1][0] = participants(3, 9, 12);
|
||||
plan.tours[1][1] = participants(6, 8, 11);
|
||||
plan.tours[1][2] = participants(10);
|
||||
plan.tours[1][3] = participants(1, 2, 4, 7);
|
||||
plan.reserves[1].push(ETRANGER, participant(13), participant(12));
|
||||
plan.tours[1].push([]);
|
||||
plan.tours[2].push([]);
|
||||
});
|
||||
assert.deepEqual(obtenu, [
|
||||
{ code: 'TOURS' },
|
||||
{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' },
|
||||
{ code: 'TABLE_INCONNUE', tour: 2, detail: 'listes' },
|
||||
{ code: 'INCONNU', participant: ETRANGER, tour: 2 },
|
||||
{ code: 'EXCLU_PLACE', participant: participant(13), tour: 2 },
|
||||
{ code: 'DOUBLE_PLACE', participant: participant(12), tour: 2 },
|
||||
{ code: 'CAPACITE', table: table(4), tour: 2 },
|
||||
{ code: 'NON_ASSIS', participant: participant(5), tour: 2 },
|
||||
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
|
||||
{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('les tables absentes se rangent dans l’ordre de l’instance', () => {
|
||||
// Les tables de numéro 2 et 3 retirées du plan, leurs occupants en
|
||||
// réserve permise. L'instance range 23 avant 22, à l'inverse du plan et
|
||||
// des numéros.
|
||||
const obtenu = apresMutation((plan) => {
|
||||
plan.tables.splice(1, 2);
|
||||
plan.tours.forEach((listes, r) => plan.reserves[r].push(...listes.splice(1, 2).flat()));
|
||||
}, { reserveAutorisee: true });
|
||||
assert.deepEqual(obtenu, [
|
||||
{ code: 'TABLE_INCONNUE', table: table(3), detail: 'absente' },
|
||||
{ code: 'TABLE_INCONNUE', table: table(2), detail: 'absente' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('NON_ASSIS et RESERVATION se rangent participant par participant', () => {
|
||||
// Au tour 2, 6 échangé avec 3, et 7 retiré : la violation de 6 précède
|
||||
// celle de 7, quel que soit son code.
|
||||
const obtenu = apresMutation((plan) => {
|
||||
plan.tours[1][0] = participants(3, 9, 12);
|
||||
plan.tours[1][1] = participants(6, 8, 11);
|
||||
retirer(plan.tours[1], participant(7));
|
||||
});
|
||||
assert.deepEqual(obtenu, [
|
||||
{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 },
|
||||
{ code: 'NON_ASSIS', participant: participant(7), tour: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('ne modifie ni le plan ni l’instance, sous chaque mutation', () => {
|
||||
// planMute rend un plan gelé. L'instance est bâtie pour chaque cas et
|
||||
// copiée avant tout appel : la copie d'une instance déjà passée au
|
||||
// vérificateur porterait ce qu'il y aurait écrit, et la comparaison ne le
|
||||
// verrait pas.
|
||||
const cas = MUTATIONS.map(([libelle, modifier, options]) => [libelle, () => {
|
||||
const instance = normaliser(configuration());
|
||||
const copie = structuredClone(instance);
|
||||
verifierInvariants(instance, planMute(modifier), options);
|
||||
assert.deepEqual(instance, copie, 'instance modifiée');
|
||||
return 'intacte';
|
||||
}, 'intacte']);
|
||||
assert.deepEqual(ecarts(cas), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifierInvariants et indexerPlan : une même lecture du plan (§ 8.9)', () => {
|
||||
// Code sous lequel indexerPlan refuse chaque violation de forme, désignée
|
||||
// par son code, suivi de son detail quand elle en porte un. CAPACITE et
|
||||
// RESERVATION n'en ont pas : indexerPlan n'examine ni les capacités ni les
|
||||
// réservations.
|
||||
const cle = ({ code, detail }) => (detail === undefined ? code : `${code} ${detail}`);
|
||||
const REFUS = new Map([
|
||||
['TOURS', 'PLAN_TOURS'],
|
||||
['TABLE_INCONNUE inconnue', 'PLAN_TABLE_INCONNUE'],
|
||||
['TABLE_INCONNUE doublon', 'PLAN_TABLE_DOUBLON'],
|
||||
['TABLE_INCONNUE absente', 'PLAN_TABLE_ABSENTE'],
|
||||
['TABLE_INCONNUE listes', 'PLAN_LISTES'],
|
||||
['INCONNU', 'PLAN_INCONNU'],
|
||||
['EXCLU_PLACE', 'PLAN_EXCLU_PLACE'],
|
||||
['DOUBLE_PLACE', 'PLAN_DOUBLE_PLACE'],
|
||||
['NON_ASSIS', 'PLAN_NON_ASSIS'],
|
||||
]);
|
||||
const SANS_REFUS = new Set(['CAPACITE', 'RESERVATION']);
|
||||
|
||||
// Ce qu'indexerPlan fait du plan selon verifierInvariants, la réserve
|
||||
// permise comme la forme indexée la porte : refuser sous le code de la
|
||||
// première violation de forme, en désignant le même participant, la même
|
||||
// table et le même tour ; accepter quand il n'y en a aucune.
|
||||
function refusAttendu(plan) {
|
||||
const forme = verifierInvariants(INSTANCE, plan, { reserveAutorisee: true })
|
||||
.filter(({ code }) => !SANS_REFUS.has(code));
|
||||
if (forme.length === 0) return 'accepté';
|
||||
const [premiere] = forme;
|
||||
const { participant: id, table: idTable, tour } = premiere;
|
||||
return { code: REFUS.get(cle(premiere)), participant: id, table: idTable, tour };
|
||||
}
|
||||
|
||||
// Ce qu'indexerPlan fait du plan, sous la même forme. Une erreur qui n'est
|
||||
// pas un refus du contenu se propage.
|
||||
function refusObtenu(plan) {
|
||||
try {
|
||||
indexerPlan(INSTANCE, plan);
|
||||
return 'accepté';
|
||||
} catch (erreur) {
|
||||
if (!(erreur instanceof ErreurConfiguration)) throw erreur;
|
||||
const { participant: id, table: idTable, tour } = erreur.details;
|
||||
return { code: erreur.code, participant: id, table: idTable, tour };
|
||||
}
|
||||
}
|
||||
|
||||
// Le plan parfait, chaque plan du tableau des mutations, et pour chaque
|
||||
// participant à chaque tour : retiré, passé en réserve, ajouté à la réserve.
|
||||
function plansEprouves() {
|
||||
const plans = [['le plan parfait', PLAN_PARFAIT]];
|
||||
for (const [libelle, modifier] of MUTATIONS) plans.push([libelle, planMute(modifier)]);
|
||||
for (let tour = 1; tour <= 4; tour += 1) {
|
||||
for (let numero = 1; numero <= 12; numero += 1) {
|
||||
const id = participant(numero);
|
||||
plans.push([`${numero} retiré du tour ${tour}`, planMute((plan) => {
|
||||
retirer(plan.tours[tour - 1], id);
|
||||
})]);
|
||||
plans.push([`${numero} passé en réserve au tour ${tour}`, planMute((plan) => {
|
||||
retirer(plan.tours[tour - 1], id);
|
||||
plan.reserves[tour - 1].push(id);
|
||||
})]);
|
||||
plans.push([`${numero} ajouté à la réserve du tour ${tour}`, planMute((plan) => {
|
||||
plan.reserves[tour - 1].push(id);
|
||||
})]);
|
||||
}
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
test('indexerPlan refuse la première violation de forme que relève verifierInvariants, et accepte le plan qui n’en a aucune', () => {
|
||||
const plans = plansEprouves();
|
||||
const acceptes = plans.filter(([, plan]) => refusAttendu(plan) === 'accepté').length;
|
||||
assert.ok(acceptes > 0 && acceptes < plans.length, `${acceptes} plans acceptés sur ${plans.length}`);
|
||||
const cas = plans.map(([libelle, plan]) => [libelle, () => refusObtenu(plan), refusAttendu(plan)]);
|
||||
assert.deepEqual(ecarts(cas), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifierIndicateurs : la ligne de décomposition (§ 12.3, § 12.10.5)', () => {
|
||||
// Chiffres du plan parfait, dans l'ordre canonique : N − 1 = 11, puis 8 pour
|
||||
// chacun. Chaque inégalité y tient, les deux dernières avec égalité.
|
||||
const N = 12;
|
||||
const huit = () => Array.from({ length: N }, () => 8);
|
||||
// Copie de huit() dont la case index prend la valeur donnée.
|
||||
const sauf = (index, valeur) => huit().map((v, p) => (p === index ? valeur : v));
|
||||
|
||||
// Mutations d'une seule case, de rang index : [libellé, rencontres,
|
||||
// a priori, réalisé, code attendu]. null en a priori : inconnu.
|
||||
const mutations = (index) => [
|
||||
['a priori 12 > N − 1', huit(), sauf(index, 12), huit(), 'PLAFOND_A_PRIORI'],
|
||||
['a priori 7 < réalisé 8', huit(), sauf(index, 7), huit(), 'ORDRE_PLAFONDS'],
|
||||
['réalisé 9 > a priori 8', huit(), huit(), sauf(index, 9), 'ORDRE_PLAFONDS'],
|
||||
['rencontres 9 > réalisé 8', sauf(index, 9), huit(), huit(), 'DEPASSEMENT'],
|
||||
['réalisé 7 < rencontres 8', huit(), huit(), sauf(index, 7), 'DEPASSEMENT'],
|
||||
['a priori inconnu, réalisé 12 > N − 1', huit(), null, sauf(index, 12), 'PLAFOND_REALISE'],
|
||||
['a priori inconnu, rencontres 9 > réalisé 8', sauf(index, 9), null, huit(), 'DEPASSEMENT'],
|
||||
];
|
||||
|
||||
test('les chiffres du plan parfait tiennent, le plafond a priori connu ou inconnu', () => {
|
||||
assert.deepEqual(verifierIndicateurs({ rencontres: huit() }, huit(), huit()), []);
|
||||
assert.deepEqual(verifierIndicateurs({ rencontres: huit() }, null, huit()), []);
|
||||
});
|
||||
|
||||
test('ne modifie ni les mesures ni les plafonds, que la ligne tienne ou qu’elle tombe', () => {
|
||||
// Entrées gelées : une écriture, un tri en place ou un champ ajouté y lève
|
||||
// TypeError. La copie, prise avant l'appel, verrait de plus ce qu'aurait
|
||||
// changé une entrée qu'on aurait oublié de geler.
|
||||
const cas = [
|
||||
['la ligne tient', huit(), huit(), huit()],
|
||||
['la ligne tient, a priori inconnu', huit(), null, huit()],
|
||||
...mutations(5).map(([libelle, rencontres, aPriori, realises]) => [libelle, rencontres, aPriori, realises]),
|
||||
].map(([libelle, rencontres, aPriori, realises]) => [libelle, () => {
|
||||
const mesures = Object.freeze({ rencontres: Object.freeze(rencontres) });
|
||||
if (aPriori !== null) Object.freeze(aPriori);
|
||||
Object.freeze(realises);
|
||||
const copie = structuredClone({ mesures, aPriori, realises });
|
||||
verifierIndicateurs(mesures, aPriori, realises);
|
||||
assert.deepEqual({ mesures, aPriori, realises }, copie);
|
||||
return 'intactes';
|
||||
}, 'intactes']);
|
||||
assert.deepEqual(ecarts(cas), []);
|
||||
});
|
||||
|
||||
test('chaque mutation fait tomber une inégalité de la ligne, et aucune autre', () => {
|
||||
const cas = mutations(5).map(([libelle, rencontres, aPriori, realises, code]) => [
|
||||
libelle,
|
||||
() => verifierIndicateurs({ rencontres }, aPriori, realises),
|
||||
[{ code, index: 5 }],
|
||||
]);
|
||||
assert.deepEqual(ecarts(cas), []);
|
||||
});
|
||||
|
||||
test('chaque personne est éprouvée, sur chaque inégalité', () => {
|
||||
const cas = [];
|
||||
for (let index = 0; index < N; index += 1) {
|
||||
for (const [libelle, rencontres, aPriori, realises, code] of mutations(index)) {
|
||||
cas.push([
|
||||
`${libelle}, rang ${index}`,
|
||||
() => verifierIndicateurs({ rencontres }, aPriori, realises),
|
||||
[{ code, index }],
|
||||
]);
|
||||
}
|
||||
}
|
||||
assert.equal(cas.length, N * 7);
|
||||
assert.deepEqual(ecarts(cas), []);
|
||||
});
|
||||
|
||||
test('chaque inégalité est large : l’égalité tient sur toute la ligne', () => {
|
||||
const onze = Array.from({ length: N }, () => 11);
|
||||
assert.deepEqual(verifierIndicateurs({ rencontres: onze }, onze, onze), []);
|
||||
assert.deepEqual(verifierIndicateurs({ rencontres: onze }, null, onze), []);
|
||||
});
|
||||
|
||||
test('les violations se rangent par index croissant, puis de gauche à droite sur la ligne', () => {
|
||||
// Au rang 2, les trois inégalités tombent : a priori 12 > N − 1, réalisé
|
||||
// 13 > a priori 12, rencontres 14 > réalisé 13. Au rang 7, la dernière
|
||||
// seule : rencontres 9 > réalisé 8.
|
||||
const rencontres = sauf(2, 14);
|
||||
rencontres[7] = 9;
|
||||
const realises = sauf(2, 13);
|
||||
assert.deepEqual(verifierIndicateurs({ rencontres }, sauf(2, 12), realises), [
|
||||
{ code: 'PLAFOND_A_PRIORI', index: 2 },
|
||||
{ code: 'ORDRE_PLAFONDS', index: 2 },
|
||||
{ code: 'DEPASSEMENT', index: 2 },
|
||||
{ code: 'DEPASSEMENT', index: 7 },
|
||||
]);
|
||||
assert.deepEqual(verifierIndicateurs({ rencontres }, null, realises), [
|
||||
{ code: 'PLAFOND_REALISE', index: 2 },
|
||||
{ code: 'DEPASSEMENT', index: 2 },
|
||||
{ code: 'DEPASSEMENT', index: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('les tableaux typés du moteur se lisent comme des listes', () => {
|
||||
const type = (valeurs) => Int32Array.from(valeurs);
|
||||
assert.deepEqual(verifierIndicateurs({ rencontres: type(huit()) }, type(huit()), type(huit())), []);
|
||||
assert.deepEqual(
|
||||
verifierIndicateurs({ rencontres: type(huit()) }, type(sauf(3, 7)), type(huit())),
|
||||
[{ code: 'ORDRE_PLAFONDS', index: 3 }],
|
||||
);
|
||||
});
|
||||
|
||||
test('une valeur qui n’est pas un nombre fait tomber les inégalités qui la lisent', () => {
|
||||
// Converti, un texte tiendrait l'inégalité face à un 8 : '8' en terme de
|
||||
// droite, '9' en terme de gauche. Chaque côté a donc son contrôle de type
|
||||
// éprouvé.
|
||||
const cas = [
|
||||
['rencontres NaN', sauf(3, Number.NaN), huit(), huit(), ['DEPASSEMENT']],
|
||||
['réalisé NaN', huit(), huit(), sauf(3, Number.NaN), ['ORDRE_PLAFONDS', 'DEPASSEMENT']],
|
||||
['a priori NaN', huit(), sauf(3, Number.NaN), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']],
|
||||
['a priori null', huit(), sauf(3, null), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']],
|
||||
['réalisé indéfini', huit(), huit(), sauf(3, undefined), ['ORDRE_PLAFONDS', 'DEPASSEMENT']],
|
||||
['rencontres en texte', sauf(3, '8'), huit(), huit(), ['DEPASSEMENT']],
|
||||
['réalisé en texte', huit(), huit(), sauf(3, '9'), ['ORDRE_PLAFONDS', 'DEPASSEMENT']],
|
||||
['a priori en texte', huit(), sauf(3, '9'), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']],
|
||||
['a priori inconnu, réalisé null', huit(), null, sauf(3, null), ['PLAFOND_REALISE', 'DEPASSEMENT']],
|
||||
].map(([libelle, rencontres, aPriori, realises, codes]) => [
|
||||
libelle,
|
||||
() => verifierIndicateurs({ rencontres }, aPriori, realises),
|
||||
codes.map((code) => ({ code, index: 3 })),
|
||||
]);
|
||||
assert.deepEqual(ecarts(cas), []);
|
||||
});
|
||||
|
||||
test('un tableau de plafonds d’une autre longueur que les rencontres lève RangeError', () => {
|
||||
const court = huit().slice(1);
|
||||
const long = [...huit(), 8];
|
||||
assert.throws(() => verifierIndicateurs({ rencontres: huit() }, huit(), court), RangeError);
|
||||
assert.throws(() => verifierIndicateurs({ rencontres: huit() }, long, huit()), RangeError);
|
||||
assert.throws(() => verifierIndicateurs({ rencontres: huit() }, null, long), RangeError);
|
||||
});
|
||||
|
||||
test('un plafond qui n’est pas une liste lève TypeError, et seul null dit l’a priori inconnu', () => {
|
||||
// undefined est la valeur d'un champ manquant : lu comme l'a priori
|
||||
// inconnu, il ôterait deux inégalités à la garde sans rien signaler.
|
||||
const rencontres = huit();
|
||||
for (const valeur of [undefined, 8, {}]) {
|
||||
assert.throws(() => verifierIndicateurs({ rencontres }, valeur, huit()),
|
||||
{ name: 'TypeError', message: /plafondsAPriori/ }, `a priori ${String(valeur)}`);
|
||||
}
|
||||
for (const valeur of [undefined, null, 8, {}]) {
|
||||
assert.throws(() => verifierIndicateurs({ rencontres }, huit(), valeur),
|
||||
{ name: 'TypeError', message: /plafondsRealises/ }, `réalisé ${String(valeur)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
8
src/version.genere.js
Normal file
8
src/version.genere.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Engendré par scripts/version.js depuis version.json — ne pas modifier.
|
||||
export const VERSION = Object.freeze({
|
||||
affichee: '2026.10.05.01',
|
||||
technique: '2026.1005.1',
|
||||
});
|
||||
772
test/arborescence.test.js
Normal file
772
test/arborescence.test.js
Normal file
|
|
@ -0,0 +1,772 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Gardes de l'arborescence, lues dans le texte des sources : le moteur et le
|
||||
// générateur de démonstrations n'appellent aucune source non reproductible
|
||||
// (§ 14.7), et ne parcourent les clés d'un objet que par exception nommée
|
||||
// (§ 15.5, point 4) ; le graphe d'imports du moteur et de la géométrie ne
|
||||
// rejoint ni l'interface, ni le stockage, ni une plateforme (§ 13.4) ;
|
||||
// chaque paquet importé est déclaré dans devDependencies ; une valeur du
|
||||
// contrat de données n'a qu'une définition, celle de configuration.js.
|
||||
// Chaque relevé lit le texte entier, commentaires et chaînes compris : un
|
||||
// commentaire qui nomme un appel refusé fait échouer la garde comme l'appel
|
||||
// lui-même. Chaque garde refuse de passer sur un balayage vide (§ 14.2), et
|
||||
// d'autres épreuves la font tourner sur des arbres temporaires qui portent,
|
||||
// à plus d'un niveau de profondeur, les formes qu'elle refuse et des formes
|
||||
// voisines qu'elle admet.
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { isBuiltin } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chargements } from './chargements.js';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
// Écrit dans un répertoire temporaire l'arbre { chemin relatif: contenu }, le
|
||||
// passe à examiner, puis l'efface ; rend ce que rend examiner.
|
||||
function avecArbre(fichiers, examiner) {
|
||||
const racine = mkdtempSync(join(tmpdir(), 'arborescence-'));
|
||||
try {
|
||||
for (const [chemin, contenu] of Object.entries(fichiers)) {
|
||||
mkdirSync(dirname(join(racine, chemin)), { recursive: true });
|
||||
writeFileSync(join(racine, chemin), contenu);
|
||||
}
|
||||
return examiner(racine);
|
||||
} finally {
|
||||
rmSync(racine, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Fichiers d'un arbre, à toute profondeur, en chemins absolus. Un arbre
|
||||
// absent n'en fournit aucun.
|
||||
function fichiersDe(dossier) {
|
||||
try {
|
||||
return readdirSync(dossier, { withFileTypes: true, recursive: true })
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => join(e.parentPath, e.name));
|
||||
} catch (erreur) {
|
||||
if (erreur.code === 'ENOENT') return [];
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Modules d'un arbre : ses fichiers JavaScript, épreuves exclues, triés.
|
||||
const EXTENSIONS_MODULE = new Set(['.js', '.mjs', '.cjs']);
|
||||
const EPREUVE = /\.test\.[cm]?js$/;
|
||||
const modulesDe = (dossier) =>
|
||||
fichiersDe(dossier)
|
||||
.filter((fichier) => EXTENSIONS_MODULE.has(extname(fichier)) && !EPREUVE.test(fichier))
|
||||
.sort();
|
||||
|
||||
// Numéro, à partir de 1, de la ligne qui porte le caractère d'indice donné.
|
||||
const ligneDe = (texte, indice) => texte.slice(0, indice).split('\n').length;
|
||||
|
||||
// Appels dont le résultat change d'une exécution à l'autre, ou d'un poste à
|
||||
// l'autre : localeCompare, Intl et les méthodes toLocale… suivent la langue
|
||||
// et les données régionales du moteur d'exécution (§ 14.7, § 15.5). Chaque
|
||||
// motif à point admet des blancs autour de lui, que la mise en forme
|
||||
// introduit quand elle coupe une chaîne d'appels, et le chaînage optionnel.
|
||||
// Math, Date et performance suivis d'un crochet, accolé ou après « ?. »,
|
||||
// sont refusés eux aussi, quel que soit le membre nommé. new Date se relève
|
||||
// avec ou sans parenthèses, et Date appelée sans new, « Date() » comme
|
||||
// « Date?.() », rend elle aussi l'heure courante. getRandomValues,
|
||||
// randomUUID et les tirages de node:crypto — randomInt, randomBytes,
|
||||
// randomFill — se reconnaissent au nom de la fonction, quel que soit l'objet
|
||||
// qui la porte ou l'import qui la nomme.
|
||||
const SOURCES_NON_REPRODUCTIBLES = [
|
||||
['Math.random', /\bMath\s*\??\.\s*random\b/g],
|
||||
['Math[', /\bMath\s*(?:\?\.)?\s*\[/g],
|
||||
['Date.now', /\bDate\s*\??\.\s*now\b/g],
|
||||
['Date[', /\bDate\s*(?:\?\.)?\s*\[/g],
|
||||
['new Date', /\bnew\s+Date\b/g],
|
||||
['Date()', /(?<!\bnew\s+)\bDate\s*(?:\?\.\s*)?\(/g],
|
||||
['performance.now', /\bperformance\s*\??\.\s*now\b/g],
|
||||
['performance[', /\bperformance\s*(?:\?\.)?\s*\[/g],
|
||||
['crypto.getRandomValues', /\bgetRandomValues\b/g],
|
||||
['crypto.randomUUID', /\brandomUUID\b/g],
|
||||
['localeCompare', /\blocaleCompare\b/g],
|
||||
['toLocale…', /\btoLocale\w*/g],
|
||||
['Intl', /\bIntl\b/g],
|
||||
['crypto.randomInt', /\brandomInt\b/g],
|
||||
['crypto.randomBytes', /\brandomBytes\b/g],
|
||||
['crypto.randomFill', /\brandomFill(?:Sync)?\b/g],
|
||||
];
|
||||
|
||||
// Parcours des clés d'un objet (§ 15.5, point 4) : les clés entières y
|
||||
// viennent d'abord, croissantes, puis les autres dans l'ordre où l'objet les
|
||||
// a reçues. Un résultat qui en dépend change quand l'objet se construit
|
||||
// autrement, sans qu'aucune épreuve ne le voie ; un départage se fait par
|
||||
// identifiant entier. Les motifs à point suivent les règles de
|
||||
// SOURCES_NON_REPRODUCTIBLES, et Object suivi d'un crochet est refusé quel
|
||||
// que soit le membre nommé. for…in se relève sur une variable, un chemin
|
||||
// pointé ou un motif de déstructuration suivi de « in », avec ou sans
|
||||
// déclaration : la boucle à compteur, for…of et l'opérateur in hors d'une
|
||||
// boucle n'en sont pas.
|
||||
const PARCOURS_DE_CLES = [
|
||||
['Object.keys', /\bObject\s*\??\.\s*keys\b/g],
|
||||
['Object.entries', /\bObject\s*\??\.\s*entries\b/g],
|
||||
['Object.values', /\bObject\s*\??\.\s*values\b/g],
|
||||
['Object[', /\bObject\s*(?:\?\.)?\s*\[(?!\s*\])/g],
|
||||
['for…in', /\bfor\s*\(\s*(?:(?:const|let|var)\s+)?(?:[\p{L}_$][\p{L}\p{N}_$.]*|\[[^\]]*\]|\{[^}]*\})\s+in\b/gu],
|
||||
['Reflect.ownKeys', /\bReflect\s*\??\.\s*ownKeys\b/g],
|
||||
['Object.getOwnPropertyNames', /\bObject\s*\??\.\s*getOwnPropertyNames\b/g],
|
||||
];
|
||||
|
||||
// Parcours de clés admis, chacun nommé par son fichier, relatif à la racine,
|
||||
// et par le texte de sa ligne, blancs de bord retirés ; sa raison dit
|
||||
// pourquoi l'ordre n'y décide d'aucun résultat. Une exception admet chaque
|
||||
// ligne de ce texte dans ce fichier, et rien d'autre : la ligne réécrite, ou
|
||||
// un second parcours ajouté à côté, se relève de nouveau. Elle ne couvre
|
||||
// aucune source de SOURCES_NON_REPRODUCTIBLES, que rien n'admet.
|
||||
const PARCOURS_DE_CLES_ADMIS = [];
|
||||
|
||||
// Relevé « fichier:ligne appel » des sources non reproductibles et des
|
||||
// parcours de clés non admis dans les modules de src/moteur et de src/demo,
|
||||
// par fichier puis par ligne, et sur une même ligne dans l'ordre de
|
||||
// SOURCES_NON_REPRODUCTIBLES puis de PARCOURS_DE_CLES. Les épreuves en sont
|
||||
// exclues : un tirage y sert légitimement (§ 14.12). Suit une ligne
|
||||
// « exception sans objet : fichier « texte » » par exception qui n'admet
|
||||
// rien, dans l'ordre de la liste. Lève quand l'un des deux arbres ne fournit
|
||||
// aucun module.
|
||||
function releverDeterminisme(racine, admis = PARCOURS_DE_CLES_ADMIS) {
|
||||
const servies = new Set();
|
||||
const estAdmis = (fichier, texteDeLigne) => {
|
||||
const exception = admis.find((e) => e.fichier === fichier && e.ligne === texteDeLigne);
|
||||
if (exception !== undefined) servies.add(exception);
|
||||
return exception !== undefined;
|
||||
};
|
||||
const releve = ['src/moteur', 'src/demo'].flatMap((arbre) => {
|
||||
const modules = modulesDe(join(racine, arbre));
|
||||
assert.ok(modules.length > 0, `${arbre} ne fournit aucun module`);
|
||||
return modules.flatMap((fichier) => {
|
||||
const nom = relative(racine, fichier);
|
||||
const texte = readFileSync(fichier, 'utf8');
|
||||
const lignes = texte.split('\n');
|
||||
const trouves = (motifs) =>
|
||||
motifs.flatMap(([appel, motif]) =>
|
||||
[...texte.matchAll(motif)].map(({ index }) => ({ ligne: ligneDe(texte, index), appel })),
|
||||
);
|
||||
return [
|
||||
...trouves(SOURCES_NON_REPRODUCTIBLES),
|
||||
...trouves(PARCOURS_DE_CLES).filter(({ ligne }) => !estAdmis(nom, lignes[ligne - 1].trim())),
|
||||
]
|
||||
.sort((a, b) => a.ligne - b.ligne)
|
||||
.map(({ ligne, appel }) => `${nom}:${ligne} ${appel}`);
|
||||
});
|
||||
});
|
||||
const sansObjet = admis
|
||||
.filter((exception) => !servies.has(exception))
|
||||
.map(({ fichier, ligne }) => `exception sans objet : ${fichier} « ${ligne} »`);
|
||||
return [...releve, ...sansObjet];
|
||||
}
|
||||
|
||||
describe('arborescence : déterminisme (§ 14.7)', () => {
|
||||
test("ni src/moteur ni src/demo ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => {
|
||||
assert.deepEqual(releverDeterminisme(RACINE), []);
|
||||
});
|
||||
|
||||
test('les tirages de node:crypto se relèvent comme ceux du navigateur', () => {
|
||||
const fichiers = {
|
||||
'src/moteur/a.js': 'export const a = 1;\n',
|
||||
'src/demo/tirage.js': [
|
||||
"import { randomInt, randomBytes, randomFillSync } from 'node:crypto';",
|
||||
'export const a = randomInt(6);',
|
||||
'export const b = randomBytes(4);',
|
||||
'export const c = randomFillSync(new Uint8Array(4));',
|
||||
].join('\n'),
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverDeterminisme), [
|
||||
'src/demo/tirage.js:1 crypto.randomInt',
|
||||
'src/demo/tirage.js:1 crypto.randomBytes',
|
||||
'src/demo/tirage.js:1 crypto.randomFill',
|
||||
'src/demo/tirage.js:2 crypto.randomInt',
|
||||
'src/demo/tirage.js:3 crypto.randomBytes',
|
||||
'src/demo/tirage.js:4 crypto.randomFill',
|
||||
]);
|
||||
});
|
||||
|
||||
test('la garde relève chaque appel par sa ligne, à toute profondeur, hors épreuves, et refuse un arbre sans module', () => {
|
||||
const fichiers = {
|
||||
'src/moteur/tirage.js': [
|
||||
'export const a = Math.random();',
|
||||
'export const b = Date.now() - performance.now();',
|
||||
'export const c = new Date(0);',
|
||||
'export const d = globalThis.crypto.getRandomValues(new Uint32Array(1));',
|
||||
"export const e = ['b', 'a'].sort((x, y) => x.localeCompare(y));",
|
||||
'export const f = Math',
|
||||
' .random;',
|
||||
'export const i = Date',
|
||||
' .now() + performance .now();',
|
||||
'export const j = globalThis.crypto',
|
||||
' .getRandomValues(t);',
|
||||
'export const k = new Date;',
|
||||
'export const p = Date();',
|
||||
'export const q = crypto.randomUUID();',
|
||||
"export const r = Math['random']();",
|
||||
"export const s = new Intl.Collator('fr').compare;",
|
||||
"export const u = 'É'.toLocaleLowerCase();",
|
||||
'export const v = webcrypto.getRandomValues(t);',
|
||||
'const { Collator } = Intl;',
|
||||
'export const x = source.randomUUID();',
|
||||
'export const y = (1234.5).toLocaleString();',
|
||||
'export const t1 = globalThis.performance?.now?.() ?? 0;',
|
||||
'export const t2 = Date?.now() + Math?.random();',
|
||||
"export const t3 = performance['now']();",
|
||||
"export const t4 = Date['now']() + Math?.['random']();",
|
||||
"export const t5 = Date?.['now']() - performance?.['now']();",
|
||||
'export const t6 = Date?.();',
|
||||
].join('\n'),
|
||||
'src/moteur/tirage.test.js': 'export const g = Math.random();\n',
|
||||
'src/moteur/sous/profond.js': 'export const l = Math.random();\n',
|
||||
'src/demo/propre.js': [
|
||||
'export const h = Math.floor(2.5) + Date.UTC(2000, 0, 1);',
|
||||
'export const mathRandom = 1;',
|
||||
'export const debutDate = { now: 2 };',
|
||||
'export const m = debutDate.now + xMath.random;',
|
||||
"export const w = 'É'.toLowerCase();",
|
||||
].join('\n'),
|
||||
'src/demo/tirage.mjs': 'export const n = Math.random();\n',
|
||||
'src/demo/tirage.cjs': 'module.exports = Date.now();\n',
|
||||
'src/demo/tirage.test.mjs': 'export const o = Math.random();\n',
|
||||
'src/demo/tirage.test.cjs': 'module.exports = Math.random();\n',
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverDeterminisme), [
|
||||
'src/moteur/sous/profond.js:1 Math.random',
|
||||
'src/moteur/tirage.js:1 Math.random',
|
||||
'src/moteur/tirage.js:2 Date.now',
|
||||
'src/moteur/tirage.js:2 performance.now',
|
||||
'src/moteur/tirage.js:3 new Date',
|
||||
'src/moteur/tirage.js:4 crypto.getRandomValues',
|
||||
'src/moteur/tirage.js:5 localeCompare',
|
||||
'src/moteur/tirage.js:6 Math.random',
|
||||
'src/moteur/tirage.js:8 Date.now',
|
||||
'src/moteur/tirage.js:9 performance.now',
|
||||
'src/moteur/tirage.js:11 crypto.getRandomValues',
|
||||
'src/moteur/tirage.js:12 new Date',
|
||||
'src/moteur/tirage.js:13 Date()',
|
||||
'src/moteur/tirage.js:14 crypto.randomUUID',
|
||||
'src/moteur/tirage.js:15 Math[',
|
||||
'src/moteur/tirage.js:16 Intl',
|
||||
'src/moteur/tirage.js:17 toLocale…',
|
||||
'src/moteur/tirage.js:18 crypto.getRandomValues',
|
||||
'src/moteur/tirage.js:19 Intl',
|
||||
'src/moteur/tirage.js:20 crypto.randomUUID',
|
||||
'src/moteur/tirage.js:21 toLocale…',
|
||||
'src/moteur/tirage.js:22 performance.now',
|
||||
'src/moteur/tirage.js:23 Math.random',
|
||||
'src/moteur/tirage.js:23 Date.now',
|
||||
'src/moteur/tirage.js:24 performance[',
|
||||
'src/moteur/tirage.js:25 Math[',
|
||||
'src/moteur/tirage.js:25 Date[',
|
||||
'src/moteur/tirage.js:26 Date[',
|
||||
'src/moteur/tirage.js:26 performance[',
|
||||
'src/moteur/tirage.js:27 Date()',
|
||||
'src/demo/tirage.cjs:1 Date.now',
|
||||
'src/demo/tirage.mjs:1 Math.random',
|
||||
]);
|
||||
assert.throws(
|
||||
() => avecArbre({ 'src/demo/propre.js': '' }, releverDeterminisme),
|
||||
/src\/moteur ne fournit aucun module/,
|
||||
);
|
||||
assert.throws(
|
||||
() => avecArbre({ 'src/moteur/a.js': '', 'src/demo/a.test.js': '' }, releverDeterminisme),
|
||||
/src\/demo ne fournit aucun module/,
|
||||
);
|
||||
});
|
||||
|
||||
test("les parcours des clés d'un objet se relèvent par leur ligne, hors épreuves, et pas leurs voisins (§ 15.5, point 4)", () => {
|
||||
const fichiers = {
|
||||
'src/moteur/cles.js': [
|
||||
'export const a = Object.keys(o);',
|
||||
'export const b = Object.entries(o).map(f);',
|
||||
'export const c = Object.values(o);',
|
||||
'for (const k in o) t.push(k);',
|
||||
'for (k in o) t.push(k);',
|
||||
'export const d = Object',
|
||||
' .keys(o);',
|
||||
'export const e = Object?.entries?.(o);',
|
||||
"export const g = Object['keys'](o);",
|
||||
'for(let k in o){}',
|
||||
'for (const [k] in o) t.push(k);',
|
||||
'for (var { length } in o) t.push(length);',
|
||||
'export const h = Reflect.ownKeys(o);',
|
||||
'export const i = Object.getOwnPropertyNames(o);',
|
||||
'for (const k of Object.keys(o)) t.push(k);',
|
||||
'for (a.b in o) t.push(a.b);',
|
||||
].join('\n'),
|
||||
'src/moteur/cles.test.js': 'export const x = Object.keys(o);\n',
|
||||
'src/demo/voisins.js': [
|
||||
'for (let i = 0; i < n; i += 1) t.push(i);',
|
||||
'for (const v of Object.freeze([])) t.push(v);',
|
||||
"export const f = 'k' in o;",
|
||||
'for (const [k, v] of m) t.push(k, v);',
|
||||
'export const z = monObject.keys + objectKeys;',
|
||||
'export const w = Object.fromEntries(paires);',
|
||||
'for (const index of inventaire) t.push(index);',
|
||||
'/** @param {Object[]} entrees les entrées, dans leur ordre */',
|
||||
].join('\n'),
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverDeterminisme), [
|
||||
'src/moteur/cles.js:1 Object.keys',
|
||||
'src/moteur/cles.js:2 Object.entries',
|
||||
'src/moteur/cles.js:3 Object.values',
|
||||
'src/moteur/cles.js:4 for…in',
|
||||
'src/moteur/cles.js:5 for…in',
|
||||
'src/moteur/cles.js:6 Object.keys',
|
||||
'src/moteur/cles.js:8 Object.entries',
|
||||
'src/moteur/cles.js:9 Object[',
|
||||
'src/moteur/cles.js:10 for…in',
|
||||
'src/moteur/cles.js:11 for…in',
|
||||
'src/moteur/cles.js:12 for…in',
|
||||
'src/moteur/cles.js:13 Reflect.ownKeys',
|
||||
'src/moteur/cles.js:14 Object.getOwnPropertyNames',
|
||||
'src/moteur/cles.js:15 Object.keys',
|
||||
'src/moteur/cles.js:16 for…in',
|
||||
]);
|
||||
});
|
||||
|
||||
test("une exception nommée n'admet que la ligne qu'elle cite, dans son fichier, et qu'un parcours de clés ; une exception sans objet se relève", () => {
|
||||
const somme = 'export const total = (o) => Object.values(o).reduce((s, v) => s + v, 0);';
|
||||
const fichiers = {
|
||||
'src/moteur/a.js': 'export const a = Math.random();\n',
|
||||
'src/demo/somme.js': [
|
||||
somme,
|
||||
'export const nombre = (o) => Object.keys(o).length;',
|
||||
'export const double = (o) => Object.values(o).reduce((s, v) => s + 2 * v, 0);',
|
||||
].join('\n'),
|
||||
'src/demo/autre.js': `${somme}\n`,
|
||||
};
|
||||
const exceptions = [
|
||||
{ fichier: 'src/demo/somme.js', ligne: somme, raison: "une somme d'entiers ne dépend pas de l'ordre de ses termes" },
|
||||
{ fichier: 'src/moteur/a.js', ligne: 'export const a = Math.random();', raison: 'un tirage ne se nomme pas en exception' },
|
||||
{ fichier: 'src/demo/disparu.js', ligne: 'for (const k in o) t.push(k);', raison: 'la ligne a disparu' },
|
||||
];
|
||||
assert.deepEqual(avecArbre(fichiers, (racine) => releverDeterminisme(racine, exceptions)), [
|
||||
'src/moteur/a.js:1 Math.random',
|
||||
'src/demo/autre.js:1 Object.values',
|
||||
'src/demo/somme.js:2 Object.keys',
|
||||
'src/demo/somme.js:3 Object.values',
|
||||
'exception sans objet : src/moteur/a.js « export const a = Math.random(); »',
|
||||
'exception sans objet : src/demo/disparu.js « for (const k in o) t.push(k); »',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Chargements d'un texte qui désignent un spécificateur, dans l'ordre du
|
||||
// texte, chacun { indice, specificateur } : ceux que relève l'analyseur
|
||||
// commun (test/chargements.js), moins les arguments calculés, qui ne
|
||||
// désignent aucun module.
|
||||
const chargementsLitteraux = (texte) =>
|
||||
chargements(texte).filter(({ specificateur }) => specificateur !== undefined);
|
||||
|
||||
// « ./ » ou « ../ » en tête, ou « . » et « .. » seuls.
|
||||
const RELATIF = /^\.{1,2}(?:\/|$)/;
|
||||
|
||||
// Nom du paquet que désigne un spécificateur nu : son premier segment, ou
|
||||
// ses deux premiers pour un paquet à portée (@portée/nom).
|
||||
const nomDePaquet = (specificateur) =>
|
||||
specificateur.split('/').slice(0, specificateur.startsWith('@') ? 2 : 1).join('/');
|
||||
|
||||
// Ce que ni le moteur ni la géométrie ne chargent (§ 13.4), outre les
|
||||
// modules natifs de Node : les paquets de l'interface et des plateformes,
|
||||
// sous-chemins compris, dont le motif s'applique au nom de paquet, et les
|
||||
// couches de src/ qui touchent l'écran ou les fichiers.
|
||||
const PAQUETS_EXTERIEURS = /^(?:svelte|electron|@capacitor\/.+)$/;
|
||||
const COUCHES_EXTERIEURES = ['interface', 'stockage'];
|
||||
|
||||
// Un accès au navigateur est un nom de document, de window, de navigator, de
|
||||
// location, de localStorage ou de sessionStorage suivi d'un crochet, ou
|
||||
// d'un point puis d'un nom de propriété, accolé ou en tête de la ligne
|
||||
// suivante. Une phrase qui continue après « document. » sur la même ligne
|
||||
// n'en est pas un, ni une ligne qui finit sur ce mot quand la suivante
|
||||
// s'ouvre sur autre chose qu'un nom, un commentaire par exemple. Le relevé
|
||||
// ne distingue pas le commentaire du code : une ligne de commentaire qui
|
||||
// finit sur « document. » juste avant une ligne de code se lit comme un
|
||||
// accès.
|
||||
const ACCES_AU_DOM = /\b(document|window|navigator|location|localStorage|sessionStorage)\s*(?:\??\.(?=[\p{L}_$]|[ \t]*\r?\n\s*[\p{L}_$])|(?:\?\.)?\s*\[)/gu;
|
||||
|
||||
// Chemin que désigne un spécificateur relatif, ou absolu depuis la racine
|
||||
// comme Vite le résout ; null pour un spécificateur nu ou une URL.
|
||||
function cibleDe(racine, fichier, specificateur) {
|
||||
if (RELATIF.test(specificateur)) return resolve(dirname(fichier), specificateur);
|
||||
if (specificateur.startsWith('/')) return join(racine, specificateur);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Relevé des refus de la frontière des couches dans le graphe d'imports du
|
||||
// moteur et de la géométrie. Le parcours part des modules de src/moteur et
|
||||
// de src/geometrie, à toute profondeur, épreuves exclues. Il suit chaque
|
||||
// chemin relatif, ou absolu depuis la racine, vers un module JavaScript
|
||||
// existant, chaque module une fois. Un fichier d'une couche extérieure n'est
|
||||
// pas parcouru : l'importer est déjà un refus. Un chemin sans extension en
|
||||
// est un aussi : Node ne le résout pas (§ 14.8), et le parcours ne suit pas
|
||||
// le module que Vite y trouverait. Un module natif de Node, que reconnaît
|
||||
// isBuiltin, est refusé : la page où le moteur s'exécute n'en a aucun.
|
||||
// Chaque refus se lit « accès:ligne refus », où l'accès est la suite des
|
||||
// modules qui mène d'un module de départ à celui qui refuse. src/geometrie
|
||||
// peut manquer ; lève quand src/moteur ne fournit aucun module.
|
||||
function releverFrontiere(racine) {
|
||||
const moteur = modulesDe(join(racine, 'src', 'moteur'));
|
||||
assert.ok(moteur.length > 0, 'src/moteur ne fournit aucun module');
|
||||
const couches = COUCHES_EXTERIEURES.map((couche) => join(racine, 'src', couche));
|
||||
const exterieur = (chemin) => couches.some((c) => chemin === c || chemin.startsWith(c + sep));
|
||||
const aParcourir = [...moteur, ...modulesDe(join(racine, 'src', 'geometrie'))];
|
||||
const acces = new Map(aParcourir.map((fichier) => [fichier, relative(racine, fichier)]));
|
||||
const refus = [];
|
||||
for (let i = 0; i < aParcourir.length; i += 1) {
|
||||
const fichier = aParcourir[i];
|
||||
const texte = readFileSync(fichier, 'utf8');
|
||||
const trouves = [...texte.matchAll(ACCES_AU_DOM)].map(({ 0: lu, 1: nom, index }) => ({
|
||||
indice: index,
|
||||
motif: nom + lu.at(-1),
|
||||
}));
|
||||
for (const { specificateur, indice } of chargementsLitteraux(texte)) {
|
||||
const cible = cibleDe(racine, fichier, specificateur);
|
||||
if (cible === null) {
|
||||
if (isBuiltin(specificateur) || PAQUETS_EXTERIEURS.test(nomDePaquet(specificateur))) {
|
||||
trouves.push({ indice, motif: `import ${specificateur}` });
|
||||
}
|
||||
} else if (exterieur(cible)) {
|
||||
trouves.push({ indice, motif: `import ${specificateur}` });
|
||||
} else if (extname(cible) === '') {
|
||||
trouves.push({ indice, motif: `import ${specificateur} sans extension` });
|
||||
} else if (!acces.has(cible) && EXTENSIONS_MODULE.has(extname(cible)) && existsSync(cible)) {
|
||||
acces.set(cible, `${acces.get(fichier)} → ${relative(racine, cible)}`);
|
||||
aParcourir.push(cible);
|
||||
}
|
||||
}
|
||||
trouves.sort((a, b) => a.indice - b.indice);
|
||||
for (const { indice, motif } of trouves) {
|
||||
refus.push(`${acces.get(fichier)}:${ligneDe(texte, indice)} ${motif}`);
|
||||
}
|
||||
}
|
||||
return refus;
|
||||
}
|
||||
|
||||
describe('arborescence : frontière des couches (§ 13.4)', () => {
|
||||
test('les globales du navigateur hors du DOM se refusent comme lui : navigator, location, localStorage, sessionStorage', () => {
|
||||
const fichiers = {
|
||||
'src/moteur/a.js': [
|
||||
'export const langue = navigator.language;',
|
||||
'export const adresse = location.href;',
|
||||
"export const memoire = localStorage.getItem('x');",
|
||||
"export const seance = sessionStorage['x'];",
|
||||
].join('\n'),
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverFrontiere), [
|
||||
'src/moteur/a.js:1 navigator.',
|
||||
'src/moteur/a.js:2 location.',
|
||||
'src/moteur/a.js:3 localStorage.',
|
||||
'src/moteur/a.js:4 sessionStorage[',
|
||||
]);
|
||||
});
|
||||
|
||||
test("le graphe d'imports du moteur et de la géométrie ne rejoint ni l'interface, ni le stockage, ni une plateforme", () => {
|
||||
assert.deepEqual(releverFrontiere(RACINE), []);
|
||||
});
|
||||
|
||||
test('la garde suit les imports depuis chaque module à toute profondeur, relève chaque refus par sa ligne, hors épreuves', () => {
|
||||
const fichiers = {
|
||||
'src/moteur/a.js': [
|
||||
"import { mount } from 'svelte';",
|
||||
"import { Capacitor } from '@capacitor/core';",
|
||||
"import { app } from 'electron';",
|
||||
"import App from '../interface/App.svelte';",
|
||||
"import { lire } from '../stockage/fichiers.js';",
|
||||
"import { aide } from '../commun/aide.js';",
|
||||
"import { ErreurConfiguration } from './erreurs.js';",
|
||||
'export const largeur = document.body.clientWidth;',
|
||||
"import { writable } from 'svelte/store';",
|
||||
"import { ipcRenderer } from 'electron/renderer';",
|
||||
"import { voisin } from '../interface-x/voisin.js';",
|
||||
'export const adresse = window.URL;',
|
||||
"import { x } from '/src/interface/x.js';",
|
||||
"import { f } from '../commun/fenetre';",
|
||||
"import { readFileSync } from 'node:fs';",
|
||||
"export const hauteur = window['innerHeight'];",
|
||||
'export const corps = () => document.',
|
||||
' body;',
|
||||
"import 'svelte';",
|
||||
'import { y } from "electron";',
|
||||
"import { z } from '@capacitor/cli';",
|
||||
'const c = require("svelte");',
|
||||
"export const echelle = window?.['devicePixelRatio'];",
|
||||
"import fs from 'fs';",
|
||||
].join('\n'),
|
||||
'src/moteur/b.js': [
|
||||
'import {',
|
||||
" // l'écran d'accueil",
|
||||
' App,',
|
||||
"} from '../interface/App.svelte';",
|
||||
].join('\n'),
|
||||
'src/moteur/erreurs.js': [
|
||||
'// Une phrase finit sur le mot document. La suivante commence ici.',
|
||||
"import { rien } from './absent.js';",
|
||||
'export const fenetres = { windows: 1, documentation: 2 };',
|
||||
'// Cette ligne finit sur le mot document.',
|
||||
'// La suivante est un commentaire.',
|
||||
].join('\n'),
|
||||
'src/moteur/sous/c.js': [
|
||||
"import { x } from '../../interface/x.js';",
|
||||
"import { y } from '../interface/y.js';",
|
||||
].join('\n'),
|
||||
'src/moteur/interface/y.js': 'export const y = 1;\n',
|
||||
'src/moteur/a.test.js': "import { mount } from 'svelte';\nexport const corps = document.body;\n",
|
||||
'src/stockage/fichiers.js': "import { Filesystem } from '@capacitor/core';\n",
|
||||
'src/commun/aide.js': [
|
||||
"import { largeur } from '../moteur/a.js';",
|
||||
'export const aide = () => window',
|
||||
' .innerWidth;',
|
||||
].join('\n'),
|
||||
'src/commun/fenetre.js': 'export const f = () => window.innerHeight;\n',
|
||||
'src/commun/racine.js': 'export const r = () => window.top;\n',
|
||||
'src/geometrie/echelle.js': [
|
||||
'export const zoom = () => window?.devicePixelRatio;',
|
||||
"import { r } from '/src/commun/racine.js';",
|
||||
].join('\n'),
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverFrontiere), [
|
||||
'src/moteur/a.js:1 import svelte',
|
||||
'src/moteur/a.js:2 import @capacitor/core',
|
||||
'src/moteur/a.js:3 import electron',
|
||||
'src/moteur/a.js:4 import ../interface/App.svelte',
|
||||
'src/moteur/a.js:5 import ../stockage/fichiers.js',
|
||||
'src/moteur/a.js:8 document.',
|
||||
'src/moteur/a.js:9 import svelte/store',
|
||||
'src/moteur/a.js:10 import electron/renderer',
|
||||
'src/moteur/a.js:12 window.',
|
||||
'src/moteur/a.js:13 import /src/interface/x.js',
|
||||
'src/moteur/a.js:14 import ../commun/fenetre sans extension',
|
||||
'src/moteur/a.js:15 import node:fs',
|
||||
'src/moteur/a.js:16 window[',
|
||||
'src/moteur/a.js:17 document.',
|
||||
'src/moteur/a.js:19 import svelte',
|
||||
'src/moteur/a.js:20 import electron',
|
||||
'src/moteur/a.js:21 import @capacitor/cli',
|
||||
'src/moteur/a.js:22 import svelte',
|
||||
'src/moteur/a.js:23 window[',
|
||||
'src/moteur/a.js:24 import fs',
|
||||
'src/moteur/b.js:4 import ../interface/App.svelte',
|
||||
'src/moteur/sous/c.js:1 import ../../interface/x.js',
|
||||
'src/geometrie/echelle.js:1 window.',
|
||||
'src/moteur/a.js → src/commun/aide.js:2 window.',
|
||||
'src/geometrie/echelle.js → src/commun/racine.js:1 window.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('src/geometrie peut manquer ; un src/moteur sans module fait échouer la garde', () => {
|
||||
assert.deepEqual(avecArbre({ 'src/moteur/a.js': '' }, releverFrontiere), []);
|
||||
assert.throws(
|
||||
() => avecArbre({ 'src/geometrie/echelle.js': '', 'src/moteur/a.test.js': '' }, releverFrontiere),
|
||||
/src\/moteur ne fournit aucun module/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Périmètre du relevé des dépendances : les fichiers de la racine, et les
|
||||
// arbres des sources, des épreuves, des scripts et de la coquille ; ni
|
||||
// node_modules ni sortie de construction.
|
||||
const ARBRES = ['src', 'test', 'scripts', 'electron'];
|
||||
const EXTENSIONS_SOURCE = new Set(['.js', '.cjs', '.mjs', '.svelte']);
|
||||
function sourcesDuProjet(racine) {
|
||||
const racineSeule = readdirSync(racine, { withFileTypes: true })
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => join(racine, e.name));
|
||||
return [...racineSeule, ...ARBRES.flatMap((arbre) => fichiersDe(join(racine, arbre)))]
|
||||
.filter((chemin) => EXTENSIONS_SOURCE.has(extname(chemin)))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Un spécificateur nu n'est ni relatif, ni absolu : ni chemin, ni URL, sinon
|
||||
// sous node:, la forme des modules natifs.
|
||||
const URL_ABSOLUE = /^(?!node:)[a-z][a-z\d+.-]*:/i;
|
||||
const estNu = (specificateur) =>
|
||||
!RELATIF.test(specificateur) && !specificateur.startsWith('/') && !URL_ABSOLUE.test(specificateur);
|
||||
|
||||
// Relevé « fichier:ligne spécificateur » des spécificateurs nus qui ne
|
||||
// désignent ni un module natif de Node ni un paquet de devDependencies ; un
|
||||
// paquet que seul dependencies déclare est relevé lui aussi, le projet
|
||||
// n'ayant aucune dépendance d'exécution. Node et Vite résolvent un
|
||||
// spécificateur nu en remontant les node_modules des répertoires parents :
|
||||
// un paquet non déclaré, présent dans un parent sur un poste, s'y charge,
|
||||
// puis manque dans un clone. isBuiltin admet le nom seul d'un module natif et
|
||||
// sa forme node:, la seule qu'acceptent certains (node:test) ; il refuse un
|
||||
// nom inconnu sous node:. Lève quand le périmètre ne porte aucun
|
||||
// spécificateur nu.
|
||||
function releverDependances(racine) {
|
||||
const { devDependencies = {} } = JSON.parse(readFileSync(join(racine, 'package.json'), 'utf8'));
|
||||
const declares = new Set(Object.keys(devDependencies));
|
||||
let nus = 0;
|
||||
const refus = [];
|
||||
for (const fichier of sourcesDuProjet(racine)) {
|
||||
const texte = readFileSync(fichier, 'utf8');
|
||||
const trouves = chargementsLitteraux(texte).filter(({ specificateur }) => estNu(specificateur));
|
||||
nus += trouves.length;
|
||||
for (const { specificateur, indice } of trouves) {
|
||||
if (!isBuiltin(specificateur) && !declares.has(nomDePaquet(specificateur))) {
|
||||
refus.push(`${relative(racine, fichier)}:${ligneDe(texte, indice)} ${specificateur}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(nus > 0, 'aucun spécificateur nu dans le périmètre');
|
||||
return refus;
|
||||
}
|
||||
|
||||
// Un import de module seul par spécificateur, une ligne chacun, entre les
|
||||
// guillemets donnés, doubles par défaut. Le texte de ce fichier ne porte
|
||||
// ainsi aucun spécificateur entre guillemets après le mot-clé : un
|
||||
// spécificateur refusé, écrit en clair dans une instruction d'import de ce
|
||||
// fichier, ferait échouer la garde sur ce fichier même. Une donnée qui charge
|
||||
// un paquet refusé par une autre forme l'interpole pour la même raison.
|
||||
const importsSeuls = (specificateurs, guillemet = '"') =>
|
||||
specificateurs.map((s) => `import ${guillemet}${s}${guillemet};`).join('\n');
|
||||
|
||||
describe('arborescence : dépendances déclarées', () => {
|
||||
test('chaque spécificateur nu désigne un module natif de Node ou un paquet de devDependencies', () => {
|
||||
assert.deepEqual(releverDependances(RACINE), []);
|
||||
});
|
||||
|
||||
test('la garde relève chaque paquet absent de devDependencies, dans son périmètre seul', () => {
|
||||
const fichiers = {
|
||||
'package.json': JSON.stringify({
|
||||
dependencies: { lodash: '^4.17.21' },
|
||||
devDependencies: { vitest: '^5.0.3', '@vitest/browser-playwright': '^5.0.3' },
|
||||
}),
|
||||
'vite.config.js': "import { defineConfig } from 'vite';\n",
|
||||
'src/interface/a.svelte': [
|
||||
'<script>',
|
||||
" import { mount } from 'svelte';",
|
||||
'</script>',
|
||||
'<style>',
|
||||
" @import 'jetons.css';",
|
||||
'</style>',
|
||||
].join('\n'),
|
||||
'electron/b.cjs': "const { app } = require('electron');\n",
|
||||
'scripts/oracle/c.mjs': [
|
||||
"const { chromium } = require('playwright');",
|
||||
"import assert from 'node:assert/strict';",
|
||||
'import {',
|
||||
' describe,',
|
||||
"} from 'vitest/config';",
|
||||
"export * from '@vitest/browser-playwright';",
|
||||
"const { join } = require('node:path');",
|
||||
"const vue = await import('svelte/store');",
|
||||
"import './voisin.js';",
|
||||
"import '..';",
|
||||
"spawnSync(process.execPath, ['--import', 'chargeur.js']);",
|
||||
"import { build } from'vite';",
|
||||
'const fc = await import(`fast-check`);',
|
||||
"const { app } = await import('electron', { with: {} });",
|
||||
].join('\n'),
|
||||
'test/d.test.js': [
|
||||
importsSeuls(['fs/promises', 'node:test', 'test', 'node:inexistant', '@vitest/inconnu']),
|
||||
importsSeuls(['jsdom'], "'"),
|
||||
importsSeuls(['/absolu.js', 'file:///e.js', 'lodash']),
|
||||
`import x from ${JSON.stringify('happy-dom')};`,
|
||||
`require(${JSON.stringify('undici')});`,
|
||||
].join('\n'),
|
||||
'node_modules/f/index.js': importsSeuls(['hors-perimetre']),
|
||||
'www/g.js': importsSeuls(['hors-perimetre']),
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverDependances), [
|
||||
'electron/b.cjs:1 electron',
|
||||
'scripts/oracle/c.mjs:1 playwright',
|
||||
'scripts/oracle/c.mjs:8 svelte/store',
|
||||
'scripts/oracle/c.mjs:12 vite',
|
||||
'scripts/oracle/c.mjs:13 fast-check',
|
||||
'scripts/oracle/c.mjs:14 electron',
|
||||
'src/interface/a.svelte:2 svelte',
|
||||
'test/d.test.js:3 test',
|
||||
'test/d.test.js:4 node:inexistant',
|
||||
'test/d.test.js:5 @vitest/inconnu',
|
||||
'test/d.test.js:6 jsdom',
|
||||
'test/d.test.js:9 lodash',
|
||||
'test/d.test.js:10 happy-dom',
|
||||
'test/d.test.js:11 undici',
|
||||
'vite.config.js:1 vite',
|
||||
]);
|
||||
});
|
||||
|
||||
test('un périmètre sans spécificateur nu fait échouer la garde', () => {
|
||||
const fichiers = { 'package.json': '{}', 'src/a.js': "import './b.js';\n" };
|
||||
assert.throws(() => avecArbre(fichiers, releverDependances), /aucun spécificateur nu/);
|
||||
});
|
||||
});
|
||||
|
||||
// Valeurs du contrat de données (types.js) : configuration.js les définit et
|
||||
// les exporte, tout autre fichier de src/moteur les importe, épreuves
|
||||
// comprises (§ 13.2). Une déclaration de l'un de ces noms ailleurs est une
|
||||
// seconde définition, qui peut dériver de la première sans qu'aucune épreuve
|
||||
// ne le voie. Le relevé lit le nom qui suit const, let ou var : une
|
||||
// déstructuration de STATUT n'est pas relevée, elle lit la définition au lieu
|
||||
// de la refaire.
|
||||
const VALEURS_DU_CONTRAT = [
|
||||
'STATUT',
|
||||
'MOBILE',
|
||||
'PARTIELLEMENT_FIXE',
|
||||
'ANCRE',
|
||||
'LIBRE',
|
||||
'RESERVE',
|
||||
'SANS_GROUPE',
|
||||
];
|
||||
const DECLARATION_DE_VALEUR = new RegExp(
|
||||
`\\b(?:const|let|var)\\s+(${VALEURS_DU_CONTRAT.join('|')})\\b`,
|
||||
'g',
|
||||
);
|
||||
const PROPRIETAIRE_DES_VALEURS = join('src', 'moteur', 'configuration.js');
|
||||
|
||||
// Relevé « fichier:ligne nom » des déclarations d'une valeur du contrat dans
|
||||
// les fichiers JavaScript de src/moteur, à toute profondeur, épreuves
|
||||
// comprises, configuration.js excepté ; par fichier, puis par ligne. Lève
|
||||
// quand src/moteur ne fournit aucun fichier JavaScript.
|
||||
function releverValeursDuContrat(racine) {
|
||||
const fichiers = fichiersDe(join(racine, 'src', 'moteur'))
|
||||
.filter((fichier) => EXTENSIONS_MODULE.has(extname(fichier)))
|
||||
.sort();
|
||||
assert.ok(fichiers.length > 0, 'src/moteur ne fournit aucun fichier JavaScript');
|
||||
return fichiers
|
||||
.filter((fichier) => relative(racine, fichier) !== PROPRIETAIRE_DES_VALEURS)
|
||||
.flatMap((fichier) => {
|
||||
const texte = readFileSync(fichier, 'utf8');
|
||||
return [...texte.matchAll(DECLARATION_DE_VALEUR)].map(
|
||||
(trouve) => `${relative(racine, fichier)}:${ligneDe(texte, trouve.index)} ${trouve[1]}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('arborescence : un seul propriétaire des valeurs du contrat (types.js)', () => {
|
||||
test('hors de configuration.js, aucun fichier de src/moteur ne déclare STATUT, un statut, LIBRE, RESERVE ni SANS_GROUPE', () => {
|
||||
assert.deepEqual(releverValeursDuContrat(RACINE), []);
|
||||
});
|
||||
|
||||
test('la garde relève chaque déclaration par sa ligne, épreuves comprises, et admet le propriétaire, la déstructuration et les noms voisins', () => {
|
||||
const fichiers = {
|
||||
'src/moteur/configuration.js': 'export const STATUT = Object.freeze({ ANCRE: 2 });\nexport const LIBRE = -1;\n',
|
||||
'src/moteur/a.js': [
|
||||
"import { STATUT } from './configuration.js';",
|
||||
'const { ANCRE } = STATUT;',
|
||||
'const ANCRES = [];',
|
||||
'let LIBRES = 0;',
|
||||
].join('\n'),
|
||||
'src/moteur/b.js': 'const ANCRE = 2;\nexport let RESERVE = -1;\n',
|
||||
'src/moteur/sous/c.test.js': '// Épreuve.\nvar SANS_GROUPE = -1;\n',
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverValeursDuContrat), [
|
||||
'src/moteur/b.js:1 ANCRE',
|
||||
'src/moteur/b.js:2 RESERVE',
|
||||
'src/moteur/sous/c.test.js:2 SANS_GROUPE',
|
||||
]);
|
||||
});
|
||||
|
||||
test('un src/moteur sans fichier JavaScript fait échouer la garde', () => {
|
||||
for (const fichiers of [{ 'src/demo/a.js': '' }, { 'src/moteur/notes.md': '' }]) {
|
||||
assert.throws(
|
||||
() => avecArbre(fichiers, releverValeursDuContrat),
|
||||
/src\/moteur ne fournit aucun fichier JavaScript/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
40
test/chargements.js
Normal file
40
test/chargements.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Analyseur des chargements qu'un texte demande, seul lu par les gardes de
|
||||
// l'arborescence (test/arborescence.test.js) et du livrable
|
||||
// (test/livrable.test.js) : import et export statiques, import seul, import()
|
||||
// et require().
|
||||
//
|
||||
// Le relevé lit le texte entier, commentaires et chaînes compris. Une
|
||||
// instruction à « from » se lit à partir de ce mot, que seuls import et
|
||||
// export font suivre d'une chaîne : un motif parti du mot-clé s'arrêterait à
|
||||
// l'apostrophe d'un commentaire placé entre les accolades. L'import d'un
|
||||
// module seul exige un blanc après le mot-clé, et aucune arobase devant lui :
|
||||
// une chaîne qui finit sur « import », comme l'argument « --import » d'une
|
||||
// commande, n'en est pas un, ni la règle @import d'une feuille de style, dont
|
||||
// la chaîne est une adresse relative à la feuille et non un paquet. import()
|
||||
// et require() désignent un spécificateur quand leur argument est une chaîne
|
||||
// littérale, entre guillemets, apostrophes ou accents graves sans
|
||||
// interpolation, suivie ou non d'options ; tout autre argument est calculé.
|
||||
const DEPUIS = /\bfrom\s*(['"])(?<specificateur>[^'"\n]+)\1/g;
|
||||
const IMPORT_SEUL = /(?<!@)\bimport\s+(['"])(?<specificateur>[^'"\n]+)\1/g;
|
||||
const APPEL =
|
||||
/\b(?:import|require)\s*\(\s*(?:(['"`])(?<specificateur>[^'"`$\n]+)\1\s*[,)]|(?<argument>[^)]*)\))/g;
|
||||
|
||||
// Chargements que demande un texte, dans l'ordre du texte. Chacun est
|
||||
// { indice, specificateur } pour une chaîne littérale, { indice, argument }
|
||||
// pour un argument calculé, son texte jusqu'à la première parenthèse
|
||||
// fermante, blancs de bord retirés. indice est la position du mot « from »,
|
||||
// de « import » ou de « require » dans le texte.
|
||||
export function chargements(texte) {
|
||||
return [DEPUIS, IMPORT_SEUL, APPEL]
|
||||
.flatMap((motif) =>
|
||||
[...texte.matchAll(motif)].map(({ groups, index }) =>
|
||||
groups.specificateur === undefined
|
||||
? { indice: index, argument: groups.argument.trim() }
|
||||
: { indice: index, specificateur: groups.specificateur },
|
||||
),
|
||||
)
|
||||
.sort((a, b) => a.indice - b.indice);
|
||||
}
|
||||
91
test/chargements.test.js
Normal file
91
test/chargements.test.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'analyseur des chargements, test/chargements.js, que lisent les gardes de
|
||||
// l'arborescence et du livrable. Chaque épreuve donne un texte et les
|
||||
// chargements qu'il demande, dans l'ordre du texte. Les spécificateurs nus
|
||||
// des données sont des paquets de devDependencies ou des modules de Node :
|
||||
// la garde des dépendances lit aussi ce fichier.
|
||||
import assert from 'node:assert/strict';
|
||||
import { chargements } from './chargements.js';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
// Numéro, à partir de 1, de la ligne qui porte le caractère d'indice donné.
|
||||
const ligneDe = (texte, indice) => texte.slice(0, indice).split('\n').length;
|
||||
|
||||
// Chaque chargement d'un texte, en « ligne spécificateur », ou en « ligne
|
||||
// calculé argument » pour un argument qui n'est pas une chaîne littérale.
|
||||
const lus = (texte) =>
|
||||
chargements(texte).map(({ indice, specificateur, argument }) =>
|
||||
specificateur === undefined
|
||||
? `${ligneDe(texte, indice)} calculé ${argument}`
|
||||
: `${ligneDe(texte, indice)} ${specificateur}`,
|
||||
);
|
||||
|
||||
describe('chargements', () => {
|
||||
test("un import dont les accolades portent un commentaire à apostrophe se relève, comme celui qui le suit", () => {
|
||||
const texte = [
|
||||
'import {',
|
||||
" // l'écran d'accueil",
|
||||
' App,',
|
||||
"} from '../interface/App.svelte';",
|
||||
"import { a } from './a.js';",
|
||||
].join('\n');
|
||||
assert.deepEqual(
|
||||
chargements(texte).map(({ specificateur }) => specificateur),
|
||||
['../interface/App.svelte', './a.js'],
|
||||
);
|
||||
});
|
||||
|
||||
test('import et export à from, import seul, import() et require() de chaîne, avec ou sans options, se relèvent à leur ligne, dans l’ordre du texte', () => {
|
||||
const texte = [
|
||||
"import { a } from './a.js';",
|
||||
'import b, { c } from "../b.js";',
|
||||
"export * from './c.js';",
|
||||
"export { d } from'./d.js';",
|
||||
"import 'svelte';",
|
||||
'const e = require("electron");',
|
||||
"const f = await import('node:fs', { with: {} });",
|
||||
'const g = await import(`vitest`);',
|
||||
"import * as h from 'svelte/store'; const i = require('./i.cjs');",
|
||||
].join('\n');
|
||||
assert.deepEqual(lus(texte), [
|
||||
'1 ./a.js',
|
||||
'2 ../b.js',
|
||||
'3 ./c.js',
|
||||
'4 ./d.js',
|
||||
'5 svelte',
|
||||
'6 electron',
|
||||
'7 node:fs',
|
||||
'8 vitest',
|
||||
'9 svelte/store',
|
||||
'9 ./i.cjs',
|
||||
]);
|
||||
});
|
||||
|
||||
test("un argument qui n'est pas une chaîne littérale se relève comme calculé, sans spécificateur", () => {
|
||||
const texte = [
|
||||
'const a = require(nom);',
|
||||
'const b = await import(`./${module}.js`);',
|
||||
"const c = require('./' + nom);",
|
||||
'const d = await import( cible );',
|
||||
].join('\n');
|
||||
assert.deepEqual(lus(texte), [
|
||||
'1 calculé nom',
|
||||
'2 calculé `./${module}.js`',
|
||||
"3 calculé './' + nom",
|
||||
'4 calculé cible',
|
||||
]);
|
||||
});
|
||||
|
||||
test("ni une chaîne qui finit sur « import », ni la règle @import d'une feuille de style ne sont des chargements", () => {
|
||||
const texte = [
|
||||
"spawnSync(process.execPath, ['--import', 'chargeur.js']);",
|
||||
'<style>',
|
||||
" @import 'jetons.css';",
|
||||
'</style>',
|
||||
"const mot = 'import';",
|
||||
].join('\n');
|
||||
assert.deepEqual(lus(texte), []);
|
||||
});
|
||||
});
|
||||
109
test/construction.long.test.js
Normal file
109
test/construction.long.test.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le paquet livré n'emporte rien de l'outillage d'épreuve (§ 14.9, § 14.15) :
|
||||
// ni fichier de test, ni le lanceur, ni donnée d'épreuve, ni paquet d'un outil
|
||||
// d'épreuve. Le test construit la page par l'API de Vite, avec la
|
||||
// configuration et le mode de la livraison, dans un répertoire temporaire ;
|
||||
// il relit les modules que contient le paquet, les noms d'origine de chaque
|
||||
// actif, puis le chemin et le contenu de chaque fichier écrit.
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from 'vite';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
// Paquets qui ne servent qu'aux épreuves.
|
||||
const OUTILS_EPREUVE =
|
||||
/[\\/]node_modules[\\/](?:vitest|@vitest|playwright|playwright-core|fast-check)[\\/]/;
|
||||
|
||||
// Vrai quand un module vient de l'outillage d'épreuve : l'arbre test/
|
||||
// (lanceur, données d'épreuve), un fichier *.test.js rangé à côté de son
|
||||
// module, un paquet d'épreuve, ou un module de Node que Vite remplace pour le
|
||||
// navigateur, comme node:assert.
|
||||
function moduleDEpreuve(id) {
|
||||
return (
|
||||
id.startsWith(join(RACINE, 'test') + sep) ||
|
||||
/\.test\.[cm]?js(?:\?|$)/.test(id) ||
|
||||
OUTILS_EPREUVE.test(id) ||
|
||||
id.includes('__vite-browser-external')
|
||||
);
|
||||
}
|
||||
|
||||
// Traces de l'outillage dans le chemin ou le contenu d'un fichier écrit. Les
|
||||
// motifs portent l'extension : le domaine emploie le mot « lanceur » (lanceur
|
||||
// portable, § 8.6), et un code minifié peut écrire « x.test.call ».
|
||||
const TRACES_EPREUVE = [/vitest/i, /node:assert/, /lanceur\.js/, /\.test\.[cm]?js/];
|
||||
|
||||
// Construit la page comme la livraison : vite.config.js, mode livraison,
|
||||
// NODE_ENV=production. Vitest pose NODE_ENV=test, dont Vite déduirait une
|
||||
// construction de développement — composants compilés en mode dev, paquets
|
||||
// résolus sous la condition « development ».
|
||||
async function construireLivraison(sortie) {
|
||||
const nodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
try {
|
||||
return await build({
|
||||
root: RACINE,
|
||||
configFile: join(RACINE, 'vite.config.js'),
|
||||
mode: 'livraison',
|
||||
logLevel: 'error',
|
||||
// Sans incrustation, chaque actif sort en fichier et garde ses noms
|
||||
// d'origine ; incrusté en data:, il ne laisserait aucune trace lisible.
|
||||
build: { outDir: sortie, emptyOutDir: true, assetsInlineLimit: 0 },
|
||||
});
|
||||
} finally {
|
||||
if (nodeEnv === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = nodeEnv;
|
||||
}
|
||||
}
|
||||
|
||||
describe('construction', () => {
|
||||
test("le paquet livré n'emporte aucun fichier ni module d'épreuve (§ 14.9)", async () => {
|
||||
const sortie = mkdtempSync(join(tmpdir(), 'construction-'));
|
||||
try {
|
||||
const resultat = await construireLivraison(sortie);
|
||||
const sorties = (Array.isArray(resultat) ? resultat : [resultat]).flatMap(
|
||||
(r) => r.output,
|
||||
);
|
||||
const modules = sorties
|
||||
.filter((s) => s.type === 'chunk')
|
||||
.flatMap((s) => s.moduleIds);
|
||||
assert.ok(
|
||||
modules.some((id) => id.startsWith(join(RACINE, 'src') + sep)),
|
||||
`aucun module de src/ parmi ${modules.length} modules examinés`,
|
||||
);
|
||||
assert.deepEqual(modules.filter(moduleDEpreuve), []);
|
||||
|
||||
// Un actif n'est pas un module : moduleIds ne le voit pas. Une donnée
|
||||
// jointe par new URL(…, import.meta.url) n'apparaît que là.
|
||||
const actifs = sorties
|
||||
.filter((s) => s.type === 'asset')
|
||||
.flatMap((s) => (s.originalFileNames ?? []).map((n) => resolve(RACINE, n)));
|
||||
assert.ok(actifs.length > 0, `aucun actif d'origine connue parmi ${sorties.length} sorties`);
|
||||
assert.deepEqual(actifs.filter(moduleDEpreuve), []);
|
||||
|
||||
const fichiers = readdirSync(sortie, { recursive: true, withFileTypes: true })
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => join(e.parentPath, e.name));
|
||||
assert.ok(
|
||||
fichiers.some((f) => f.endsWith('.js')),
|
||||
`aucun script parmi ${fichiers.length} fichiers écrits`,
|
||||
);
|
||||
const traces = fichiers.flatMap((f) => {
|
||||
const nom = relative(sortie, f);
|
||||
const contenu = readFileSync(f, 'utf8');
|
||||
return TRACES_EPREUVE.filter((m) => m.test(nom) || m.test(contenu)).map(
|
||||
(m) => `${nom} : ${m}`,
|
||||
);
|
||||
});
|
||||
assert.deepEqual(traces, []);
|
||||
} finally {
|
||||
rmSync(sortie, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
148
test/coquille.test.js
Normal file
148
test/coquille.test.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La coquille Electron (§ 13.1) : la fenêtre qu'ouvre le processus principal,
|
||||
// la page qu'elle charge, le pont étroit que le préchargement expose, et le
|
||||
// trajet du dossier publié par le lanceur portable jusqu'à la page (§ 8.6,
|
||||
// point 1).
|
||||
//
|
||||
// electron/main.js s'exécute sous Node, dans un processus à part, contre
|
||||
// test/electron_factice.js, qui consigne ce que la coquille demande à
|
||||
// Electron puis rend son journal en JSON. Ce qui s'éprouve ici est ce que la
|
||||
// coquille demande ; qu'Electron l'applique relève d'une épreuve dans un vrai
|
||||
// Electron.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
const FACTICE = pathToFileURL(join(RACINE, 'test', 'electron_factice.js')).href;
|
||||
const PRINCIPAL = join(RACINE, 'electron', 'main.js');
|
||||
const PRECHARGEMENT = join(RACINE, 'electron', 'preload.cjs');
|
||||
const CANAL = 'gtt:dossier-executable';
|
||||
|
||||
// Délai de l'exécution de la coquille. Le factice ne joue la page qu'une
|
||||
// fois le processus sans rien à faire ; un minuteur ou un écouteur laissé
|
||||
// actif l'en empêche, et l'exécution ne finit jamais. Le délai reste sous
|
||||
// celui d'une épreuve de Vitest, 5 s, qui n'interrompt pas un appel
|
||||
// synchrone : l'épreuve échoue en le disant, au lieu de figer la série. Le
|
||||
// délai écoulé, le processus reçoit KILL, qu'aucun gestionnaire de signal
|
||||
// ne retient.
|
||||
const DELAI_MS = 4_000;
|
||||
|
||||
// Exécute la coquille contre le factice et rend son journal. Le répertoire
|
||||
// courant est un dossier vide hors du projet : dans l'exécutable, il n'est
|
||||
// jamais l'intérieur d'app.asar, si bien qu'un chemin résolu contre lui ne
|
||||
// désigne ni la page ni le préchargement.
|
||||
function executerLaCoquille() {
|
||||
const environnement = { ...process.env };
|
||||
delete environnement.PORTABLE_EXECUTABLE_DIR;
|
||||
const ailleurs = mkdtempSync(join(tmpdir(), 'coquille-'));
|
||||
try {
|
||||
const resultat = spawnSync(process.execPath, ['--import', FACTICE, PRINCIPAL], {
|
||||
cwd: ailleurs,
|
||||
encoding: 'utf8',
|
||||
env: environnement,
|
||||
timeout: DELAI_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
assert.notEqual(
|
||||
resultat.error?.code,
|
||||
'ETIMEDOUT',
|
||||
`la coquille ne s'arrête pas en ${DELAI_MS} ms : un minuteur ou un écouteur la tient en vie`,
|
||||
);
|
||||
assert.equal(resultat.status, 0, `la coquille ne s'exécute pas :\n${resultat.stderr}`);
|
||||
return JSON.parse(resultat.stdout);
|
||||
} finally {
|
||||
rmSync(ailleurs, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// L'exécution est partagée par les épreuves : le processus ne se lance
|
||||
// qu'une fois, et son échec se rend à chacune sans relancer.
|
||||
let execution;
|
||||
function journalDeLaCoquille() {
|
||||
if (execution === undefined) {
|
||||
try {
|
||||
execution = { journal: executerLaCoquille() };
|
||||
} catch (erreur) {
|
||||
execution = { erreur };
|
||||
}
|
||||
}
|
||||
if (execution.erreur !== undefined) throw execution.erreur;
|
||||
return execution.journal;
|
||||
}
|
||||
|
||||
describe('coquille : processus principal', () => {
|
||||
test("une seule fenêtre, 1366 × 768 : page isolée, sans Node, en bac à sable, préchargée par le pont", () => {
|
||||
const { fenetres } = journalDeLaCoquille();
|
||||
assert.equal(fenetres.length, 1);
|
||||
const [{ width, height, webPreferences }] = fenetres;
|
||||
assert.deepEqual({ width, height }, { width: 1366, height: 768 });
|
||||
assert.deepEqual(webPreferences, {
|
||||
preload: PRECHARGEMENT,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
});
|
||||
assert.ok(existsSync(webPreferences.preload), `${webPreferences.preload} absent`);
|
||||
});
|
||||
|
||||
test("aucun menu d'application : le menu par défaut d'Electron, en anglais, est retiré avant la première fenêtre (§ 2)", () => {
|
||||
const { menus, sequence } = journalDeLaCoquille();
|
||||
assert.deepEqual(menus, [null]);
|
||||
const retrait = sequence.indexOf('Menu.setApplicationMenu');
|
||||
const fenetre = sequence.indexOf('new BrowserWindow');
|
||||
assert.ok(retrait !== -1 && fenetre !== -1 && retrait < fenetre, `ordre des appels : ${sequence.join(', ')}`);
|
||||
});
|
||||
|
||||
test('la fenêtre charge la page que Vite construit dans www/', () => {
|
||||
assert.deepEqual(journalDeLaCoquille().chargements, [
|
||||
{ loadFile: join(RACINE, 'www', 'index.html') },
|
||||
]);
|
||||
});
|
||||
|
||||
test(`un seul canal IPC, ${CANAL}, posé avant le chargement de la page`, () => {
|
||||
const { canaux, sequence } = journalDeLaCoquille();
|
||||
assert.deepEqual(canaux, [CANAL]);
|
||||
const pose = sequence.indexOf(`ipcMain.handle:${CANAL}`);
|
||||
const charge = sequence.indexOf('loadFile');
|
||||
assert.ok(pose !== -1 && charge !== -1 && pose < charge, `ordre des appels : ${sequence.join(', ')}`);
|
||||
});
|
||||
|
||||
test("la page n'ouvre aucune fenêtre et ne navigue nulle part", () => {
|
||||
const { ouvertures, navigations } = journalDeLaCoquille();
|
||||
assert.deepEqual(ouvertures, [{ action: 'deny' }]);
|
||||
assert.ok(navigations.length > 0, 'aucun écouteur de will-navigate');
|
||||
assert.deepEqual(navigations, navigations.map(() => true));
|
||||
});
|
||||
});
|
||||
|
||||
describe('coquille : préchargement', () => {
|
||||
test("n'expose que window.gtt, et ne requiert qu'electron", () => {
|
||||
const { prechargement } = journalDeLaCoquille();
|
||||
assert.equal(prechargement.erreur, undefined, prechargement.erreur);
|
||||
assert.deepEqual(prechargement.requis, ['electron']);
|
||||
assert.deepEqual(prechargement.exposes, [
|
||||
{ cle: 'gtt', membres: ['dossierExecutable', 'plateforme'] },
|
||||
]);
|
||||
assert.equal(prechargement.plateforme, 'electron');
|
||||
});
|
||||
|
||||
test(`gtt.dossierExecutable() demande à chaque appel, par ${CANAL}, le dossier publié par le lanceur portable, null sans lui`, () => {
|
||||
const { prechargement, invocations } = journalDeLaCoquille();
|
||||
assert.equal(prechargement.erreur, undefined, prechargement.erreur);
|
||||
assert.deepEqual(prechargement.reponses, {
|
||||
publie: { valeur: 'E:\\soirees' },
|
||||
absent: { valeur: null },
|
||||
vide: { valeur: null },
|
||||
});
|
||||
// Le préchargement voit lui aussi process.env : seules les invocations
|
||||
// montrent que la réponse vient du processus principal, une par appel.
|
||||
assert.deepEqual(invocations, [CANAL, CANAL, CANAL]);
|
||||
});
|
||||
});
|
||||
238
test/electron_factice.js
Normal file
238
test/electron_factice.js
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Electron factice : la coquille s'éprouve sous Node, sans Electron.
|
||||
//
|
||||
// node --import <ce module> electron/main.js
|
||||
//
|
||||
// Chargé avant electron/main.js, ce module fait résoudre le spécificateur
|
||||
// « electron » vers lui-même : le processus principal s'exécute contre les
|
||||
// fausses API exportées ici, qui consignent chaque appel. Quand le processus
|
||||
// n'a plus rien à faire, le module joue le rôle de la page : il exécute
|
||||
// electron/preload.cjs dans une fonction qui reçoit require, module et
|
||||
// exports, appelle le pont exposé et les gestionnaires posés par le processus
|
||||
// principal, puis imprime le journal en JSON sur la sortie standard.
|
||||
//
|
||||
// Seul require est restreint, à electron, comme dans un rendu en bac à
|
||||
// sable. Les globales de Node du processus d'épreuve, dont process et Buffer,
|
||||
// restent visibles du préchargement, et un vrai préchargement en bac à sable
|
||||
// lit lui aussi process.env. Un pont qui lirait lui-même la variable
|
||||
// répondrait donc juste : le journal des invocations, et non l'absence de
|
||||
// process, montre que chaque réponse vient du processus principal.
|
||||
//
|
||||
// Les exports sont la surface d'Electron que la coquille a le droit
|
||||
// d'employer : un import absent d'ici fait échouer le chargement de
|
||||
// electron/main.js. Ils refusent ce qu'Electron refuse et qu'une erreur de
|
||||
// la coquille provoquerait : une fenêtre créée avant que l'application soit
|
||||
// prête, un second gestionnaire pour un même canal, un appel à un canal sans
|
||||
// gestionnaire.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { compileFunction } from 'node:vm';
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === 'electron') return { url: import.meta.url, shortCircuit: true };
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
const PRECHARGEMENT = fileURLToPath(new URL('../electron/preload.cjs', import.meta.url));
|
||||
|
||||
// Valeur de PORTABLE_EXECUTABLE_DIR quand le lanceur portable la publie.
|
||||
const DOSSIER_PUBLIE = 'E:\\soirees';
|
||||
|
||||
const journal = {
|
||||
// Appels qui comptent pour l'ordre, dans l'ordre où ils arrivent.
|
||||
sequence: [],
|
||||
// Options de chaque new BrowserWindow.
|
||||
fenetres: [],
|
||||
// Argument de chaque Menu.setApplicationMenu.
|
||||
menus: [],
|
||||
// { loadFile: chemin } ou { loadURL: adresse }, par appel.
|
||||
chargements: [],
|
||||
// Canal de chaque ipcMain.handle ou ipcMain.on.
|
||||
canaux: [],
|
||||
// Canal de chaque ipcRenderer.invoke du préchargement, dans l'ordre des
|
||||
// appels.
|
||||
invocations: [],
|
||||
// Réponse du gestionnaire d'ouverture de chaque fenêtre à une adresse
|
||||
// externe.
|
||||
ouvertures: [],
|
||||
// Pour chaque écouteur de will-navigate : la navigation est-elle annulée ?
|
||||
navigations: [],
|
||||
// Ce que fait le préchargement : modules requis, objets exposés, et la
|
||||
// réponse du pont selon PORTABLE_EXECUTABLE_DIR.
|
||||
prechargement: null,
|
||||
};
|
||||
|
||||
const gestionnaires = new Map();
|
||||
const pageDeFenetre = [];
|
||||
|
||||
// L'application n'est jamais prête pendant l'évaluation du module principal,
|
||||
// ni dans les microtâches qu'elle lance : whenReady() la rend prête à la
|
||||
// phase setImmediate qui suit, puis résout, la même promesse à chaque appel.
|
||||
// Sans appel à whenReady(), elle ne l'est jamais.
|
||||
let pret = false;
|
||||
let quandPret;
|
||||
|
||||
export const app = {
|
||||
whenReady() {
|
||||
journal.sequence.push('app.whenReady');
|
||||
quandPret ??= new Promise((resoudre) => {
|
||||
setImmediate(() => {
|
||||
pret = true;
|
||||
resoudre();
|
||||
});
|
||||
});
|
||||
return quandPret;
|
||||
},
|
||||
on(evenement) {
|
||||
journal.sequence.push(`app.on:${evenement}`);
|
||||
},
|
||||
quit() {
|
||||
journal.sequence.push('app.quit');
|
||||
},
|
||||
};
|
||||
|
||||
export class BrowserWindow {
|
||||
constructor(options) {
|
||||
if (!pret) throw new Error('Cannot create BrowserWindow before app is ready');
|
||||
journal.sequence.push('new BrowserWindow');
|
||||
journal.fenetres.push(options);
|
||||
const page = { ouverture: null, ecouteurs: [] };
|
||||
pageDeFenetre.push(page);
|
||||
this.webContents = {
|
||||
setWindowOpenHandler(gestionnaire) {
|
||||
page.ouverture = gestionnaire;
|
||||
},
|
||||
on(evenement, ecouteur) {
|
||||
page.ecouteurs.push({ evenement, ecouteur });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
loadFile(chemin) {
|
||||
journal.sequence.push('loadFile');
|
||||
journal.chargements.push({ loadFile: chemin });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
loadURL(adresse) {
|
||||
journal.sequence.push('loadURL');
|
||||
journal.chargements.push({ loadURL: adresse });
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export const Menu = {
|
||||
setApplicationMenu(menu) {
|
||||
journal.sequence.push('Menu.setApplicationMenu');
|
||||
journal.menus.push(menu);
|
||||
},
|
||||
};
|
||||
|
||||
export const ipcMain = {
|
||||
handle(canal, gestionnaire) {
|
||||
journal.sequence.push(`ipcMain.handle:${canal}`);
|
||||
journal.canaux.push(canal);
|
||||
if (gestionnaires.has(canal)) {
|
||||
throw new Error(`Attempted to register a second handler for '${canal}'`);
|
||||
}
|
||||
gestionnaires.set(canal, gestionnaire);
|
||||
},
|
||||
on(canal) {
|
||||
journal.sequence.push(`ipcMain.on:${canal}`);
|
||||
journal.canaux.push(canal);
|
||||
},
|
||||
};
|
||||
|
||||
// Côté page : ce que require('electron') rend au préchargement. invoke se
|
||||
// consigne, atteint le gestionnaire posé par le processus principal pour ce
|
||||
// canal, et rejette comme Electron quand il n'y en a pas.
|
||||
function electronDuRendu(exposes) {
|
||||
return {
|
||||
contextBridge: {
|
||||
exposeInMainWorld(cle, api) {
|
||||
exposes.push({ cle, api });
|
||||
},
|
||||
},
|
||||
ipcRenderer: {
|
||||
async invoke(canal, ...arguments_) {
|
||||
journal.invocations.push(canal);
|
||||
const gestionnaire = gestionnaires.get(canal);
|
||||
if (gestionnaire === undefined) throw new Error(`No handler registered for '${canal}'`);
|
||||
return gestionnaire({ sender: null }, ...arguments_);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Réponse du pont pour une valeur de PORTABLE_EXECUTABLE_DIR, undefined
|
||||
// valant variable absente ; une erreur se consigne par son message.
|
||||
async function reponseDuPont(api, valeur) {
|
||||
if (valeur === undefined) delete process.env.PORTABLE_EXECUTABLE_DIR;
|
||||
else process.env.PORTABLE_EXECUTABLE_DIR = valeur;
|
||||
try {
|
||||
return { valeur: await api.dossierExecutable() };
|
||||
} catch (erreur) {
|
||||
return { erreur: erreur.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function jouerLePrechargement() {
|
||||
const requis = [];
|
||||
const exposes = [];
|
||||
const electron = electronDuRendu(exposes);
|
||||
const requerir = (nom) => {
|
||||
requis.push(nom);
|
||||
if (nom === 'electron') return electron;
|
||||
throw new Error(`module « ${nom} » indisponible dans un préchargement en bac à sable`);
|
||||
};
|
||||
const module = { exports: {} };
|
||||
compileFunction(readFileSync(PRECHARGEMENT, 'utf8'), ['require', 'module', 'exports'], {
|
||||
filename: PRECHARGEMENT,
|
||||
})(requerir, module, module.exports);
|
||||
|
||||
const gtt = exposes.find(({ cle }) => cle === 'gtt')?.api;
|
||||
return {
|
||||
requis,
|
||||
exposes: exposes.map(({ cle, api }) => ({ cle, membres: Object.keys(api).sort() })),
|
||||
plateforme: gtt?.plateforme ?? null,
|
||||
reponses:
|
||||
typeof gtt?.dossierExecutable === 'function'
|
||||
? {
|
||||
publie: await reponseDuPont(gtt, DOSSIER_PUBLIE),
|
||||
absent: await reponseDuPont(gtt, undefined),
|
||||
vide: await reponseDuPont(gtt, ''),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function jouerLaPage() {
|
||||
const externe = 'https://exemple.invalid/';
|
||||
for (const page of pageDeFenetre) {
|
||||
journal.ouvertures.push(page.ouverture === null ? null : page.ouverture({ url: externe }));
|
||||
for (const { evenement, ecouteur } of page.ecouteurs) {
|
||||
if (evenement !== 'will-navigate') continue;
|
||||
let annulee = false;
|
||||
ecouteur({ url: externe, preventDefault: () => (annulee = true) }, externe);
|
||||
journal.navigations.push(annulee);
|
||||
}
|
||||
}
|
||||
try {
|
||||
journal.prechargement = await jouerLePrechargement();
|
||||
} catch (erreur) {
|
||||
journal.prechargement = { erreur: erreur.message };
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(journal)}\n`);
|
||||
}
|
||||
|
||||
process.once('beforeExit', () => {
|
||||
jouerLaPage().catch((erreur) => {
|
||||
process.stderr.write(`${erreur.stack}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
});
|
||||
123
test/fils.test.js
Normal file
123
test/fils.test.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Sur une petite machine, les séries node et node-long se jouent en threads
|
||||
// (test/machine.js). Un thread de Node refuse ce qui changerait le processus
|
||||
// entier — process.chdir, process.umask avec un masque, les changements
|
||||
// d'identité — là où un processus complet l'accepte : une épreuve qui s'en
|
||||
// servirait passerait sur une grande machine et échouerait sur une petite.
|
||||
// La garde les refuse dans tout le code que ces séries exécutent : les
|
||||
// épreuves de src/ et de scripts/, les modules sous test/, et les scripts
|
||||
// qu'elles importent. L'environnement, lui, est copié dans chaque thread :
|
||||
// une épreuve qui écrit process.env ne trouble pas sa voisine, et la garde ne
|
||||
// le refuse pas.
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
// Appels qu'un thread de Node refuse. Un motif exige la parenthèse de
|
||||
// l'appel : une phrase qui nomme process.chdir n'en est pas un. umask sans
|
||||
// argument lit le masque, ce qu'un thread accepte.
|
||||
const REFUSES_DANS_UN_THREAD = [
|
||||
['process.chdir', /\bprocess\s*\??\.\s*chdir\s*(?:\?\.)?\s*\(/g],
|
||||
['process.umask(masque)', /\bprocess\s*\??\.\s*umask\s*(?:\?\.)?\s*\(\s*[^)\s]/g],
|
||||
['process.set…id', /\bprocess\s*\??\.\s*(?:set(?:e?[ug]id|groups)|initgroups)\s*(?:\?\.)?\s*\(/g],
|
||||
];
|
||||
|
||||
// Ce fichier nomme ces appels dans ses motifs et dans ses données d'épreuve :
|
||||
// il est hors de son propre balayage.
|
||||
const CE_FICHIER = fileURLToPath(import.meta.url);
|
||||
|
||||
// Fichiers JavaScript que les séries node exécutent : les épreuves de src/ et
|
||||
// de scripts/, hors épreuves du navigateur, tout module sous test/, et les
|
||||
// scripts de scripts/, que des épreuves importent.
|
||||
function fichiersExecutes(racine) {
|
||||
const sous = (dossier) => {
|
||||
try {
|
||||
return readdirSync(join(racine, dossier), { recursive: true, withFileTypes: true })
|
||||
.filter((e) => e.isFile() && /\.[cm]?js$/.test(e.name))
|
||||
.map((e) => join(e.parentPath, e.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
return [
|
||||
...sous('src').filter((f) => /\.test\.[cm]?js$/.test(f) && !/\.navigateur\.test\.[cm]?js$/.test(f)),
|
||||
...sous('scripts'),
|
||||
...sous('test').filter((f) => !f.includes(`${join('test', 'fixtures')}`) && f !== CE_FICHIER),
|
||||
].sort();
|
||||
}
|
||||
|
||||
// Relevé « fichier:ligne appel » de ces appels, par fichier puis par ligne.
|
||||
// Lève quand aucun fichier n'est examiné (§ 14.2).
|
||||
function releverAppelsRefuses(racine) {
|
||||
const fichiers = fichiersExecutes(racine);
|
||||
assert.ok(fichiers.length > 0, 'aucun fichier examiné');
|
||||
return fichiers.flatMap((fichier) => {
|
||||
const texte = readFileSync(fichier, 'utf8');
|
||||
return REFUSES_DANS_UN_THREAD.flatMap(([appel, motif]) =>
|
||||
[...texte.matchAll(motif)].map(({ index }) => ({ ligne: texte.slice(0, index).split('\n').length, appel })),
|
||||
)
|
||||
.sort((a, b) => a.ligne - b.ligne)
|
||||
.map(({ ligne, appel }) => `${relative(racine, fichier)}:${ligne} ${appel}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Écrit les fichiers donnés dans une racine temporaire, rend le relevé, et
|
||||
// efface la racine.
|
||||
function avecArbre(fichiers) {
|
||||
const racine = mkdtempSync(join(tmpdir(), 'fils-'));
|
||||
try {
|
||||
for (const [chemin, texte] of Object.entries(fichiers)) {
|
||||
mkdirSync(dirname(join(racine, chemin)), { recursive: true });
|
||||
writeFileSync(join(racine, chemin), texte);
|
||||
}
|
||||
return releverAppelsRefuses(racine);
|
||||
} finally {
|
||||
rmSync(racine, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('fils : ce qu’un thread refuse, hors des séries node (test/machine.js)', () => {
|
||||
test('aucune épreuve, aucun module sous test/ ni aucun script n’appelle ce qu’un thread refuse', () => {
|
||||
assert.deepEqual(releverAppelsRefuses(RACINE), []);
|
||||
});
|
||||
|
||||
test('la garde relève chaque appel par sa ligne, dans les épreuves, les modules sous test/ et les scripts, et pas ses voisins', () => {
|
||||
const fichiers = {
|
||||
'src/moteur/a.test.js': [
|
||||
"process.chdir('/tmp');",
|
||||
'process.umask(0o022);',
|
||||
'process.setuid(1000);',
|
||||
'process?.chdir?.(ici);',
|
||||
].join('\n'),
|
||||
'scripts/outil.js': 'process.setgroups([]);\n',
|
||||
'test/aide.js': 'process.initgroups("x", 1);\n',
|
||||
'src/moteur/voisins.test.js': [
|
||||
'const ici = process.cwd();',
|
||||
'const masque = process.umask();',
|
||||
"process.env.SONDE = 'copie par thread';",
|
||||
'// process.chdir changerait le processus entier.',
|
||||
].join('\n'),
|
||||
'src/moteur/module.js': "process.chdir('/tmp');\n",
|
||||
'src/interface/App.navigateur.test.js': "process.chdir('/tmp');\n",
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers), [
|
||||
'scripts/outil.js:1 process.set…id',
|
||||
'src/moteur/a.test.js:1 process.chdir',
|
||||
'src/moteur/a.test.js:2 process.umask(masque)',
|
||||
'src/moteur/a.test.js:3 process.set…id',
|
||||
'src/moteur/a.test.js:4 process.chdir',
|
||||
'test/aide.js:1 process.set…id',
|
||||
]);
|
||||
});
|
||||
|
||||
test('un arbre sans fichier exécuté par ces séries fait échouer la garde', () => {
|
||||
assert.throws(() => avecArbre({ 'src/moteur/module.js': 'export const a = 1;\n' }), /aucun fichier examiné/);
|
||||
});
|
||||
});
|
||||
7
test/lanceur.js
Normal file
7
test/lanceur.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Point d'import unique des primitives de test. Remplacer cette ligne par
|
||||
// « export { test, describe } from 'node:test'; » fait tourner la série node
|
||||
// sous « node --test » sans toucher aux fichiers de test (§ 14.8).
|
||||
export { test, describe } from 'vitest';
|
||||
146
test/licence.test.js
Normal file
146
test/licence.test.js
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Chaque fichier source du projet s'ouvre sur l'en-tête de licence : la ligne
|
||||
// du titulaire, puis celle de la licence, après un éventuel shebang ou la
|
||||
// déclaration <!doctype html>. Les deux lignes sont des commentaires du
|
||||
// langage du fichier ; le texte ne varie pas. package.json déclare la même
|
||||
// licence et le même titulaire, que reprennent les outils d'emballage.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { extname, join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
const TITULAIRE = /^© \d{4} TechnoLibre \(http:\/\/www\.technolibre\.ca\)$/;
|
||||
const LICENCE = 'License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)';
|
||||
|
||||
// Marqueurs de commentaire, par extension de fichier source. Un commentaire
|
||||
// de ligne n'a pas de fermant.
|
||||
const COMMENTAIRE = new Map([
|
||||
['.js', { ouvrant: '//', fermant: null }],
|
||||
['.cjs', { ouvrant: '//', fermant: null }],
|
||||
['.mjs', { ouvrant: '//', fermant: null }],
|
||||
['.sh', { ouvrant: '#', fermant: null }],
|
||||
['.svelte', { ouvrant: '<!--', fermant: '-->' }],
|
||||
['.html', { ouvrant: '<!--', fermant: '-->' }],
|
||||
['.css', { ouvrant: '/*', fermant: '*/' }],
|
||||
]);
|
||||
|
||||
// Périmètre : les fichiers de la racine et les arbres des sources, des
|
||||
// épreuves, des scripts et de la coquille. Les dépendances et les sorties de
|
||||
// construction n'y entrent pas, ni les données d'épreuve, qui sont des
|
||||
// données et non des sources. Le séparateur final borne l'exclusion au
|
||||
// répertoire test/fixtures/ : un module test/fixtures.js reste une source.
|
||||
const ARBRES = ['src', 'test', 'scripts', 'electron'];
|
||||
const DONNEES_EPREUVE = join(RACINE, 'test', 'fixtures') + sep;
|
||||
|
||||
function fichiersSources() {
|
||||
const chemins = readdirSync(RACINE, { withFileTypes: true })
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => join(RACINE, e.name));
|
||||
for (const arbre of ARBRES) {
|
||||
const dossier = join(RACINE, arbre);
|
||||
let entrees;
|
||||
try {
|
||||
entrees = readdirSync(dossier, { withFileTypes: true, recursive: true });
|
||||
} catch (erreur) {
|
||||
if (erreur.code === 'ENOENT') continue;
|
||||
throw erreur;
|
||||
}
|
||||
for (const e of entrees) {
|
||||
if (e.isFile()) chemins.push(join(e.parentPath, e.name));
|
||||
}
|
||||
}
|
||||
return chemins
|
||||
.filter((chemin) => COMMENTAIRE.has(extname(chemin)))
|
||||
.filter((chemin) => !chemin.startsWith(DONNEES_EPREUVE))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Vrai quand les deux lignes sont des commentaires, et que le commentaire ne
|
||||
// se prolonge pas au-delà de la seconde. Un commentaire de ligne ouvre
|
||||
// chacune d'elles. Un commentaire de bloc court sur les deux — ouvert sur la
|
||||
// première sans s'y fermer, fermé en fin de seconde — ou s'ouvre et se ferme
|
||||
// sur chacune. Une seconde ligne hors commentaire se lit comme du code : un
|
||||
// texte affiché avant le bandeau dans un composant, une règle de style
|
||||
// invalidée dans une feuille, une commande dans un script.
|
||||
function enCommentaire(premiere, seconde, { ouvrant, fermant }) {
|
||||
const suite = seconde.trim();
|
||||
if (!premiere.startsWith(ouvrant)) return false;
|
||||
if (fermant === null) return suite.startsWith(ouvrant);
|
||||
const courant = !premiere.includes(fermant) && suite.endsWith(fermant);
|
||||
const repete =
|
||||
premiere.trimEnd().endsWith(fermant) &&
|
||||
suite.startsWith(ouvrant) &&
|
||||
suite.endsWith(fermant);
|
||||
return courant || repete;
|
||||
}
|
||||
|
||||
// Vrai quand le contenu s'ouvre sur l'en-tête, après un éventuel shebang ou
|
||||
// la déclaration <!doctype html> : deux lignes de commentaire du langage de
|
||||
// l'extension, dont le texte, débarrassé des marqueurs, est celui de
|
||||
// l'en-tête.
|
||||
function porteLEnTete(contenu, extension) {
|
||||
let lignes = contenu.split('\n');
|
||||
if (lignes[0].startsWith('#!')) lignes = lignes.slice(1);
|
||||
if (/^<!doctype html>$/i.test(lignes[0])) lignes = lignes.slice(1);
|
||||
const [premiere = '', seconde = ''] = lignes;
|
||||
const texte = (ligne) =>
|
||||
ligne
|
||||
.replace(/^\s*(?:\/\/|#|<!--|\/\*|\*)?\s*/, '')
|
||||
.replace(/\s*(?:-->|\*\/)?\s*$/, '');
|
||||
return (
|
||||
enCommentaire(premiere, seconde, COMMENTAIRE.get(extension)) &&
|
||||
TITULAIRE.test(texte(premiere)) &&
|
||||
texte(seconde) === LICENCE
|
||||
);
|
||||
}
|
||||
|
||||
describe('licence', () => {
|
||||
test("chaque fichier source s'ouvre sur l'en-tête de licence", () => {
|
||||
const fichiers = fichiersSources();
|
||||
assert.ok(fichiers.length > 0, 'aucun fichier source examiné');
|
||||
const sansEnTete = fichiers
|
||||
.filter((chemin) => !porteLEnTete(readFileSync(chemin, 'utf8'), extname(chemin)))
|
||||
.map((chemin) => relative(RACINE, chemin));
|
||||
assert.deepEqual(sansEnTete, []);
|
||||
});
|
||||
|
||||
test("l'en-tête tient en deux lignes de commentaire du langage du fichier", () => {
|
||||
const titulaire = '© 2026 TechnoLibre (http://www.technolibre.ca)';
|
||||
// [extension, contenu, admis] : les formes admises ; puis des secondes
|
||||
// lignes laissées hors commentaire, que le langage lirait comme du code,
|
||||
// et un commentaire qui se prolonge au-delà de la seconde ligne.
|
||||
const cas = [
|
||||
['.js', `// ${titulaire}\n// ${LICENCE}\n`, true],
|
||||
['.sh', `#!/bin/sh\n# ${titulaire}\n# ${LICENCE}\n`, true],
|
||||
['.svelte', `<!-- ${titulaire}\n ${LICENCE} -->\n`, true],
|
||||
['.svelte', `<!-- ${titulaire} -->\n<!-- ${LICENCE} -->\n`, true],
|
||||
['.html', `<!doctype html>\n<!-- ${titulaire}\n ${LICENCE} -->\n`, true],
|
||||
['.css', `/* ${titulaire}\n ${LICENCE} */\n`, true],
|
||||
['.css', `/* ${titulaire} */\n/* ${LICENCE} */\n`, true],
|
||||
['.js', `// ${titulaire}\n${LICENCE}\n`, false],
|
||||
['.sh', `#!/bin/sh\n# ${titulaire}\n${LICENCE}\n`, false],
|
||||
['.svelte', `<!-- ${titulaire} -->\n${LICENCE}\n`, false],
|
||||
['.html', `<!doctype html>\n<!-- ${titulaire} -->\n${LICENCE}\n`, false],
|
||||
['.html', `<!-- ${titulaire}\n ${LICENCE}\n-->\n`, false],
|
||||
['.css', `/* ${titulaire} */\n${LICENCE}\n`, false],
|
||||
['.css', `/* ${titulaire} */\n * ${LICENCE} */\n`, false],
|
||||
];
|
||||
const ecarts = cas
|
||||
.filter(([extension, contenu, admis]) => porteLEnTete(contenu, extension) !== admis)
|
||||
.map(([extension, contenu, admis]) =>
|
||||
`${extension} ${admis ? 'refusé' : 'admis'} : ${JSON.stringify(contenu)}`,
|
||||
);
|
||||
assert.deepEqual(ecarts, []);
|
||||
});
|
||||
|
||||
test('package.json déclare la licence et le titulaire', () => {
|
||||
const paquet = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8'));
|
||||
assert.equal(paquet.license, 'AGPL-3.0-or-later');
|
||||
assert.equal(paquet.author, 'TechnoLibre (http://www.technolibre.ca)');
|
||||
});
|
||||
});
|
||||
221
test/livrable.test.js
Normal file
221
test/livrable.test.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le livrable tel qu'electron-builder.config.cjs le décrit : son nom (§ 18.2),
|
||||
// sa forme (§ 16), ce qu'il emporte (§ 14.15) et l'identité inscrite dans sa
|
||||
// ressource de version ; et le nom de l'application, un seul pour Electron,
|
||||
// pour Capacitor et pour le titre de la fenêtre (§ 18.6). La configuration se
|
||||
// charge ici comme electron-builder la charge, par require.
|
||||
import assert from 'node:assert/strict';
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire, isBuiltin } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { deriver } from '../scripts/version.js';
|
||||
import { titreAvecVersion } from '../src/interface/libelles.js';
|
||||
import { VERSION } from '../src/version.genere.js';
|
||||
import { chargements } from './chargements.js';
|
||||
import { describe, test } from './lanceur.js';
|
||||
import { versionVoisine } from './version_voisine.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
const CONFIGURATION = 'electron-builder.config.cjs';
|
||||
|
||||
const lireJson = (racine, chemin) => JSON.parse(readFileSync(join(racine, chemin), 'utf8'));
|
||||
const charger = (racine) => {
|
||||
const chemin = join(racine, CONFIGURATION);
|
||||
return createRequire(chemin)(chemin);
|
||||
};
|
||||
|
||||
// Fichiers d'electron/ sous une racine, à toute profondeur, en chemins
|
||||
// absolus.
|
||||
const fichiersDElectron = (racine) =>
|
||||
readdirSync(join(racine, 'electron'), { recursive: true, withFileTypes: true })
|
||||
.filter((entree) => entree.isFile())
|
||||
.map((entree) => join(entree.parentPath, entree.name));
|
||||
|
||||
// Raison de refuser le chargement que demande un fichier du répertoire
|
||||
// electron donné, ou null quand il reste dans le paquet : electron, un module
|
||||
// de Node hors du lanceur d'épreuves, ou un fichier de ce répertoire. Le
|
||||
// paquet n'emporte ni node_modules ni rien de test/ : un chargement qui en
|
||||
// sort échoue au démarrage de l'exécutable. Un chargement calculé ne se
|
||||
// vérifie pas d'ici.
|
||||
function raisonDeRefus(electron, { fichier, specificateur, argument }) {
|
||||
if (specificateur === undefined) return `argument calculé, ${argument} : il ne se vérifie pas`;
|
||||
if (/^(?:vitest|node:test)(?:\/|$)|^@vitest\//.test(specificateur)) return "lanceur d'épreuves";
|
||||
if (specificateur === 'electron' || isBuiltin(specificateur)) return null;
|
||||
if (/^\.{1,2}(?:\/|$)/.test(specificateur)) {
|
||||
return resolve(dirname(fichier), specificateur).startsWith(`${electron}${sep}`) ? null : "hors d'electron/";
|
||||
}
|
||||
return "hors du paquet, qui n'emporte aucun node_modules";
|
||||
}
|
||||
|
||||
// Chargements refusés des fichiers d'electron/ sous une racine, par fichier
|
||||
// puis dans l'ordre du texte : chacun est { fichier, specificateur, raison }
|
||||
// ou { fichier, argument, raison }, fichier relatif à la racine. Lève quand
|
||||
// electron/ n'a aucun fichier, ou quand ses fichiers ne demandent aucun
|
||||
// chargement.
|
||||
function refusDElectron(racine) {
|
||||
const electron = join(racine, 'electron');
|
||||
const fichiers = fichiersDElectron(racine).sort();
|
||||
assert.ok(fichiers.length > 0, 'electron/ ne contient aucun fichier');
|
||||
const demandes = fichiers.flatMap((fichier) =>
|
||||
chargements(readFileSync(fichier, 'utf8')).map(({ specificateur, argument }) =>
|
||||
specificateur === undefined ? { fichier, argument } : { fichier, specificateur },
|
||||
),
|
||||
);
|
||||
assert.ok(demandes.length > 0, 'aucun chargement relevé dans electron/');
|
||||
return demandes
|
||||
.map((demande) => ({ ...demande, fichier: relative(racine, demande.fichier), raison: raisonDeRefus(electron, demande) }))
|
||||
.filter(({ raison }) => raison !== null);
|
||||
}
|
||||
|
||||
describe('livrable : nom', () => {
|
||||
test('le nom est celui que deriver calcule depuis version.json (§ 18.2)', () => {
|
||||
const { version } = lireJson(RACINE, 'version.json');
|
||||
assert.equal(charger(RACINE).artifactName, deriver(version).nomFichier);
|
||||
});
|
||||
|
||||
test("le nom suit version.json : la configuration ne l'écrit pas elle-même", () => {
|
||||
// Un arbre où version.json porte la version voisine. La configuration et
|
||||
// scripts/version.js y sont recopiés tels quels.
|
||||
const { version } = lireJson(RACINE, 'version.json');
|
||||
const autre = versionVoisine(version);
|
||||
const arbre = mkdtempSync(join(tmpdir(), 'livrable-'));
|
||||
try {
|
||||
for (const chemin of [CONFIGURATION, 'scripts/version.js', 'package.json', 'capacitor.config.json']) {
|
||||
mkdirSync(dirname(join(arbre, chemin)), { recursive: true });
|
||||
copyFileSync(join(RACINE, chemin), join(arbre, chemin));
|
||||
}
|
||||
writeFileSync(join(arbre, 'version.json'), `${JSON.stringify({ version: autre })}\n`);
|
||||
const nom = charger(arbre).artifactName;
|
||||
assert.equal(nom, deriver(autre).nomFichier);
|
||||
assert.notEqual(nom, deriver(version).nomFichier);
|
||||
} finally {
|
||||
rmSync(arbre, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('livrable : forme et contenu', () => {
|
||||
test('un exécutable portable à fichier unique, pour Windows x64 : jamais un installateur (§ 16)', () => {
|
||||
assert.deepEqual(charger(RACINE).win, { target: [{ target: 'portable', arch: ['x64'] }] });
|
||||
});
|
||||
|
||||
test("sortie dans dist-electron/ ; le paquet n'emporte que la page, la coquille et package.json", () => {
|
||||
const configuration = charger(RACINE);
|
||||
assert.deepEqual(configuration.directories, { output: 'dist-electron' });
|
||||
assert.deepEqual(configuration.files, ['www/**', 'electron/**', 'package.json']);
|
||||
});
|
||||
|
||||
test("la configuration n'a pas d'autre clé que celles que ces épreuves examinent (§ 14.15, § 18.1)", () => {
|
||||
// Une clé de plus change ce que le paquet emporte (extraResources,
|
||||
// extraFiles, asar) ou la version qu'il porte (buildVersion, buildNumber),
|
||||
// et aucune épreuve ne la lirait : elle échoue ici jusqu'à sa revue. Les
|
||||
// épreuves de chaque clé comparent sa valeur entière, si bien qu'une clé
|
||||
// ajoutée plus bas, sous win ou directories, échoue aussi.
|
||||
assert.deepEqual(Object.keys(charger(RACINE)).sort(), [
|
||||
'appId',
|
||||
'artifactName',
|
||||
'copyright',
|
||||
'directories',
|
||||
'files',
|
||||
'productName',
|
||||
'win',
|
||||
]);
|
||||
});
|
||||
|
||||
test("electron/, que le paquet emporte en entier, ne contient aucune épreuve (§ 14.15)", () => {
|
||||
const fichiers = fichiersDElectron(RACINE);
|
||||
assert.ok(fichiers.length > 0, 'electron/ ne contient aucun fichier');
|
||||
assert.deepEqual(fichiers.filter((chemin) => /\.test\.[cm]?js$/.test(chemin)), []);
|
||||
});
|
||||
|
||||
test("electron/ ne charge qu'electron, Node et ses propres fichiers : ni épreuve, ni lanceur, ni dépendance (§ 14.9)", () => {
|
||||
assert.deepEqual(refusDElectron(RACINE), []);
|
||||
});
|
||||
|
||||
test("la garde d'electron/ relève chaque chargement refusé, import à commentaire entre ses accolades compris, et admet electron, Node et les fichiers d'electron/", () => {
|
||||
const arbre = mkdtempSync(join(tmpdir(), 'livrable-'));
|
||||
try {
|
||||
mkdirSync(join(arbre, 'electron'));
|
||||
writeFileSync(join(arbre, 'electron', 'main.js'), [
|
||||
"import { app } from 'electron';",
|
||||
"import { join } from 'node:path';",
|
||||
'import {',
|
||||
" // l'API des épreuves",
|
||||
' test,',
|
||||
"} from 'vitest';",
|
||||
'import {',
|
||||
" // l'aide d'à côté",
|
||||
' aide,',
|
||||
"} from '../test/aide.js';",
|
||||
"import { voisin } from './voisin.js';",
|
||||
"const prechargement = require('./preload.cjs');",
|
||||
'const module = require(chemin);',
|
||||
"const { mount } = await import('svelte');",
|
||||
].join('\n'));
|
||||
writeFileSync(join(arbre, 'electron', 'preload.cjs'), "const { contextBridge } = require('electron');\n");
|
||||
assert.deepEqual(refusDElectron(arbre), [
|
||||
{ fichier: join('electron', 'main.js'), specificateur: 'vitest', raison: "lanceur d'épreuves" },
|
||||
{ fichier: join('electron', 'main.js'), specificateur: '../test/aide.js', raison: "hors d'electron/" },
|
||||
{ fichier: join('electron', 'main.js'), argument: 'chemin', raison: 'argument calculé, chemin : il ne se vérifie pas' },
|
||||
{ fichier: join('electron', 'main.js'), specificateur: 'svelte', raison: "hors du paquet, qui n'emporte aucun node_modules" },
|
||||
]);
|
||||
writeFileSync(join(arbre, 'electron', 'main.js'), '// Aucun chargement.\n');
|
||||
writeFileSync(join(arbre, 'electron', 'preload.cjs'), '');
|
||||
assert.throws(() => refusDElectron(arbre), /aucun chargement relevé dans electron\//);
|
||||
} finally {
|
||||
rmSync(arbre, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("package.json ne déclare aucune dépendance d'exécution : le paquet n'emporte aucun node_modules", () => {
|
||||
const paquet = lireJson(RACINE, 'package.json');
|
||||
assert.equal(paquet.dependencies, undefined);
|
||||
assert.equal(paquet.optionalDependencies, undefined);
|
||||
assert.match(paquet.devDependencies?.electron ?? '', /^\^44(\.|$)/);
|
||||
assert.match(paquet.devDependencies?.['electron-builder'] ?? '', /^\^26(\.|$)/);
|
||||
assert.equal(paquet.main, 'electron/main.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('livrable : identité', () => {
|
||||
test("l'identifiant est celui de Capacitor, le nom de produit celui de package.json", () => {
|
||||
const configuration = charger(RACINE);
|
||||
assert.equal(configuration.appId, lireJson(RACINE, 'capacitor.config.json').appId);
|
||||
assert.equal(configuration.productName, lireJson(RACINE, 'package.json').productName);
|
||||
// Une source muette rend undefined des deux côtés de l'égalité, et
|
||||
// electron-builder retombe alors sur ses valeurs par défaut : chaque
|
||||
// valeur a aussi sa forme.
|
||||
assert.match(configuration.appId ?? '', /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/);
|
||||
assert.ok(
|
||||
typeof configuration.productName === 'string' && configuration.productName.trim() !== '',
|
||||
`productName vide : ${configuration.productName}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('le titulaire entre dans la ressource de version, champ LegalCopyright', () => {
|
||||
assert.equal(charger(RACINE).copyright, '© 2026 TechnoLibre');
|
||||
});
|
||||
|
||||
test("le nom de l'application est un seul : productName de package.json, appName de Capacitor, celui du titre de la fenêtre (§ 18.6)", () => {
|
||||
// electron-builder nomme l'application d'après productName, Capacitor
|
||||
// d'après appName, et la page pose le titre de la fenêtre par
|
||||
// titreAvecVersion : le nom suivi de la version affichée, que l'épreuve
|
||||
// retire pour lire le nom.
|
||||
const titre = titreAvecVersion(VERSION.affichee);
|
||||
const suite = ` — ${VERSION.affichee}`;
|
||||
assert.ok(titre.endsWith(suite), `le titre « ${titre} » ne finit pas sur « ${suite} »`);
|
||||
const nom = titre.slice(0, -suite.length);
|
||||
assert.ok(nom.trim() !== '', `le titre « ${titre} » ne porte aucun nom`);
|
||||
assert.deepEqual(
|
||||
{
|
||||
productName: lireJson(RACINE, 'package.json').productName,
|
||||
appName: lireJson(RACINE, 'capacitor.config.json').appName,
|
||||
},
|
||||
{ productName: nom, appName: nom },
|
||||
);
|
||||
});
|
||||
});
|
||||
61
test/machine.js
Normal file
61
test/machine.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Réglages des séries node selon le nombre de cœurs de la machine (§ 14.14).
|
||||
//
|
||||
// Le budget de relance, deux secondes, tient avec des processus complets à
|
||||
// partir de quatre cœurs. En deçà, le lanceur ne porte la série que dans un
|
||||
// seul processus, et la relance qui suit un module de base — que presque
|
||||
// toutes les épreuves importent — dépasse ce budget : de l'ordre de trois
|
||||
// secondes sur deux cœurs. Une petite machine prend donc des threads, moins
|
||||
// coûteux à démarrer qu'un processus, sur tous ses cœurs, et les épreuves
|
||||
// lourdes de la série surveillée passent dans la série longue : la même
|
||||
// relance y tient alors sous une seconde et demie.
|
||||
//
|
||||
// Aucune épreuve ne disparaît : elle change de série, et ce qu'elle éprouve
|
||||
// ne dépend pas de la machine. Les threads partagent un processus : la garde
|
||||
// de test/fils.test.js tient la série node à l'écart de l'état global du
|
||||
// processus, sans quoi une épreuve en troublerait une autre.
|
||||
import { availableParallelism } from 'node:os';
|
||||
|
||||
// Nombre de cœurs à partir duquel la série surveillée garde des processus
|
||||
// complets et toutes ses épreuves.
|
||||
export const SEUIL_COEURS = 4;
|
||||
|
||||
// Épreuves de la série surveillée qui passent dans node-long sur une petite
|
||||
// machine. L'épreuve de bout en bout lance de vraies recherches : c'est la
|
||||
// plus lourde de la série, et toute modification du moteur la rejoue.
|
||||
export const LOURDES = Object.freeze(['src/moteur/integration.test.js']);
|
||||
|
||||
/**
|
||||
* Réglages des projets node et node-long pour un nombre de cœurs.
|
||||
*
|
||||
* @param {number} coeurs entier ≥ 1
|
||||
* @returns {{petiteMachine: boolean, coeurs: number, pool: 'forks'|'threads',
|
||||
* maxWorkers: number|undefined, horsSerieSurveillee: string[]}}
|
||||
* maxWorkers undefined laisse au lanceur son défaut
|
||||
*/
|
||||
export function reglagesSeries(coeurs) {
|
||||
if (!Number.isInteger(coeurs) || coeurs < 1) {
|
||||
throw new RangeError(`nombre de cœurs ${String(coeurs)} : entier ≥ 1 attendu`);
|
||||
}
|
||||
if (coeurs >= SEUIL_COEURS) {
|
||||
return { petiteMachine: false, coeurs, pool: 'forks', maxWorkers: undefined, horsSerieSurveillee: [] };
|
||||
}
|
||||
return { petiteMachine: true, coeurs, pool: 'threads', maxWorkers: coeurs, horsSerieSurveillee: [...LOURDES] };
|
||||
}
|
||||
|
||||
// Cœurs que ce processus peut occuper. availableParallelism suit l'affinité
|
||||
// du processus : sous « taskset -c 0,1 », il rend 2 sur une machine qui en a
|
||||
// seize, ce qui permet d'éprouver le mode d'une petite machine sur une grande.
|
||||
export const coeursDisponibles = () => availableParallelism();
|
||||
|
||||
// Ligne que le rapporteur imprime, pour qu'un développeur sache sur quelle
|
||||
// série sa relance a porté.
|
||||
export function ligneMachine({ petiteMachine, coeurs, horsSerieSurveillee }) {
|
||||
const compte = coeurs === 1 ? '1 cœur' : `${coeurs} cœurs`;
|
||||
if (!petiteMachine) return `Machine : ${compte} — processus complets, série surveillée entière.`;
|
||||
const ou = coeurs === 1 ? 'sur son seul cœur' : `sur les ${coeurs} cœurs`;
|
||||
return `Machine : ${compte}, en deçà de ${SEUIL_COEURS} — threads ${ou} ; `
|
||||
+ `en série longue : ${horsSerieSurveillee.join(', ')}.`;
|
||||
}
|
||||
104
test/machine.test.js
Normal file
104
test/machine.test.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les réglages des séries node selon le nombre de cœurs (§ 14.14), et leur
|
||||
// application dans vitest.config.js.
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { availableParallelism } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import configuration from '../vitest.config.js';
|
||||
import { describe, test } from './lanceur.js';
|
||||
import { LOURDES, SEUIL_COEURS, coeursDisponibles, ligneMachine, reglagesSeries } from './machine.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
const projet = (nom) => configuration.test.projects.find(({ test: { name } }) => name === nom).test;
|
||||
|
||||
describe('machine : les réglages selon le nombre de cœurs (§ 14.14)', () => {
|
||||
test('à partir de quatre cœurs, des processus complets et la série surveillée entière', () => {
|
||||
for (const coeurs of [SEUIL_COEURS, 8, 16, 64]) {
|
||||
assert.deepEqual(reglagesSeries(coeurs), {
|
||||
petiteMachine: false,
|
||||
coeurs,
|
||||
pool: 'forks',
|
||||
maxWorkers: undefined,
|
||||
horsSerieSurveillee: [],
|
||||
}, `${coeurs} cœurs`);
|
||||
}
|
||||
});
|
||||
|
||||
test('en deçà de quatre cœurs, des threads sur tous les cœurs, et les épreuves lourdes en série longue', () => {
|
||||
for (const coeurs of [1, 2, 3]) {
|
||||
assert.deepEqual(reglagesSeries(coeurs), {
|
||||
petiteMachine: true,
|
||||
coeurs,
|
||||
pool: 'threads',
|
||||
maxWorkers: coeurs,
|
||||
horsSerieSurveillee: [...LOURDES],
|
||||
}, `${coeurs} cœurs`);
|
||||
}
|
||||
});
|
||||
|
||||
test('un nombre de cœurs qui n’est pas un entier ≥ 1 lève RangeError', () => {
|
||||
for (const coeurs of [0, -1, 2.5, Number.NaN, '4', undefined]) {
|
||||
assert.throws(() => reglagesSeries(coeurs), RangeError, String(coeurs));
|
||||
}
|
||||
});
|
||||
|
||||
test('les épreuves lourdes existent, sont de la série surveillée, et la liste n’est pas vide', () => {
|
||||
assert.ok(LOURDES.length > 0, 'aucune épreuve lourde nommée');
|
||||
for (const fichier of LOURDES) {
|
||||
assert.ok(existsSync(join(RACINE, fichier)), `${fichier} absent`);
|
||||
assert.match(fichier, /\.test\.js$/);
|
||||
assert.doesNotMatch(fichier, /\.(long|navigateur)\.test\.js$/, `${fichier} n'est pas de la série surveillée`);
|
||||
}
|
||||
});
|
||||
|
||||
test('les cœurs disponibles suivent l’affinité du processus, comme availableParallelism', () => {
|
||||
assert.equal(coeursDisponibles(), availableParallelism());
|
||||
assert.ok(coeursDisponibles() >= 1);
|
||||
});
|
||||
|
||||
test('la ligne annoncée dit le mode, sur une petite machine comme sur une grande', () => {
|
||||
assert.equal(
|
||||
ligneMachine(reglagesSeries(2)),
|
||||
'Machine : 2 cœurs, en deçà de 4 — threads sur les 2 cœurs ; '
|
||||
+ 'en série longue : src/moteur/integration.test.js.',
|
||||
);
|
||||
assert.equal(
|
||||
ligneMachine(reglagesSeries(1)),
|
||||
'Machine : 1 cœur, en deçà de 4 — threads sur son seul cœur ; '
|
||||
+ 'en série longue : src/moteur/integration.test.js.',
|
||||
);
|
||||
assert.equal(ligneMachine(reglagesSeries(16)), 'Machine : 16 cœurs — processus complets, série surveillée entière.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('machine : vitest.config.js applique les réglages de cette machine', () => {
|
||||
const reglages = reglagesSeries(coeursDisponibles());
|
||||
|
||||
test('les projets node et node-long prennent le pool et le nombre de processus retenus', () => {
|
||||
for (const nom of ['node', 'node-long']) {
|
||||
assert.equal(projet(nom).pool, reglages.pool, nom);
|
||||
assert.equal(projet(nom).maxWorkers, reglages.maxWorkers, nom);
|
||||
}
|
||||
});
|
||||
|
||||
test('une épreuve hors de la série surveillée entre dans la série longue, et nulle part ailleurs', () => {
|
||||
for (const fichier of reglages.horsSerieSurveillee) {
|
||||
assert.ok(projet('node').exclude.includes(fichier), `${fichier} reste dans node`);
|
||||
assert.ok(projet('node-long').include.includes(fichier), `${fichier} manque à node-long`);
|
||||
}
|
||||
for (const fichier of LOURDES.filter((f) => !reglages.horsSerieSurveillee.includes(f))) {
|
||||
assert.ok(!projet('node').exclude.includes(fichier), `${fichier} sort de node sur cette machine`);
|
||||
assert.ok(!projet('node-long').include.includes(fichier), `${fichier} entre dans node-long sur cette machine`);
|
||||
}
|
||||
});
|
||||
|
||||
test('le projet navigateur ne prend aucun de ces réglages', () => {
|
||||
const navigateur = projet('navigateur');
|
||||
assert.equal(navigateur.pool, undefined);
|
||||
assert.equal(navigateur.maxWorkers, undefined);
|
||||
});
|
||||
});
|
||||
33
test/projets.test.js
Normal file
33
test/projets.test.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le projet node n'expose aucun DOM et ne compile aucun composant : un module
|
||||
// qui toucherait document ou window, ou qui importerait un composant Svelte,
|
||||
// y échoue au lieu de passer sur un environnement que la série node ne doit
|
||||
// pas fournir (§ 14.4, § 14.8).
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
describe('projet node', () => {
|
||||
test('globalThis.document vaut undefined', () => {
|
||||
assert.equal(globalThis.document, undefined);
|
||||
});
|
||||
|
||||
test('globalThis.window vaut undefined', () => {
|
||||
assert.equal(globalThis.window, undefined);
|
||||
});
|
||||
|
||||
test('un composant Svelte ne se charge pas', async () => {
|
||||
// Le composant existe, et son import échoue sur la lecture du source :
|
||||
// sans greffon Svelte, l'analyse des imports de Vite rejette la syntaxe
|
||||
// d'un .svelte, et sous « node --test » Node en refuse l'extension. Un
|
||||
// refus venu d'ailleurs, comme un module qui lirait window au chargement,
|
||||
// fait échouer l'épreuve au lieu de la satisfaire.
|
||||
assert.ok(existsSync(new URL('../src/interface/App.svelte', import.meta.url)));
|
||||
await assert.rejects(
|
||||
import('../src/interface/App.svelte'),
|
||||
/Failed to parse source for import analysis|Unknown file extension/,
|
||||
);
|
||||
});
|
||||
});
|
||||
193
test/rapporteur.js
Normal file
193
test/rapporteur.js
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Rapporteur des durées de Vitest, déclaré dans vitest.config.js à côté du
|
||||
// rapporteur par défaut (§ 14.14). À la fin de chaque exécution, en série
|
||||
// simple comme à chaque relance sous surveillance, il imprime la durée
|
||||
// totale, la durée au mur de chaque projet et les dix épreuves les plus
|
||||
// lentes. Un projet qui dépasse son plafond fait sortir le lanceur en code 1,
|
||||
// et l'erreur le nomme.
|
||||
//
|
||||
// La durée d'un projet est celle de l'exécution, au mur, moins le temps où
|
||||
// seuls d'autres projets ont un fichier en cours. Un fichier est en cours du
|
||||
// moment où un processus de Vitest le prend (onTestModuleQueued) à celui où
|
||||
// sa dernière épreuve finit (onTestModuleEnd) ; le démarrage du processus
|
||||
// qui le prend, avant, n'est en cours pour aucun projet, et compte donc à
|
||||
// chacun. Un projet qui tourne seul dure ainsi toute l'exécution, comme la
|
||||
// durée qu'affiche Vitest ; avec d'autres, le temps où il attend un
|
||||
// processus occupé ailleurs ne lui est pas compté, si bien que son plafond
|
||||
// ne dépend pas des projets lancés avec lui. Un fichier que le rapporteur
|
||||
// n'a pas vu prendre rend la durée de son projet inconnue, et un projet à
|
||||
// plafond refuse alors au lieu de passer sans mesure (§ 14.2). Une exécution
|
||||
// interrompue imprime son bilan sans rien refuser : ses durées sont
|
||||
// partielles. Le bilan finit sur le mode que la machine a fait retenir
|
||||
// (test/machine.js) : un développeur sait ainsi sur quelle série sa relance a
|
||||
// porté.
|
||||
import { coeursDisponibles, ligneMachine, reglagesSeries } from './machine.js';
|
||||
|
||||
// Plafond au mur de chaque projet, en millisecondes, par nom d'exécution du
|
||||
// projet : celui de vitest.config.js, que Vitest fait suivre du navigateur
|
||||
// entre parenthèses pour un projet navigateur. Le refus est généreux
|
||||
// (§ 14.14) : un plafond calé sur la mesure échouerait sur une machine
|
||||
// chargée, et un test capricieux se désactive.
|
||||
export const PLAFONDS = new Map([
|
||||
// La série node surveillée : trente secondes (§ 14.14). Seule, elle dure
|
||||
// 1,8 s au mur sur 16 cœurs et 3,7 s sur 2, où elle se joue en threads
|
||||
// (test/machine.js).
|
||||
['node', 30_000],
|
||||
// Posé sur la mesure de node-long : seule, 17 s au mur sur 16 cœurs et
|
||||
// 28 s sur un seul. Le plafond laisse un facteur 2,1 au cas le plus lent.
|
||||
['node-long', 60_000],
|
||||
]);
|
||||
|
||||
// Nombre d'épreuves que nomme le bilan, des plus lentes.
|
||||
const PLUS_LENTES = 10;
|
||||
|
||||
// Durée en secondes, deux décimales, virgule décimale.
|
||||
const secondes = (ms) => `${(ms / 1000).toFixed(2).replace('.', ',')} s`;
|
||||
|
||||
// Longueur, en millisecondes, de la réunion d'intervalles { debut, fin }
|
||||
// donnés dans n'importe quel ordre.
|
||||
export function dureeReunie(intervalles) {
|
||||
let total = 0;
|
||||
let finReunie = -Infinity;
|
||||
for (const { debut, fin } of [...intervalles].sort((a, b) => a.debut - b.debut)) {
|
||||
if (fin > finReunie) {
|
||||
total += fin - Math.max(debut, finReunie);
|
||||
finReunie = fin;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Les n épreuves les plus lentes parmi des { duree, projet, fichier, nom },
|
||||
// de la plus lente à la plus rapide ; à durée égale, par fichier puis par
|
||||
// nom, comparés par points de code.
|
||||
export function plusLentes(epreuves, n) {
|
||||
const avant = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
return [...epreuves]
|
||||
.sort((a, b) => b.duree - a.duree || avant(a.fichier, b.fichier) || avant(a.nom, b.nom))
|
||||
.slice(0, n);
|
||||
}
|
||||
|
||||
// Bilan d'une exécution : ses lignes et ses refus. duree est celle de
|
||||
// l'exécution ; projets associe à chaque nom de projet { duree, nonSuivis },
|
||||
// nonSuivis listant les fichiers dont la durée manque ; epreuves porte chaque
|
||||
// épreuve exécutée, { duree, projet, fichier, nom }. Les projets s'impriment
|
||||
// triés par nom. Un projet refuse quand sa durée dépasse son plafond, ou
|
||||
// quand il a un plafond et un fichier non suivi.
|
||||
export function bilan({ duree, projets, epreuves, plafonds = PLAFONDS }) {
|
||||
const lignes = [`Durée totale : ${secondes(duree)}`];
|
||||
const refus = [];
|
||||
for (const nom of [...projets.keys()].sort()) {
|
||||
const { duree: dureeProjet, nonSuivis } = projets.get(nom);
|
||||
const plafond = plafonds.get(nom);
|
||||
const mesure =
|
||||
nonSuivis.length === 0
|
||||
? `${secondes(dureeProjet)} au mur`
|
||||
: `durée inconnue, ${nonSuivis.length} fichier${nonSuivis.length > 1 ? 's' : ''} non suivi${nonSuivis.length > 1 ? 's' : ''}`;
|
||||
lignes.push(` ${nom} : ${mesure}${plafond === undefined ? '' : `, plafond ${secondes(plafond)}`}`);
|
||||
if (plafond === undefined) continue;
|
||||
if (nonSuivis.length > 0) {
|
||||
refus.push(
|
||||
`Le projet ${nom} a un plafond de ${secondes(plafond)}, et sa durée ne se mesure pas : ` +
|
||||
`le rapporteur n'a pas suivi ${nonSuivis.join(', ')} (§ 14.14).`,
|
||||
);
|
||||
} else if (dureeProjet > plafond) {
|
||||
refus.push(
|
||||
`Le projet ${nom} dure ${secondes(dureeProjet)} au mur, au-delà de son plafond de ${secondes(plafond)} (§ 14.14).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (epreuves.length === 0) {
|
||||
lignes.push('Aucune épreuve exécutée.');
|
||||
} else {
|
||||
const lentes = plusLentes(epreuves, PLUS_LENTES);
|
||||
lignes.push(`Épreuves les plus lentes (${lentes.length} sur ${epreuves.length}) :`);
|
||||
for (const { duree: dureeEpreuve, projet, fichier, nom } of lentes) {
|
||||
lignes.push(`${String(Math.round(dureeEpreuve)).padStart(8)} ms ${projet} ${fichier} > ${nom}`);
|
||||
}
|
||||
}
|
||||
return { lignes, refus };
|
||||
}
|
||||
|
||||
// Le rapporteur que Vitest construit, sans option ; une épreuve lui passe sa
|
||||
// propre horloge, en millisecondes, et ses propres plafonds.
|
||||
export default class Rapporteur {
|
||||
#horloge;
|
||||
#plafonds;
|
||||
#journal;
|
||||
#debut;
|
||||
// Intervalle { debut, fin } de chaque fichier pris pendant l'exécution
|
||||
// courante, par identifiant de fichier ; fin reste undefined jusqu'à la
|
||||
// fin du fichier.
|
||||
#intervalles = new Map();
|
||||
|
||||
constructor({ horloge = () => performance.now(), plafonds = PLAFONDS } = {}) {
|
||||
this.#horloge = horloge;
|
||||
this.#plafonds = plafonds;
|
||||
}
|
||||
|
||||
onInit(vitest) {
|
||||
this.#journal = vitest.logger;
|
||||
}
|
||||
|
||||
onTestRunStart() {
|
||||
this.#debut = this.#horloge();
|
||||
this.#intervalles = new Map();
|
||||
}
|
||||
|
||||
onTestModuleQueued(module) {
|
||||
this.#intervalles.set(module.id, { debut: this.#horloge(), fin: undefined });
|
||||
}
|
||||
|
||||
onTestModuleEnd(module) {
|
||||
const intervalle = this.#intervalles.get(module.id);
|
||||
if (intervalle !== undefined) intervalle.fin = this.#horloge();
|
||||
}
|
||||
|
||||
// Un fichier pris et jamais fini est en cours jusqu'à la fin de
|
||||
// l'exécution. Le temps où seuls d'autres projets ont un fichier en cours
|
||||
// vaut la réunion de tous les fichiers moins celle du projet : la durée
|
||||
// du projet est donc celle de l'exécution, moins la première, plus la
|
||||
// seconde.
|
||||
onTestRunEnd(modules, _erreurs, raison) {
|
||||
const fin = this.#horloge();
|
||||
const duree = fin - this.#debut;
|
||||
const parProjet = new Map();
|
||||
const tous = [];
|
||||
const epreuves = [];
|
||||
for (const module of modules) {
|
||||
const projet = module.project.name;
|
||||
if (!parProjet.has(projet)) parProjet.set(projet, { intervalles: [], nonSuivis: [] });
|
||||
const suivi = parProjet.get(projet);
|
||||
const intervalle = this.#intervalles.get(module.id);
|
||||
if (intervalle === undefined) {
|
||||
suivi.nonSuivis.push(module.relativeModuleId);
|
||||
} else {
|
||||
const enCours = { debut: intervalle.debut, fin: intervalle.fin ?? fin };
|
||||
suivi.intervalles.push(enCours);
|
||||
tous.push(enCours);
|
||||
}
|
||||
for (const epreuve of module.children.allTests()) {
|
||||
const diagnostic = epreuve.diagnostic();
|
||||
if (diagnostic !== undefined) {
|
||||
epreuves.push({ duree: diagnostic.duration, projet, fichier: module.relativeModuleId, nom: epreuve.fullName });
|
||||
}
|
||||
}
|
||||
}
|
||||
const horsDeTout = duree - dureeReunie(tous);
|
||||
const projets = new Map(
|
||||
[...parProjet].map(([projet, { intervalles, nonSuivis }]) => [
|
||||
projet,
|
||||
{ duree: horsDeTout + dureeReunie(intervalles), nonSuivis },
|
||||
]),
|
||||
);
|
||||
const { lignes, refus } = bilan({ duree, projets, epreuves, plafonds: this.#plafonds });
|
||||
for (const ligne of lignes) this.#journal.log(ligne);
|
||||
this.#journal.log(ligneMachine(reglagesSeries(coeursDisponibles())));
|
||||
if (raison === 'interrupted') return;
|
||||
for (const message of refus) this.#journal.error(message);
|
||||
if (refus.length > 0) process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
252
test/rapporteur.test.js
Normal file
252
test/rapporteur.test.js
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le rapporteur des durées (test/rapporteur.js, § 14.14) : sa mesure au mur,
|
||||
// son bilan, son refus, et sa déclaration dans vitest.config.js. Le
|
||||
// rapporteur s'éprouve ici hors de Vitest : une fausse horloge date chaque
|
||||
// événement, et de faux fichiers portent les épreuves et leurs durées.
|
||||
import assert from 'node:assert/strict';
|
||||
import configuration from '../vitest.config.js';
|
||||
import Rapporteur, { PLAFONDS, bilan, dureeReunie, plusLentes } from './rapporteur.js';
|
||||
import { describe, test } from './lanceur.js';
|
||||
import { coeursDisponibles, ligneMachine, reglagesSeries } from './machine.js';
|
||||
|
||||
// Un faux fichier d'épreuves, tel que Vitest le passe au rapporteur : son
|
||||
// identifiant, son projet, son chemin, et ses épreuves, chacune
|
||||
// { nom, duree } ; une durée absente fait une épreuve sautée, sans
|
||||
// diagnostic.
|
||||
const fichier = (projet, chemin, epreuves = []) => ({
|
||||
id: `${projet}:${chemin}`,
|
||||
project: { name: projet },
|
||||
relativeModuleId: chemin,
|
||||
children: {
|
||||
*allTests() {
|
||||
for (const { nom, duree } of epreuves) {
|
||||
yield { fullName: nom, diagnostic: () => (duree === undefined ? undefined : { duration: duree }) };
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Une épreuve telle que la lisent plusLentes et bilan.
|
||||
const epreuve = (duree, fichierDEpreuve, nom, projet = 'node') => ({ duree, projet, fichier: fichierDEpreuve, nom });
|
||||
|
||||
// Projets mesurés tels que les lit bilan, depuis [nom, durée] : chacun suivi
|
||||
// en entier.
|
||||
const mesures = (paires) => new Map(paires.map(([nom, duree]) => [nom, { duree, nonSuivis: [] }]));
|
||||
|
||||
// Un rapporteur branché sur une horloge réglée à la main, sur des plafonds
|
||||
// d'épreuve et sur un journal qui garde ses lignes. Rend le rapporteur, de
|
||||
// quoi régler l'horloge, et les deux listes du journal.
|
||||
function brancher() {
|
||||
let maintenant = 0;
|
||||
const journal = { lignes: [], erreurs: [] };
|
||||
const plafonds = new Map([['node', 30_000], ['node-long', 60_000]]);
|
||||
const rapporteur = new Rapporteur({ horloge: () => maintenant, plafonds });
|
||||
rapporteur.onInit({
|
||||
logger: { log: (texte) => journal.lignes.push(texte), error: (texte) => journal.erreurs.push(texte) },
|
||||
});
|
||||
return { rapporteur, journal, regler: (t) => (maintenant = t) };
|
||||
}
|
||||
|
||||
// Exécute fin(), puis rend le code de sortie qu'elle a posé et remet celui
|
||||
// d'avant : l'épreuve ne laisse pas son code au processus qui l'exécute.
|
||||
function codeDeSortieDe(fin) {
|
||||
const avant = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
try {
|
||||
fin();
|
||||
return process.exitCode;
|
||||
} finally {
|
||||
process.exitCode = avant;
|
||||
}
|
||||
}
|
||||
|
||||
describe('rapporteur : mesure', () => {
|
||||
test("dureeReunie mesure la réunion des intervalles, quel que soit leur ordre : chevauchés, emboîtés, bout à bout ou disjoints", () => {
|
||||
assert.equal(dureeReunie([]), 0);
|
||||
assert.equal(dureeReunie([{ debut: 5, fin: 9 }]), 4);
|
||||
assert.equal(dureeReunie([{ debut: 20, fin: 25 }, { debut: 0, fin: 10 }]), 15);
|
||||
assert.equal(dureeReunie([{ debut: 5, fin: 20 }, { debut: 0, fin: 10 }]), 20);
|
||||
assert.equal(dureeReunie([{ debut: 0, fin: 30 }, { debut: 5, fin: 10 }, { debut: 12, fin: 31 }]), 31);
|
||||
assert.equal(dureeReunie([{ debut: 10, fin: 20 }, { debut: 0, fin: 10 }]), 20);
|
||||
});
|
||||
|
||||
test('plusLentes rend les n plus longues, de la plus lente à la plus rapide, à durée égale par fichier puis par nom', () => {
|
||||
const epreuves = [
|
||||
epreuve(3, 'b.test.js', 'x'),
|
||||
epreuve(9, 'a.test.js', 'y'),
|
||||
epreuve(3, 'a.test.js', 'z'),
|
||||
epreuve(3, 'a.test.js', 'w'),
|
||||
epreuve(1, 'a.test.js', 'v'),
|
||||
];
|
||||
assert.deepEqual(plusLentes(epreuves, 4), [
|
||||
epreuve(9, 'a.test.js', 'y'),
|
||||
epreuve(3, 'a.test.js', 'w'),
|
||||
epreuve(3, 'a.test.js', 'z'),
|
||||
epreuve(3, 'b.test.js', 'x'),
|
||||
]);
|
||||
assert.deepEqual(plusLentes(epreuves.slice(0, 2), 10), [epreuve(9, 'a.test.js', 'y'), epreuve(3, 'b.test.js', 'x')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rapporteur : bilan et refus', () => {
|
||||
test("un projet refuse au-delà de son plafond, pas à son plafond ; un projet sans plafond ne refuse jamais", () => {
|
||||
const plafonds = new Map([['node', 30_000], ['node-long', 60_000]]);
|
||||
const { refus } = bilan({
|
||||
duree: 70_000,
|
||||
projets: mesures([['node', 30_001], ['node-long', 60_000], ['navigateur', 90_000]]),
|
||||
epreuves: [],
|
||||
plafonds,
|
||||
});
|
||||
assert.deepEqual(refus, ['Le projet node dure 30,00 s au mur, au-delà de son plafond de 30,00 s (§ 14.14).']);
|
||||
assert.deepEqual(bilan({ duree: 1, projets: mesures([['node', 30_000]]), epreuves: [], plafonds }).refus, []);
|
||||
});
|
||||
|
||||
test("une exécution sans épreuve le dit au lieu d'une liste vide", () => {
|
||||
assert.deepEqual(bilan({ duree: 4, projets: new Map(), epreuves: [], plafonds: new Map() }).lignes, [
|
||||
'Durée totale : 0,00 s',
|
||||
'Aucune épreuve exécutée.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('le bilan donne la durée totale, chaque projet trié par nom avec son plafond, et les dix épreuves les plus lentes', () => {
|
||||
const epreuves = Array.from({ length: 12 }, (_, i) => epreuve(10 * (i + 1), 'a.test.js', `e${i + 1}`));
|
||||
const { lignes } = bilan({
|
||||
duree: 2_345,
|
||||
projets: mesures([['node-long', 2_000], ['navigateur', 1_234]]),
|
||||
epreuves,
|
||||
plafonds: new Map([['node-long', 60_000]]),
|
||||
});
|
||||
assert.deepEqual(lignes, [
|
||||
'Durée totale : 2,35 s',
|
||||
' navigateur : 1,23 s au mur',
|
||||
' node-long : 2,00 s au mur, plafond 60,00 s',
|
||||
'Épreuves les plus lentes (10 sur 12) :',
|
||||
' 120 ms node a.test.js > e12',
|
||||
' 110 ms node a.test.js > e11',
|
||||
' 100 ms node a.test.js > e10',
|
||||
' 90 ms node a.test.js > e9',
|
||||
' 80 ms node a.test.js > e8',
|
||||
' 70 ms node a.test.js > e7',
|
||||
' 60 ms node a.test.js > e6',
|
||||
' 50 ms node a.test.js > e5',
|
||||
' 40 ms node a.test.js > e4',
|
||||
' 30 ms node a.test.js > e3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rapporteur : branché sur les événements de Vitest', () => {
|
||||
test("chaque exécution compte à chaque projet sa durée moins le temps où seuls d'autres projets tournent, imprime son bilan, et sort en code 1 quand un projet dépasse son plafond", () => {
|
||||
const { rapporteur, journal, regler } = brancher();
|
||||
const a = fichier('node', 'src/a.test.js', [{ nom: 'a > un', duree: 7 }, { nom: 'a > sautée' }]);
|
||||
const b = fichier('node', 'src/b.test.js', [{ nom: 'b > deux', duree: 29_000 }]);
|
||||
const c = fichier('node-long', 'src/c.long.test.js', [{ nom: 'c > trois', duree: 19_000 }]);
|
||||
regler(0);
|
||||
rapporteur.onTestRunStart([]);
|
||||
regler(100);
|
||||
rapporteur.onTestModuleQueued(a);
|
||||
rapporteur.onTestModuleQueued(c);
|
||||
regler(400);
|
||||
rapporteur.onTestModuleEnd(a);
|
||||
regler(20_100);
|
||||
rapporteur.onTestModuleEnd(c);
|
||||
rapporteur.onTestModuleQueued(b);
|
||||
regler(50_000);
|
||||
rapporteur.onTestModuleEnd(b);
|
||||
regler(50_500);
|
||||
const code = codeDeSortieDe(() => rapporteur.onTestRunEnd([a, b, c], [], 'passed'));
|
||||
assert.equal(code, 1);
|
||||
// Aucun fichier ne tourne pendant 0,6 s, compté à chaque projet. node y
|
||||
// ajoute ses fichiers, [100, 400] et [20 100, 50 000], soit 30,8 s : les
|
||||
// 19,7 s où c tourne seul ne lui sont pas comptées. node-long y ajoute
|
||||
// [100, 20 100], soit 20,6 s.
|
||||
assert.deepEqual(journal.lignes.slice(0, 3), [
|
||||
'Durée totale : 50,50 s',
|
||||
' node : 30,80 s au mur, plafond 30,00 s',
|
||||
' node-long : 20,60 s au mur, plafond 60,00 s',
|
||||
]);
|
||||
assert.deepEqual(journal.lignes.slice(3), [
|
||||
'Épreuves les plus lentes (3 sur 3) :',
|
||||
' 29000 ms node src/b.test.js > b > deux',
|
||||
' 19000 ms node-long src/c.long.test.js > c > trois',
|
||||
' 7 ms node src/a.test.js > a > un',
|
||||
// Le bilan finit sur le mode que cette machine fait retenir.
|
||||
ligneMachine(reglagesSeries(coeursDisponibles())),
|
||||
]);
|
||||
assert.deepEqual(journal.erreurs, ['Le projet node dure 30,80 s au mur, au-delà de son plafond de 30,00 s (§ 14.14).']);
|
||||
|
||||
// Une seconde exécution, comme une relance sous surveillance, repart de
|
||||
// zéro. Seul, le projet dure toute l'exécution, temps d'avant la prise de
|
||||
// son fichier compris ; sous son plafond, il ne pose aucun code de
|
||||
// sortie.
|
||||
journal.lignes.length = 0;
|
||||
journal.erreurs.length = 0;
|
||||
regler(60_000);
|
||||
rapporteur.onTestRunStart([]);
|
||||
regler(60_200);
|
||||
rapporteur.onTestModuleQueued(a);
|
||||
regler(60_300);
|
||||
rapporteur.onTestModuleEnd(a);
|
||||
regler(60_400);
|
||||
assert.equal(codeDeSortieDe(() => rapporteur.onTestRunEnd([a], [], 'passed')), undefined);
|
||||
assert.deepEqual(journal.lignes.slice(0, 2), ['Durée totale : 0,40 s', ' node : 0,40 s au mur, plafond 30,00 s']);
|
||||
assert.deepEqual(journal.erreurs, []);
|
||||
});
|
||||
|
||||
test("un fichier dont le rapporteur n'a vu ni la prise ni la fin rend la durée de son projet inconnue, et refuse quand le projet a un plafond", () => {
|
||||
const { rapporteur, journal, regler } = brancher();
|
||||
const a = fichier('node', 'src/a.test.js', [{ nom: 'a > un', duree: 1 }]);
|
||||
const b = fichier('node', 'src/b.test.js', [{ nom: 'b > deux', duree: 1 }]);
|
||||
const n = fichier('navigateur', 'src/n.navigateur.test.js', [{ nom: 'n > trois', duree: 1 }]);
|
||||
regler(0);
|
||||
rapporteur.onTestRunStart([]);
|
||||
rapporteur.onTestModuleQueued(a);
|
||||
regler(10);
|
||||
rapporteur.onTestModuleEnd(a);
|
||||
const code = codeDeSortieDe(() => rapporteur.onTestRunEnd([a, b, n], [], 'passed'));
|
||||
assert.equal(code, 1);
|
||||
assert.deepEqual(journal.lignes.slice(0, 3), [
|
||||
'Durée totale : 0,01 s',
|
||||
' navigateur : durée inconnue, 1 fichier non suivi',
|
||||
' node : durée inconnue, 1 fichier non suivi, plafond 30,00 s',
|
||||
]);
|
||||
assert.deepEqual(journal.erreurs, [
|
||||
"Le projet node a un plafond de 30,00 s, et sa durée ne se mesure pas : le rapporteur n'a pas suivi src/b.test.js (§ 14.14).",
|
||||
]);
|
||||
});
|
||||
|
||||
test("une exécution interrompue imprime son bilan sans rien refuser", () => {
|
||||
const { rapporteur, journal, regler } = brancher();
|
||||
const a = fichier('node', 'src/a.test.js', [{ nom: 'a > un', duree: 1 }]);
|
||||
regler(0);
|
||||
rapporteur.onTestRunStart([]);
|
||||
rapporteur.onTestModuleQueued(a);
|
||||
regler(40_000);
|
||||
assert.equal(codeDeSortieDe(() => rapporteur.onTestRunEnd([a], [], 'interrupted')), undefined);
|
||||
assert.deepEqual(journal.lignes.slice(0, 2), ['Durée totale : 40,00 s', ' node : 40,00 s au mur, plafond 30,00 s']);
|
||||
assert.deepEqual(journal.erreurs, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rapporteur : déclaration', () => {
|
||||
test('vitest.config.js déclare le rapporteur à côté du rapporteur par défaut', () => {
|
||||
assert.deepEqual(configuration.test.reporters, ['default', './test/rapporteur.js']);
|
||||
});
|
||||
|
||||
test("chaque plafond nomme un projet de vitest.config.js sous son nom d'exécution, et node comme node-long ont le leur (§ 14.14)", () => {
|
||||
// Vitest nomme chaque instance d'un projet navigateur d'après le projet
|
||||
// et le navigateur, « navigateur (chromium) » : seul ce nom s'apparie à
|
||||
// un plafond.
|
||||
const projets = configuration.test.projects.flatMap(({ test: { name, browser } }) =>
|
||||
browser?.enabled ? browser.instances.map((instance) => `${name} (${instance.browser})`) : [name],
|
||||
);
|
||||
assert.ok(projets.includes('navigateur (chromium)'), `noms d'exécution : ${projets.join(', ')}`);
|
||||
assert.deepEqual([...PLAFONDS.keys()].filter((projet) => !projets.includes(projet)), []);
|
||||
assert.ok(PLAFONDS.has('node'), 'aucun plafond pour node');
|
||||
assert.ok(PLAFONDS.has('node-long'), 'aucun plafond pour node-long');
|
||||
assert.equal(PLAFONDS.get('node'), 30_000);
|
||||
assert.equal(PLAFONDS.get('node-long'), 60_000);
|
||||
});
|
||||
});
|
||||
10
test/version_voisine.js
Normal file
10
test/version_voisine.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Version voisine d'une version affichée AAAA.MM.JJ.NN valide, pour les
|
||||
// épreuves qui ont besoin d'une seconde version : même date, rang 01 changé
|
||||
// en 02 et tout autre rang en 01. La date reste valide quelle qu'elle soit,
|
||||
// un 29 février compris, et la voisine diffère toujours de la version reçue.
|
||||
export function versionVoisine(version) {
|
||||
return version.replace(/\d{2}$/, (rang) => (rang === '01' ? '02' : '01'));
|
||||
}
|
||||
1
version.json
Normal file
1
version.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "version": "2026.10.05.01" }
|
||||
18
vite.config.js
Normal file
18
vite.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Construit la page que servent la plateforme web de Capacitor et la coquille
|
||||
// Electron. La base relative fait commencer chaque chemin d'actif par « ./ » :
|
||||
// la coquille charge la page par file://, où un chemin absolu « /assets/… »
|
||||
// désigne la racine du disque.
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
outDir: 'www',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
76
vitest.config.js
Normal file
76
vitest.config.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Un seul lanceur, trois projets (§ 14.4, § 14.8).
|
||||
//
|
||||
// node fonctions pures, sans DOM et sans greffon Svelte : un module qui
|
||||
// importerait un composant échoue au chargement.
|
||||
// node-long épreuves lourdes, hors de la série surveillée. Leur durée croît
|
||||
// avec l'application : chacune dispose de deux minutes, là où le
|
||||
// délai par défaut de Vitest, 5 s, la ferait échouer par
|
||||
// dépassement et non sur un défaut. Ce délai arrête un test
|
||||
// bloqué ; le plafond de la série est celui du rapporteur.
|
||||
// navigateur un vrai moteur de rendu. Le projet étend vite.config.js : un
|
||||
// module s'y résout par les mêmes greffons et réglages que dans la
|
||||
// construction du livrable, sans seconde configuration à tenir
|
||||
// d'accord avec la première.
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { coeursDisponibles, reglagesSeries } from './test/machine.js';
|
||||
|
||||
// Les séries node suivent le nombre de cœurs de la machine (test/machine.js) :
|
||||
// en deçà de quatre, des threads sur tous les cœurs, et les épreuves lourdes
|
||||
// de la série surveillée passent dans la série longue (§ 14.14).
|
||||
const SERIES = reglagesSeries(coeursDisponibles());
|
||||
const PROCESSUS = { pool: SERIES.pool, maxWorkers: SERIES.maxWorkers };
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// test/rapporteur.js imprime, après le bilan par défaut, la durée de
|
||||
// chaque projet et les dix épreuves les plus lentes, et fait échouer
|
||||
// l'exécution quand node ou node-long dépasse son plafond (§ 14.14). Un
|
||||
// chemin relatif se lit depuis la racine du projet.
|
||||
reporters: ['default', './test/rapporteur.js'],
|
||||
// Sous surveillance, une épreuve se relance quand un module de son graphe
|
||||
// d'imports change. Les fichiers des motifs se lisent hors de tout
|
||||
// graphe : le contrôle de version (§ 18.5) lit les premiers par le
|
||||
// système de fichiers ; la coquille s'exécute dans un processus à part,
|
||||
// la configuration d'electron-builder se charge par un require natif et
|
||||
// lit capacitor.config.json, et le script de construction se lance
|
||||
// depuis une copie. Chaque motif rattache ses fichiers aux épreuves qui
|
||||
// les lisent, et au contrôle de version quand son point 6 les balaie. Un
|
||||
// fichier qui répond à un motif ne relance que les épreuves nommées, et
|
||||
// plus celles qui l'importeraient : un motif ne nomme donc aucun fichier
|
||||
// qu'une épreuve importe. L'option se lit au niveau du lanceur : posée
|
||||
// dans un projet, elle est ignorée sans avertissement.
|
||||
watchTriggerPatterns: [
|
||||
{ pattern: /(^|\/)(version\.json|package-lock\.json|CHANGELOG\.md|README\.md|GUIDE-[^/]*\.md)$/,
|
||||
testsToRun: () => 'scripts/version.test.js' },
|
||||
{ pattern: /(^|\/)electron\/[^/]+$/,
|
||||
testsToRun: () => ['test/coquille.test.js', 'test/livrable.test.js', 'scripts/version.test.js'] },
|
||||
{ pattern: /(^|\/)test\/electron_factice\.js$/,
|
||||
testsToRun: () => ['test/coquille.test.js', 'scripts/version.test.js'] },
|
||||
{ pattern: /(^|\/)(electron-builder\.config\.cjs|capacitor\.config\.json)$/,
|
||||
testsToRun: () => ['test/livrable.test.js', 'scripts/construire_windows.test.js', 'scripts/version.test.js'] },
|
||||
{ pattern: /(^|\/)scripts\/construire_windows\.sh$/,
|
||||
testsToRun: () => ['scripts/construire_windows.test.js', 'scripts/version.test.js'] },
|
||||
],
|
||||
projects: [
|
||||
{ test: {
|
||||
name: 'node', environment: 'node', ...PROCESSUS,
|
||||
include: ['src/**/*.test.js', 'scripts/**/*.test.js', 'test/**/*.test.js'],
|
||||
exclude: ['**/*.long.test.js', '**/*.navigateur.test.js', 'node_modules/**',
|
||||
...SERIES.horsSerieSurveillee] } },
|
||||
{ test: {
|
||||
name: 'node-long', environment: 'node', ...PROCESSUS,
|
||||
include: ['src/**/*.long.test.js', 'test/**/*.long.test.js', ...SERIES.horsSerieSurveillee],
|
||||
testTimeout: 120_000 } },
|
||||
{ extends: './vite.config.js',
|
||||
test: {
|
||||
name: 'navigateur',
|
||||
include: ['src/**/*.navigateur.test.js'],
|
||||
browser: { enabled: true, headless: true, provider: playwright(),
|
||||
instances: [{ browser: 'chromium' }] } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue