332 lines
13 KiB
JavaScript
332 lines
13 KiB
JavaScript
|
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|||
|
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|||
|
|
|
|||
|
|
// Épreuves de la géométrie des tables (§ 7.1, § 7.7) : diamètre et côté tirés
|
|||
|
|
// du nombre de places, chaises posées dans le sens horaire depuis le haut,
|
|||
|
|
// emprise, poignée hors de toute chaise, bloc de noms, englobant, case libre
|
|||
|
|
// d'une table créée et nombre de places sous la poignée. Les valeurs
|
|||
|
|
// attendues se calculent à la main dans le commentaire qui les précède.
|
|||
|
|
import assert from 'node:assert/strict';
|
|||
|
|
import { describe, test } from '../../test/lanceur.js';
|
|||
|
|
import { surLeSol } from './designation.js';
|
|||
|
|
import {
|
|||
|
|
ECART_LISTE,
|
|||
|
|
ECART_POIGNEE,
|
|||
|
|
LARGEUR_PAR_PLACE,
|
|||
|
|
PAS_GRILLE,
|
|||
|
|
PLANCHER_DIAMETRE,
|
|||
|
|
RAYON_CHAISE,
|
|||
|
|
RAYON_POIGNEE,
|
|||
|
|
caseLibre,
|
|||
|
|
cote,
|
|||
|
|
diametre,
|
|||
|
|
englobant,
|
|||
|
|
geometrieTable,
|
|||
|
|
placesSousPoignee,
|
|||
|
|
} from './tables.js';
|
|||
|
|
|
|||
|
|
const EPSILON = 1e-9;
|
|||
|
|
|
|||
|
|
// Affirme |reel − attendu| ≤ EPSILON, le libellé nommant la grandeur.
|
|||
|
|
function proche(reel, attendu, libelle) {
|
|||
|
|
assert.ok(Math.abs(reel - attendu) <= EPSILON, `${libelle} : ${reel} au lieu de ${attendu}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function procheDuPoint(reel, attendu, libelle) {
|
|||
|
|
proche(reel.x, attendu.x, `${libelle}, x`);
|
|||
|
|
proche(reel.y, attendu.y, `${libelle}, y`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|||
|
|
|
|||
|
|
// Une table du fichier d'état : identifiant, numéro, forme et centre.
|
|||
|
|
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
|
|||
|
|
|
|||
|
|
describe('constantes', () => {
|
|||
|
|
test('les valeurs du contrat', () => {
|
|||
|
|
assert.equal(PLANCHER_DIAMETRE, 70);
|
|||
|
|
assert.equal(LARGEUR_PAR_PLACE, 60);
|
|||
|
|
assert.equal(RAYON_CHAISE, 22);
|
|||
|
|
assert.equal(RAYON_POIGNEE, 15);
|
|||
|
|
assert.equal(ECART_POIGNEE, 8);
|
|||
|
|
assert.equal(ECART_LISTE, 10);
|
|||
|
|
assert.equal(PAS_GRILLE, 300);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('diametre (§ 7.7)', () => {
|
|||
|
|
test('le plancher de 70 cm borne 2 et 3 places, le périmètre borne dès 4', () => {
|
|||
|
|
assert.equal(diametre(2), 70);
|
|||
|
|
assert.equal(diametre(3), 70);
|
|||
|
|
// 4 × 60 / π = 76,394 > 70 : un plancher appliqué jusqu'à 6 places rendrait 70.
|
|||
|
|
proche(diametre(4), 240 / Math.PI, 'diamètre de 4');
|
|||
|
|
proche(diametre(5), 300 / Math.PI, 'diamètre de 5');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('115, 153 et 191 cm pour 6, 8 et 10 places, arrondis (§ 7.7)', () => {
|
|||
|
|
assert.equal(Math.round(diametre(6)), 115);
|
|||
|
|
assert.equal(Math.round(diametre(8)), 153);
|
|||
|
|
assert.equal(Math.round(diametre(10)), 191);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('un diamètre saisi l’emporte', () => {
|
|||
|
|
assert.equal(diametre(8, { saisi: 180 }), 180);
|
|||
|
|
assert.equal(diametre(2, { saisi: 50 }), 50);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('cote', () => {
|
|||
|
|
test('max(70, ⌈places / 4⌉ × 60)', () => {
|
|||
|
|
assert.equal(cote(3), 70);
|
|||
|
|
assert.equal(cote(5), 120);
|
|||
|
|
assert.equal(cote(8), 120);
|
|||
|
|
assert.equal(cote(9), 180);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('un côté saisi l’emporte', () => {
|
|||
|
|
assert.equal(cote(8, { saisi: 150 }), 150);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('geometrieTable, ronde', () => {
|
|||
|
|
// Ronde de 8 au centre (300, 600) : diamètre 480/π, demi-taille 240/π
|
|||
|
|
// (≈ 76,394), chaises sur le cercle de rayon 240/π + 22 (≈ 98,394).
|
|||
|
|
const g = geometrieTable(table(4, 'ronde', 300, 600), 8);
|
|||
|
|
const r = 240 / Math.PI;
|
|||
|
|
|
|||
|
|
test('identité, forme, centre, demi-taille et pas', () => {
|
|||
|
|
assert.equal(g.id, 4);
|
|||
|
|
assert.equal(g.numero, 4);
|
|||
|
|
assert.equal(g.forme, 'ronde');
|
|||
|
|
assert.deepEqual(g.centre, { x: 300, y: 600 });
|
|||
|
|
proche(g.demiTaille, r, 'demi-taille');
|
|||
|
|
proche(g.pas, 60, 'pas');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('huit chaises de rayon 22, siège 1 en haut, sens horaire', () => {
|
|||
|
|
assert.equal(g.chaises.length, 8);
|
|||
|
|
g.chaises.forEach((chaise, i) => {
|
|||
|
|
assert.equal(chaise.siege, i + 1);
|
|||
|
|
assert.equal(chaise.rayon, RAYON_CHAISE);
|
|||
|
|
proche(distance(chaise, g.centre), r + 22, `distance du siège ${chaise.siege}`);
|
|||
|
|
});
|
|||
|
|
procheDuPoint(g.chaises[0], { x: 300, y: 600 - (r + 22) }, 'siège 1');
|
|||
|
|
procheDuPoint(g.chaises[2], { x: 300 + r + 22, y: 600 }, 'siège 3');
|
|||
|
|
procheDuPoint(g.chaises[4], { x: 300, y: 600 + r + 22 }, 'siège 5');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('emprise, poignée et origine de la liste', () => {
|
|||
|
|
proche(g.rayonEmprise, r + 44, 'rayon d’emprise');
|
|||
|
|
const ecart = (r + 44 + 23) / Math.SQRT2;
|
|||
|
|
procheDuPoint(g.poignee, { x: 300 + ecart, y: 600 + ecart }, 'poignée');
|
|||
|
|
procheDuPoint(g.origineListe, { x: 300 + r + 54, y: 600 - r }, 'origine de la liste');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('ronde de 3 : plancher de 70 cm, pas 70π/3', () => {
|
|||
|
|
const petite = geometrieTable(table(1, 'ronde', 0, 0), 3);
|
|||
|
|
proche(petite.demiTaille, 35, 'demi-taille');
|
|||
|
|
proche(petite.pas, (70 * Math.PI) / 3, 'pas');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('le pas vaut 60 exactement au-dessus du plancher, de 4 à 40 places', () => {
|
|||
|
|
// Le produit π × (places × 60 / π) / places rend 59,999… pour 7 places :
|
|||
|
|
// le régime des cibles basculerait alors un cran trop tôt.
|
|||
|
|
let comptees = 0;
|
|||
|
|
for (let places = 4; places <= 40; places += 1) {
|
|||
|
|
assert.equal(geometrieTable(table(1, 'ronde', 0, 0), places).pas, 60, `${places} places`);
|
|||
|
|
comptees += 1;
|
|||
|
|
}
|
|||
|
|
assert.equal(comptees, 37);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('geometrieTable, carrée', () => {
|
|||
|
|
// Carrée de 8 au centre (300, 600) : côté 120, demi-côté 60, deux sièges
|
|||
|
|
// par côté à 30 et 90 cm du coin, centres à 60 + 22 = 82 cm de l'axe.
|
|||
|
|
const g = geometrieTable(table(2, 'carree', 300, 600), 8);
|
|||
|
|
|
|||
|
|
test('côté 120, pas 60, deux sièges par côté dans le sens horaire', () => {
|
|||
|
|
assert.equal(g.forme, 'carree');
|
|||
|
|
assert.equal(g.demiTaille, 60);
|
|||
|
|
assert.equal(g.pas, 60);
|
|||
|
|
assert.equal(g.chaises.length, 8);
|
|||
|
|
const attendus = [
|
|||
|
|
[270, 518], [330, 518], // haut, de gauche à droite
|
|||
|
|
[382, 570], [382, 630], // droit, de haut en bas
|
|||
|
|
[330, 682], [270, 682], // bas, de droite à gauche
|
|||
|
|
[218, 630], [218, 570], // gauche, de bas en haut
|
|||
|
|
];
|
|||
|
|
g.chaises.forEach((chaise, i) => {
|
|||
|
|
assert.equal(chaise.siege, i + 1);
|
|||
|
|
assert.equal(chaise.rayon, RAYON_CHAISE);
|
|||
|
|
procheDuPoint(chaise, { x: attendus[i][0], y: attendus[i][1] }, `siège ${i + 1}`);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('emprise 104, poignée au-delà du coin, liste en (414, 540)', () => {
|
|||
|
|
assert.equal(g.rayonEmprise, 104);
|
|||
|
|
procheDuPoint(g.poignee, { x: 404 + 23 / Math.SQRT2, y: 704 + 23 / Math.SQRT2 }, 'poignée');
|
|||
|
|
procheDuPoint(g.origineListe, { x: 414, y: 540 }, 'origine de la liste');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('carrée de 3 : côtés garnis 1, 1, 1, 0 et pas 70', () => {
|
|||
|
|
// Côté 70, demi-côté 35 : un siège au milieu du haut, du droit et du bas.
|
|||
|
|
const petite = geometrieTable(table(1, 'carree', 0, 0), 3);
|
|||
|
|
assert.equal(petite.pas, 70);
|
|||
|
|
assert.equal(petite.chaises.length, 3);
|
|||
|
|
procheDuPoint(petite.chaises[0], { x: 0, y: -57 }, 'siège 1');
|
|||
|
|
procheDuPoint(petite.chaises[1], { x: 57, y: 0 }, 'siège 2');
|
|||
|
|
procheDuPoint(petite.chaises[2], { x: 0, y: 57 }, 'siège 3');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('carrée de 5 : le côté haut reçoit la place en surplus', () => {
|
|||
|
|
// Côté 120 : 2 sièges en haut, puis 1 à droite, en bas et à gauche.
|
|||
|
|
const g5 = geometrieTable(table(1, 'carree', 0, 0), 5);
|
|||
|
|
assert.equal(g5.pas, 60);
|
|||
|
|
const attendus = [[-30, -82], [30, -82], [82, 0], [0, 82], [-82, 0]];
|
|||
|
|
assert.equal(g5.chaises.length, attendus.length);
|
|||
|
|
g5.chaises.forEach((chaise, i) => procheDuPoint(chaise, { x: attendus[i][0], y: attendus[i][1] }, `siège ${i + 1}`));
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('emprise et chaises, de 2 à 40 places', () => {
|
|||
|
|
test('l’emprise contient chaque chaise, et deux chaises ne se touchent pas', () => {
|
|||
|
|
let tables = 0;
|
|||
|
|
for (const forme of ['ronde', 'carree']) {
|
|||
|
|
for (let places = 2; places <= 40; places += 1) {
|
|||
|
|
const g = geometrieTable(table(1, forme, 10, -20), places);
|
|||
|
|
assert.equal(g.chaises.length, places);
|
|||
|
|
proche(g.rayonEmprise, g.demiTaille + 2 * RAYON_CHAISE, `${forme} de ${places}, emprise`);
|
|||
|
|
for (const chaise of g.chaises) {
|
|||
|
|
const dx = Math.abs(chaise.x - g.centre.x);
|
|||
|
|
const dy = Math.abs(chaise.y - g.centre.y);
|
|||
|
|
const portee = forme === 'ronde' ? Math.hypot(dx, dy) : Math.max(dx, dy);
|
|||
|
|
assert.ok(portee + chaise.rayon <= g.rayonEmprise + EPSILON, `${forme} de ${places}, siège ${chaise.siege}`);
|
|||
|
|
}
|
|||
|
|
for (let i = 0; i < places; i += 1) {
|
|||
|
|
for (let j = i + 1; j < places; j += 1) {
|
|||
|
|
assert.ok(distance(g.chaises[i], g.chaises[j]) >= 2 * RAYON_CHAISE, `${forme} de ${places}, sièges ${i + 1} et ${j + 1}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
tables += 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
assert.equal(tables, 78);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('la poignée ne touche aucune chaise (§ 7.1)', () => {
|
|||
|
|
test('rondes de 3, 8 et 16, carrées de 3, 8, 12 et 16', () => {
|
|||
|
|
const cas = [
|
|||
|
|
['ronde', 3], ['ronde', 8], ['ronde', 16],
|
|||
|
|
['carree', 3], ['carree', 8], ['carree', 12], ['carree', 16],
|
|||
|
|
];
|
|||
|
|
let eprouvees = 0;
|
|||
|
|
for (const [forme, places] of cas) {
|
|||
|
|
const g = geometrieTable(table(1, forme, 0, 0), places);
|
|||
|
|
assert.ok(g.chaises.length > 0);
|
|||
|
|
for (const chaise of g.chaises) {
|
|||
|
|
assert.ok(
|
|||
|
|
distance(g.poignee, chaise) > RAYON_CHAISE + RAYON_POIGNEE,
|
|||
|
|
`${forme} de ${places}, siège ${chaise.siege} à ${distance(g.poignee, chaise)} cm`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
assert.equal(surLeSol(g.poignee, [g]), true, `${forme} de ${places} : centre de la poignée hors du sol`);
|
|||
|
|
eprouvees += 1;
|
|||
|
|
}
|
|||
|
|
assert.equal(eprouvees, 7);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('aucune rotation (§ 7.1)', () => {
|
|||
|
|
test('la géométrie ne porte aucun angle et une seule poignée', () => {
|
|||
|
|
for (const forme of ['ronde', 'carree']) {
|
|||
|
|
const g = geometrieTable(table(1, forme, 0, 0), 8);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
Object.keys(g).sort(),
|
|||
|
|
['centre', 'chaises', 'demiTaille', 'forme', 'id', 'numero', 'origineListe', 'pas', 'poignee', 'rayonEmprise'],
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(Object.keys(g.poignee).sort(), ['x', 'y']);
|
|||
|
|
for (const chaise of g.chaises) assert.deepEqual(Object.keys(chaise).sort(), ['rayon', 'siege', 'x', 'y']);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('geometrieTable refuse une table qu’elle ne sait pas dessiner', () => {
|
|||
|
|
test('forme inconnue, places nulles ou non entières', () => {
|
|||
|
|
assert.throws(() => geometrieTable(table(1, 'ovale', 0, 0), 8), RangeError);
|
|||
|
|
assert.throws(() => geometrieTable(table(1, 'ronde', 0, 0), 0), RangeError);
|
|||
|
|
assert.throws(() => geometrieTable(table(1, 'carree', 0, 0), 2.5), RangeError);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('diametre et cote : places invalides, dimension saisie nulle ou non finie', () => {
|
|||
|
|
assert.throws(() => diametre(0), RangeError);
|
|||
|
|
assert.throws(() => cote(1.5), RangeError);
|
|||
|
|
assert.throws(() => diametre(8, { saisi: 0 }), RangeError);
|
|||
|
|
assert.throws(() => cote(8, { saisi: Number.NaN }), RangeError);
|
|||
|
|
assert.throws(() => diametre(8, { saisi: Infinity }), RangeError);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('englobant', () => {
|
|||
|
|
test('null sans table', () => {
|
|||
|
|
assert.equal(englobant([]), null);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('deux tables : min et max exacts de leurs emprises', () => {
|
|||
|
|
// Ronde de 8 en (300, 600), emprise 240/π + 44 ; carrée de 8 en
|
|||
|
|
// (−100, 50), emprise 104.
|
|||
|
|
const r = 240 / Math.PI + 44;
|
|||
|
|
const e = englobant([
|
|||
|
|
geometrieTable(table(1, 'ronde', 300, 600), 8),
|
|||
|
|
geometrieTable(table(2, 'carree', -100, 50), 8),
|
|||
|
|
]);
|
|||
|
|
assert.deepEqual(Object.keys(e).sort(), ['maxX', 'maxY', 'minX', 'minY']);
|
|||
|
|
proche(e.minX, -204, 'minX');
|
|||
|
|
proche(e.minY, -54, 'minY');
|
|||
|
|
proche(e.maxX, 300 + r, 'maxX');
|
|||
|
|
proche(e.maxY, 600 + r, 'maxY');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('caseLibre', () => {
|
|||
|
|
test('sans table, l’origine', () => {
|
|||
|
|
assert.deepEqual(caseLibre([]), { x: 0, y: 0 });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('deux centres en (0, 0) et (300, 0) : deux colonnes, la case suivante est (0, 300)', () => {
|
|||
|
|
assert.deepEqual(caseLibre([{ x: 0, y: 0 }, { x: 300, y: 0 }]), { x: 0, y: 300 });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('un centre en (150, 0) prend les deux premières cases', () => {
|
|||
|
|
assert.deepEqual(caseLibre([{ x: 150, y: 0 }]), { x: 0, y: 300 });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('un centre à plus de PAS_GRILLE / 2 laisse la case libre', () => {
|
|||
|
|
assert.deepEqual(caseLibre([{ x: 150.001, y: 0 }]), { x: 0, y: 0 });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('quatre centres sur 2 × 2 : trois colonnes, la case suivante est (600, 0)', () => {
|
|||
|
|
const centres = [{ x: 0, y: 0 }, { x: 300, y: 0 }, { x: 0, y: 300 }, { x: 300, y: 300 }];
|
|||
|
|
assert.deepEqual(caseLibre(centres.slice(0, 3)), { x: 300, y: 300 });
|
|||
|
|
assert.deepEqual(caseLibre(centres), { x: 600, y: 0 });
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('placesSousPoignee', () => {
|
|||
|
|
const PAS_RAYON = 60 / (2 * Math.PI);
|
|||
|
|
|
|||
|
|
test('écart nul, deux pas, dix pas en arrière, un pas', () => {
|
|||
|
|
assert.equal(placesSousPoignee(8, 0), 8);
|
|||
|
|
assert.equal(placesSousPoignee(8, 2 * PAS_RAYON), 10);
|
|||
|
|
assert.equal(placesSousPoignee(8, -10 * PAS_RAYON), 2);
|
|||
|
|
assert.equal(placesSousPoignee(3, PAS_RAYON), 4);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('l’écart s’arrondit au pas le plus proche', () => {
|
|||
|
|
assert.equal(placesSousPoignee(8, 0.4 * PAS_RAYON), 8);
|
|||
|
|
assert.equal(placesSousPoignee(8, 0.6 * PAS_RAYON), 9);
|
|||
|
|
assert.equal(placesSousPoignee(8, -0.6 * PAS_RAYON), 7);
|
|||
|
|
});
|
|||
|
|
});
|