[ADD] geometry: tables, their chairs, designating a seat
A table's tabletop, chairs, footprint and handle derive from its number of places, chairs numbered clockwise from the top, in drawing centimetres. The seat a pointer designates is read from the same chairs the drawing places, through one implementation, so what is drawn and where a drop lands cannot disagree. No table rotates. Checked: 1681 node tests. --- FR --- [ADD] géométrie : les tables, leurs chaises, la désignation d'un siège Le plateau, les chaises, l'emprise et la poignée d'une table dérivent de son nombre de places, chaises numérotées depuis le haut dans le sens horaire, en centimètres de dessin. Le siège que désigne un pointeur se lit sur les chaises mêmes que le dessin pose, par une seule implémentation : ce qui est dessiné et l'endroit où tombe un dépôt ne peuvent diverger. Aucune table ne tourne. Vérifié : 1681 épreuves node. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
1ea572468d
commit
eec14bdcb0
4 changed files with 833 additions and 0 deletions
75
src/geometrie/designation.js
Normal file
75
src/geometrie/designation.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La désignation d'un siège (§ 7.3, § 14.10) : ce que contient un point de
|
||||
// dessin, lu sur les géométries de tables.js, celles mêmes que le dessin pose.
|
||||
// Le dépôt d'un glissement ne cherche pas sa cible dans le DOM : il convertit
|
||||
// le point en coordonnées de dessin et le passe à designer.
|
||||
|
||||
/** Taille d'écran minimale d'une cible de siège, en px (§ 7.3). */
|
||||
export const CIBLE_MIN_PX = 24;
|
||||
|
||||
// Vrai quand le point est dans le disque (ronde) ou le carré aux côtés
|
||||
// parallèles aux axes (carrée) de demi-taille donnée, centré sur la table,
|
||||
// bord compris.
|
||||
function dans(point, { centre, forme }, demiTaille) {
|
||||
const dx = point.x - centre.x;
|
||||
const dy = point.y - centre.y;
|
||||
return forme === 'ronde' ? Math.hypot(dx, dy) <= demiTaille : Math.max(Math.abs(dx), Math.abs(dy)) <= demiTaille;
|
||||
}
|
||||
|
||||
/**
|
||||
* Régime des cibles d'une table au zoom k : 'sieges' quand k × pas ≥
|
||||
* CIBLE_MIN_PX, 'table' sinon. Le seuil se décide table par table, sur le
|
||||
* pas de chacune.
|
||||
*
|
||||
* @param {number} k px/cm
|
||||
* @param {import('./tables.js').GeometrieTable} geometrie
|
||||
* @returns {'sieges'|'table'}
|
||||
*/
|
||||
export function regimeCibles(k, geometrie) {
|
||||
return k * geometrie.pas >= CIBLE_MIN_PX ? 'sieges' : 'table';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que désigne un point de dessin au zoom k.
|
||||
*
|
||||
* Les tables s'examinent de la dernière reçue à la première, l'ordre inverse
|
||||
* de la peinture : la première dont la surface peinte contient le point
|
||||
* décide. En régime table, la surface est l'emprise — disque d'une ronde,
|
||||
* carré d'une carrée — et la table est désignée. En régime sièges, elle est
|
||||
* le plateau et les chaises : un point dans une chaise désigne son siège ;
|
||||
* un point sur le plateau ne désigne rien, et cache les chaises des tables
|
||||
* peintes dessous ; entre les chaises, le point passe aux tables de dessous.
|
||||
* Deux chaises d'une même table ne se recouvrent pas.
|
||||
*
|
||||
* @param {{x: number, y: number}} point cm
|
||||
* @param {import('./tables.js').GeometrieTable[]} geometries dans l'ordre du dessin
|
||||
* @param {number} k px/cm
|
||||
* @returns {{table: number, siege: number}|{table: number}|null}
|
||||
*/
|
||||
export function designer(point, geometries, k) {
|
||||
for (let i = geometries.length - 1; i >= 0; i -= 1) {
|
||||
const geometrie = geometries[i];
|
||||
if (regimeCibles(k, geometrie) === 'table') {
|
||||
if (dans(point, geometrie, geometrie.rayonEmprise)) return { table: geometrie.id };
|
||||
continue;
|
||||
}
|
||||
const chaise = geometrie.chaises.find((c) => Math.hypot(point.x - c.x, point.y - c.y) <= c.rayon);
|
||||
if (chaise !== undefined) return { table: geometrie.id, siege: chaise.siege };
|
||||
if (dans(point, geometrie, geometrie.demiTaille)) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai quand le point n'est dans l'emprise — disque ou carré — d'aucune
|
||||
* table : le sol, où un dépôt retire la personne du tour.
|
||||
*
|
||||
* @param {{x: number, y: number}} point cm
|
||||
* @param {import('./tables.js').GeometrieTable[]} geometries
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function surLeSol(point, geometries) {
|
||||
return !geometries.some((geometrie) => dans(point, geometrie, geometrie.rayonEmprise));
|
||||
}
|
||||
188
src/geometrie/designation.test.js
Normal file
188
src/geometrie/designation.test.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves de la désignation (§ 7.3, § 14.10) : le régime des cibles, décidé
|
||||
// table par table sur le pas de chacune, et le siège que désigne un point de
|
||||
// dessin, qui est celui que geometrieTable pose — sur les quatre
|
||||
// démonstrations livrées et sur des carrées, à un zoom où les sièges sont
|
||||
// des cibles et à un zoom où la table l'est.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { LIVREES } from '../demo/livrees.js';
|
||||
import { analyser, capacite } from '../stockage/document.js';
|
||||
import { CIBLE_MIN_PX, designer, regimeCibles, surLeSol } from './designation.js';
|
||||
import { englobant, geometrieTable } from './tables.js';
|
||||
|
||||
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
|
||||
|
||||
const milieu = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
|
||||
|
||||
describe('regimeCibles (§ 7.3)', () => {
|
||||
test('le seuil est de 24 px', () => {
|
||||
assert.equal(CIBLE_MIN_PX, 24);
|
||||
});
|
||||
|
||||
test('ronde de 8, pas 60 : 0,40 px/cm et au-dessus, les sièges', () => {
|
||||
const g = geometrieTable(table(1, 'ronde', 0, 0), 8);
|
||||
assert.equal(regimeCibles(0.41, g), 'sieges');
|
||||
assert.equal(regimeCibles(0.4, g), 'sieges');
|
||||
assert.equal(regimeCibles(0.39, g), 'table');
|
||||
});
|
||||
|
||||
test('ronde de 3, pas 70π/3 : bascule entre 0,33 et 0,32', () => {
|
||||
const g = geometrieTable(table(1, 'ronde', 0, 0), 3);
|
||||
assert.equal(regimeCibles(0.33, g), 'sieges');
|
||||
assert.equal(regimeCibles(0.32, g), 'table');
|
||||
});
|
||||
|
||||
test('carrée de 3, pas 70 : bascule entre 0,35 et 0,34', () => {
|
||||
const g = geometrieTable(table(1, 'carree', 0, 0), 3);
|
||||
assert.equal(regimeCibles(0.35, g), 'sieges');
|
||||
assert.equal(regimeCibles(0.34, g), 'table');
|
||||
});
|
||||
|
||||
test('ronde de 7, pas 60 exactement : sièges à 0,40', () => {
|
||||
assert.equal(regimeCibles(0.4, geometrieTable(table(1, 'ronde', 0, 0), 7)), 'sieges');
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’emprise d’une carrée est un carré', () => {
|
||||
// Carrée de 12 au centre (0, 0) : côté 180, emprise 90 + 44 = 134 ; le
|
||||
// siège 6, dernier du côté droit, est en (112, 60).
|
||||
const geometries = [geometrieTable(table(7, 'carree', 0, 0), 12)];
|
||||
|
||||
test('(125, 125) : hors du plateau et des chaises, dans le coin de l’emprise', () => {
|
||||
const point = { x: 125, y: 125 };
|
||||
assert.deepEqual(designer(point, geometries, 0.2), { table: 7 });
|
||||
assert.equal(designer(point, geometries, 1), null);
|
||||
assert.equal(surLeSol(point, geometries), false);
|
||||
});
|
||||
|
||||
test('(130, 60) : dans la chaise du siège 6, à 143 cm du centre', () => {
|
||||
const point = { x: 130, y: 60 };
|
||||
assert.deepEqual(designer(point, geometries, 0.2), { table: 7 });
|
||||
assert.deepEqual(designer(point, geometries, 1), { table: 7, siege: 6 });
|
||||
assert.equal(surLeSol(point, geometries), false);
|
||||
});
|
||||
|
||||
test('(140, 0) est le sol', () => {
|
||||
const point = { x: 140, y: 0 };
|
||||
assert.equal(designer(point, geometries, 0.2), null);
|
||||
assert.equal(designer(point, geometries, 1), null);
|
||||
assert.equal(surLeSol(point, geometries), true);
|
||||
});
|
||||
});
|
||||
|
||||
// Les géométries de chaque démonstration livrée, telles que le dessin les
|
||||
// pose, puis trois carrées de 3, 8 et 12 : les démonstrations n'ont que des
|
||||
// rondes.
|
||||
const JEUX = [
|
||||
...LIVREES.map(({ cle, texte }) => {
|
||||
const { charge } = analyser(texte);
|
||||
return { nom: cle, geometries: charge.tables.map((t) => geometrieTable(t, capacite(charge, t))) };
|
||||
}),
|
||||
{
|
||||
nom: 'carrées',
|
||||
geometries: [
|
||||
geometrieTable(table(1, 'carree', 0, 0), 3),
|
||||
geometrieTable(table(2, 'carree', 1000, 0), 8),
|
||||
geometrieTable(table(3, 'carree', 2000, 0), 12),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('le siège désigné est celui que le dessin pose (§ 7.3, § 14.10)', () => {
|
||||
const ATTENDUES = { grande: 260, 'grande-sans-exception': 264, petite: 12, 'petite-conflit': 12, 'carrées': 23 };
|
||||
|
||||
test('cinq jeux, dans l’ordre', () => {
|
||||
assert.deepEqual(JEUX.map(({ nom }) => nom), ['grande', 'grande-sans-exception', 'petite', 'petite-conflit', 'carrées']);
|
||||
});
|
||||
|
||||
for (const { nom, geometries } of JEUX) {
|
||||
test(`${nom}, k = 1 : chaque chaise rend sa table et son siège`, () => {
|
||||
let chaises = 0;
|
||||
for (const g of geometries) {
|
||||
assert.equal(regimeCibles(1, g), 'sieges');
|
||||
for (const chaise of g.chaises) {
|
||||
assert.deepEqual(designer(chaise, geometries, 1), { table: g.id, siege: chaise.siege });
|
||||
chaises += 1;
|
||||
}
|
||||
}
|
||||
assert.ok(ATTENDUES[nom] > 0);
|
||||
assert.equal(chaises, ATTENDUES[nom]);
|
||||
});
|
||||
|
||||
test(`${nom}, k = 1 : ni le milieu de deux chaises voisines, ni le centre d’une table ne désigne rien`, () => {
|
||||
let milieux = 0;
|
||||
for (const g of geometries) {
|
||||
const n = g.chaises.length;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const point = milieu(g.chaises[i], g.chaises[(i + 1) % n]);
|
||||
assert.equal(designer(point, geometries, 1), null, `table ${g.id}, sièges ${i + 1} et ${((i + 1) % n) + 1}`);
|
||||
assert.equal(surLeSol(point, geometries), false);
|
||||
milieux += 1;
|
||||
}
|
||||
assert.equal(designer(g.centre, geometries, 1), null, `centre de la table ${g.id}`);
|
||||
assert.equal(surLeSol(g.centre, geometries), false);
|
||||
}
|
||||
assert.equal(milieux, ATTENDUES[nom]);
|
||||
});
|
||||
|
||||
test(`${nom} : un point à 10 m de tout est le sol`, () => {
|
||||
const e = englobant(geometries);
|
||||
const loin = { x: e.maxX + 1000, y: e.maxY + 1000 };
|
||||
assert.equal(surLeSol(loin, geometries), true);
|
||||
assert.equal(designer(loin, geometries, 1), null);
|
||||
assert.equal(designer(loin, geometries, 0.2), null);
|
||||
});
|
||||
|
||||
test(`${nom}, k = 0,2 : chaque chaise, chaque milieu et chaque centre rendent la table, jamais un siège`, () => {
|
||||
let points = 0;
|
||||
for (const g of geometries) {
|
||||
assert.equal(regimeCibles(0.2, g), 'table');
|
||||
const n = g.chaises.length;
|
||||
const candidats = [g.centre, ...g.chaises, ...g.chaises.map((c, i) => milieu(c, g.chaises[(i + 1) % n]))];
|
||||
for (const point of candidats) {
|
||||
assert.deepEqual(designer(point, geometries, 0.2), { table: g.id });
|
||||
points += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(points, 2 * ATTENDUES[nom] + geometries.length);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('tables qui se recouvrent : la dernière reçue, peinte au-dessus', () => {
|
||||
test('deux tables au même centre : la seconde', () => {
|
||||
const geometries = [geometrieTable(table(1, 'ronde', 0, 0), 8), geometrieTable(table(2, 'ronde', 0, 0), 8)];
|
||||
const chaise = geometries[0].chaises[2];
|
||||
assert.deepEqual(designer(chaise, geometries, 1), { table: 2, siege: 3 });
|
||||
assert.deepEqual(designer({ x: 0, y: 0 }, geometries, 0.2), { table: 2 });
|
||||
});
|
||||
|
||||
test('régime sièges : le plateau du dessus cache la chaise de dessous', () => {
|
||||
// Rondes de 8 en (0, 0) et (100, 0) : le siège 3 de la première, en
|
||||
// (98,394 ; 0), tombe dans le plateau de la seconde, de rayon 76,394.
|
||||
const premiere = geometrieTable(table(1, 'ronde', 0, 0), 8);
|
||||
const seconde = geometrieTable(table(2, 'ronde', 100, 0), 8);
|
||||
const cachee = premiere.chaises[2];
|
||||
assert.equal(designer(cachee, [premiere, seconde], 1), null);
|
||||
assert.deepEqual(designer(cachee, [seconde, premiere], 1), { table: 1, siege: 3 });
|
||||
assert.deepEqual(designer(premiere.chaises[6], [premiere, seconde], 1), { table: 1, siege: 7 });
|
||||
});
|
||||
|
||||
test('régimes différents : chaque table se juge sur son propre pas', () => {
|
||||
// À k = 0,35, la ronde de 8 (pas 60) est en régime table, la carrée de 3
|
||||
// (pas 70) en régime sièges.
|
||||
const ronde = geometrieTable(table(1, 'ronde', 0, 0), 8);
|
||||
const carree = geometrieTable(table(2, 'carree', 1000, 0), 3);
|
||||
const geometries = [ronde, carree];
|
||||
assert.deepEqual(designer(ronde.chaises[0], geometries, 0.35), { table: 1 });
|
||||
assert.deepEqual(designer(carree.chaises[0], geometries, 0.35), { table: 2, siege: 1 });
|
||||
});
|
||||
|
||||
test('sans table, aucun point n’est désigné et tout est le sol', () => {
|
||||
assert.equal(designer({ x: 0, y: 0 }, [], 1), null);
|
||||
assert.equal(surLeSol({ x: 0, y: 0 }, []), true);
|
||||
});
|
||||
});
|
||||
239
src/geometrie/tables.js
Normal file
239
src/geometrie/tables.js
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La géométrie d'une table (§ 7.1, § 7.7), en centimètres de dessin, l'axe y
|
||||
// vers le bas : plateau tiré du nombre de places, chaises numérotées depuis le
|
||||
// haut dans le sens horaire, emprise, poignée et origine du bloc de noms. Le
|
||||
// dessin pose les chaises par geometrieTable et la désignation d'un siège
|
||||
// (designation.js) lit les mêmes chaises : une seule implémentation, que le
|
||||
// dessin et le dépôt partagent. Aucune table ne tourne : la géométrie ne
|
||||
// porte aucun angle.
|
||||
|
||||
/**
|
||||
* @typedef {Object} GeometrieTable en cm
|
||||
* @property {number} id
|
||||
* @property {number} numero
|
||||
* @property {'ronde'|'carree'} forme
|
||||
* @property {{x: number, y: number}} centre
|
||||
* @property {number} demiTaille rayon du plateau (ronde) ou demi-côté (carrée)
|
||||
* @property {number} pas distance entre deux sièges voisins, au bord du plateau
|
||||
* @property {Array<{siege: number, x: number, y: number, rayon: number}>} chaises sièges 1 à places
|
||||
* @property {{x: number, y: number}} poignee centre du disque de la poignée
|
||||
* @property {number} rayonEmprise demiTaille + 2 × RAYON_CHAISE : rayon du disque d'emprise
|
||||
* (ronde) ou demi-côté du carré d'emprise (carrée)
|
||||
* @property {{x: number, y: number}} origineListe coin haut gauche du bloc de noms
|
||||
*/
|
||||
|
||||
/** Diamètre minimal d'une table ronde, côté minimal d'une carrée, en cm :
|
||||
* réglage par défaut, non norme (§ 7.7). */
|
||||
export const PLANCHER_DIAMETRE = 70;
|
||||
/** Longueur de bord par convive, en cm (§ 7.7). */
|
||||
export const LARGEUR_PAR_PLACE = 60;
|
||||
/** Rayon d'une chaise, en cm. */
|
||||
export const RAYON_CHAISE = 22;
|
||||
/** Rayon du disque de la poignée, en cm. */
|
||||
export const RAYON_POIGNEE = 15;
|
||||
/** Distance entre l'emprise et le bord de la poignée, en cm. */
|
||||
export const ECART_POIGNEE = 8;
|
||||
/** Distance entre l'emprise et le bloc de noms, en cm. */
|
||||
export const ECART_LISTE = 10;
|
||||
/** Distance entre deux cases de la grille des tables créées, en cm. */
|
||||
export const PAS_GRILLE = 300;
|
||||
|
||||
// Accroissement du rayon d'une ronde pour une place de plus, au-dessus du
|
||||
// plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π.
|
||||
const RAYON_PAR_PLACE = LARGEUR_PAR_PLACE / (2 * Math.PI);
|
||||
|
||||
// Les quatre côtés d'une carrée, dans l'ordre où ils reçoivent les places —
|
||||
// haut, droit, bas, gauche — : le coin où commence le côté dans le sens
|
||||
// horaire, en demi-côtés depuis le centre, la direction de parcours et la
|
||||
// normale sortante.
|
||||
const COTES = [
|
||||
{ depart: { x: -1, y: -1 }, direction: { x: 1, y: 0 }, normale: { x: 0, y: -1 } },
|
||||
{ depart: { x: 1, y: -1 }, direction: { x: 0, y: 1 }, normale: { x: 1, y: 0 } },
|
||||
{ depart: { x: 1, y: 1 }, direction: { x: -1, y: 0 }, normale: { x: 0, y: 1 } },
|
||||
{ depart: { x: -1, y: 1 }, direction: { x: 0, y: -1 }, normale: { x: -1, y: 0 } },
|
||||
];
|
||||
|
||||
// Lève RangeError sur un nombre de places qui n'est pas un entier ≥ 1, ou un
|
||||
// diamètre ou côté saisi qui n'est pas un nombre fini > 0 : la géométrie
|
||||
// d'une table se calcule sans valeur de repli.
|
||||
function exigerPlaces(places, saisi) {
|
||||
if (!Number.isInteger(places) || places < 1) {
|
||||
throw new RangeError(`nombre de places entier ≥ 1 attendu, reçu ${JSON.stringify(places)}`);
|
||||
}
|
||||
if (saisi !== null && !(Number.isFinite(saisi) && saisi > 0)) {
|
||||
throw new RangeError(`dimension saisie > 0 attendue, reçu ${JSON.stringify(saisi)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π), ou le
|
||||
* diamètre saisi, qui l'emporte.
|
||||
*
|
||||
* @param {number} places entier ≥ 1
|
||||
* @param {{saisi?: number|null}} [options]
|
||||
* @returns {number} cm
|
||||
*/
|
||||
export function diametre(places, { saisi = null } = {}) {
|
||||
exigerPlaces(places, saisi);
|
||||
return saisi ?? Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Côté d'une table carrée : max(70, ⌈places / 4⌉ × 60) — le côté le plus
|
||||
* garni porte ⌈places / 4⌉ sièges —, ou le côté saisi, qui l'emporte.
|
||||
*
|
||||
* @param {number} places entier ≥ 1
|
||||
* @param {{saisi?: number|null}} [options]
|
||||
* @returns {number} cm
|
||||
*/
|
||||
export function cote(places, { saisi = null } = {}) {
|
||||
exigerPlaces(places, saisi);
|
||||
return saisi ?? Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE);
|
||||
}
|
||||
|
||||
// Chaises d'une ronde : sur le cercle de rayon demiTaille + RAYON_CHAISE,
|
||||
// siège s à l'angle −π/2 + 2π(s − 1)/places, le siège 1 en haut et la
|
||||
// numérotation dans le sens horaire à l'écran (y vers le bas).
|
||||
function chaisesRonde(centre, demiTaille, places) {
|
||||
const rayon = demiTaille + RAYON_CHAISE;
|
||||
return Array.from({ length: places }, (_, i) => {
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * i) / places;
|
||||
return { siege: i + 1, x: centre.x + rayon * Math.cos(angle), y: centre.y + rayon * Math.sin(angle), rayon: RAYON_CHAISE };
|
||||
});
|
||||
}
|
||||
|
||||
// Nombre de sièges de chaque côté d'une carrée, dans l'ordre de COTES :
|
||||
// ⌊places / 4⌋ chacun, et une place en surplus à chacun des premiers côtés.
|
||||
const siegesParCote = (places) => COTES.map((_, i) => Math.floor(places / 4) + (i < places % 4 ? 1 : 0));
|
||||
|
||||
// Chaises d'une carrée : sur chaque côté, n sièges espacés de côté / n, le
|
||||
// premier à côté / 2n du coin de départ, leur centre à RAYON_CHAISE au-delà
|
||||
// du bord ; le siège 1 est le premier du côté haut.
|
||||
function chaisesCarree(centre, demiTaille, places) {
|
||||
const chaises = [];
|
||||
siegesParCote(places).forEach((nombre, i) => {
|
||||
const { depart, direction, normale } = COTES[i];
|
||||
for (let j = 0; j < nombre; j += 1) {
|
||||
const avance = ((j + 0.5) * 2 * demiTaille) / nombre;
|
||||
chaises.push({
|
||||
siege: chaises.length + 1,
|
||||
x: centre.x + depart.x * demiTaille + direction.x * avance + normale.x * RAYON_CHAISE,
|
||||
y: centre.y + depart.y * demiTaille + direction.y * avance + normale.y * RAYON_CHAISE,
|
||||
rayon: RAYON_CHAISE,
|
||||
});
|
||||
}
|
||||
});
|
||||
return chaises;
|
||||
}
|
||||
|
||||
/**
|
||||
* Géométrie d'une table, en cm (contrat de données, GeometrieTable).
|
||||
*
|
||||
* Le pas d'une ronde est π × diamètre / places, calculé sous la forme
|
||||
* max(60, 70π / places) qui lui est égale : elle rend 60 exactement dès que
|
||||
* le périmètre borne la table, là où le produit rend 59,999… pour certains
|
||||
* nombres de places et ferait basculer le régime des cibles (designation.js)
|
||||
* sous le seuil. Le pas d'une carrée est son côté divisé par le nombre de
|
||||
* sièges du côté le plus garni.
|
||||
*
|
||||
* La poignée est un disque de rayon RAYON_POIGNEE sur la diagonale à +45°
|
||||
* (vers le bas à droite), son bord à ECART_POIGNEE de l'emprise : son centre
|
||||
* est à rayonEmprise + 23 cm du centre d'une ronde, à rayonEmprise × √2 + 23
|
||||
* cm de celui d'une carrée, au-delà du coin de son emprise.
|
||||
*
|
||||
* @param {{id: number, numero: number, forme: 'ronde'|'carree', position: {x: number, y: number}}} table
|
||||
* l'enregistrement de charge.tables ; position est le centre
|
||||
* @param {number} places la capacité courante, entier ≥ 1
|
||||
* @returns {GeometrieTable}
|
||||
* @throws {RangeError} pour une forme inconnue ou un nombre de places invalide
|
||||
*/
|
||||
export function geometrieTable(table, places) {
|
||||
const { id, numero, forme } = table;
|
||||
if (forme !== 'ronde' && forme !== 'carree') {
|
||||
throw new RangeError(`forme de table inconnue : ${JSON.stringify(forme)}`);
|
||||
}
|
||||
const centre = { x: table.position.x, y: table.position.y };
|
||||
const ronde = forme === 'ronde';
|
||||
const demiTaille = (ronde ? diametre(places) : cote(places)) / 2;
|
||||
const pas = ronde
|
||||
? Math.max(LARGEUR_PAR_PLACE, (PLANCHER_DIAMETRE * Math.PI) / places)
|
||||
: (2 * demiTaille) / Math.ceil(places / 4);
|
||||
const chaises = ronde ? chaisesRonde(centre, demiTaille, places) : chaisesCarree(centre, demiTaille, places);
|
||||
const rayonEmprise = demiTaille + 2 * RAYON_CHAISE;
|
||||
const distancePoignee = (ronde ? rayonEmprise : rayonEmprise * Math.SQRT2) + ECART_POIGNEE + RAYON_POIGNEE;
|
||||
const decalage = distancePoignee / Math.SQRT2;
|
||||
return {
|
||||
id,
|
||||
numero,
|
||||
forme,
|
||||
centre,
|
||||
demiTaille,
|
||||
pas,
|
||||
chaises,
|
||||
poignee: { x: centre.x + decalage, y: centre.y + decalage },
|
||||
rayonEmprise,
|
||||
origineListe: { x: centre.x + rayonEmprise + ECART_LISTE, y: centre.y - demiTaille },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rectangle englobant des emprises : pour chaque table, le carré de
|
||||
* demi-côté rayonEmprise centré sur elle, qui circonscrit le disque d'une
|
||||
* ronde et égale l'emprise d'une carrée.
|
||||
*
|
||||
* @param {GeometrieTable[]} geometries
|
||||
* @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} null sans table
|
||||
*/
|
||||
export function englobant(geometries) {
|
||||
if (geometries.length === 0) return null;
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const { centre, rayonEmprise } of geometries) {
|
||||
minX = Math.min(minX, centre.x - rayonEmprise);
|
||||
minY = Math.min(minY, centre.y - rayonEmprise);
|
||||
maxX = Math.max(maxX, centre.x + rayonEmprise);
|
||||
maxY = Math.max(maxY, centre.y + rayonEmprise);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Position d'une table créée : la première case libre, ligne par ligne, d'une
|
||||
* grille de PAS_GRILLE cm à ⌈√(T + 1)⌉ colonnes, T = centres.length, partant
|
||||
* de (0, 0) ; une case est prise quand un centre est à PAS_GRILLE / 2 ou
|
||||
* moins. centres : les positions de charge.tables, que l'appelant passe sans
|
||||
* construire de géométrie.
|
||||
*
|
||||
* La recherche s'arrête : un centre est à PAS_GRILLE / 2 ou moins d'au plus
|
||||
* deux cases, le plus petit disque qui contient trois cases ayant un rayon
|
||||
* d'au moins PAS_GRILLE / √2 ; l'une des 2T + 1 premières cases est donc
|
||||
* libre.
|
||||
*
|
||||
* @param {Array<{x: number, y: number}>} centres
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
export function caseLibre(centres) {
|
||||
const colonnes = Math.ceil(Math.sqrt(centres.length + 1));
|
||||
for (let rang = 0; ; rang += 1) {
|
||||
const candidate = { x: (rang % colonnes) * PAS_GRILLE, y: Math.floor(rang / colonnes) * PAS_GRILLE };
|
||||
const prise = centres.some(({ x, y }) => Math.hypot(x - candidate.x, y - candidate.y) <= PAS_GRILLE / 2);
|
||||
if (!prise) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de places sous la poignée : placesInitiales + round(ecartRadial ×
|
||||
* 2π / 60), jamais sous 2. L'écart se compte depuis le début du geste
|
||||
* (§ 7.2) ; un demi-pas exact s'arrondit vers le haut (Math.round).
|
||||
*
|
||||
* @param {number} placesInitiales les places au début du geste
|
||||
* @param {number} ecartRadial cm, projeté sur l'axe de la poignée, positif en s'éloignant du centre
|
||||
* @returns {number} entier ≥ 2
|
||||
*/
|
||||
export function placesSousPoignee(placesInitiales, ecartRadial) {
|
||||
return Math.max(2, placesInitiales + Math.round(ecartRadial / RAYON_PAR_PLACE));
|
||||
}
|
||||
331
src/geometrie/tables.test.js
Normal file
331
src/geometrie/tables.test.js
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
// © 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue