script-volume-auto/src/pipeline/pipeline.util.spec.ts
Nicolas Fryder 051209b022 Suppression de run, parametres CC exposes, pourcentages vs L0, carte de comparaison unique
- Bouton "Supprimer" sur chaque run (liste + detail), DELETE deja expose cote API.
- Tous les parametres CloudCompare mirroires dans l'UI (mirroir de la boite "Compute Volume" du
  GUI) : strategie de remplissage (leave_empty/min/max/custom/interpolate/kriging), hauteur de
  cellule (moyenne/min/max/mediane, -PROJ), direction de projection (X/Y/Z, -VERT_DIR),
  multiplicateur de grille. Nuage comble desormais exporte en ASCII (pas .bin) : -VOLUME le lit
  nativement, evite un aller-retour inutile puisqu'il faut de toute facon un export ASCII pour la
  carte de comparaison.
- Tableau : volume/surface/volume ajoute/volume retire avec % absolu et % relatif vs niveau 0.
  Matching cells retire du tableau et des graphiques (mais toujours calcule/stocke, juste plus
  affiche).
- Carte de comparaison unique par niveau (bleu->rouge façon CloudCompare, colormap jet) a la place
  des heightmaps separees par nuage : diff = ceil - ground (ou ceil - plan constant), calculee
  nous-memes par binning numpy sur une grille commune.
- README + CLAUDE.md : avertissement explicite sur la persistance Coolify quand le build pack est
  "Application"/Dockerfile plutot que "Docker Compose" (docker-compose.yml et son volume nomme sont
  alors completement ignores par Coolify, il faut un volume "Storages" configure manuellement).
- Valide en local avec les 2 vrais nuages utilisateur (mode comparaison) : resultats identiques a
  avant (14 244 -> 14 722 m3 selon le niveau), cartes de comparaison generees a chaque niveau,
  bouton suppression teste via l'UI.
2026-07-10 17:03:33 +02:00

145 lines
4.9 KiB
TypeScript

import { reportToCsv, sanitizeBaseName } from './pipeline.service';
import { RunReport } from './types';
describe('sanitizeBaseName', () => {
it('retire l\'extension et les caracteres non surs', () => {
expect(sanitizeBaseName('mon nuage (2026) v2.las')).toBe('mon_nuage_2026_v2');
});
it('gere le double-extension .copc.laz (ne retire que le dernier ".laz")', () => {
expect(sanitizeBaseName('zone_chantier.copc.laz')).toBe('zone_chantier_copc');
});
it('retombe sur un nom par defaut si le nom de base est vide apres extraction de l\'extension', () => {
expect(sanitizeBaseName('.las')).toBe('nuage');
});
it('tronque a 80 caracteres', () => {
const long = 'a'.repeat(200) + '.las';
expect(sanitizeBaseName(long).length).toBe(80);
});
});
describe('reportToCsv', () => {
const constHeightReport: RunReport = {
runId: 'run-1',
mode: 'const_height',
originalFilename: 'test.las',
originalFilenameBottom: null,
createdAt: '2026-01-01T00:00:00.000Z',
params: {
zref: 100,
zrefSource: 'auto',
initialStep: 0.02,
initialStepSource: 'auto',
gridStepMultiplier: 2,
gridStepMultiplierSource: 'auto',
maxEdgeLengthMultiplier: 3,
maxEdgeLengthSource: 'auto',
emptyCellStrategy: 'interpolate',
customHeightValue: null,
krigingKnn: null,
projectionType: 'avg',
vertDir: 2,
factor: 2,
steps: 1,
densityMatch: null,
sampleStats: { sampleCount: 100, meanNn: 0.02, medianNn: 0.02, bboxApprox: { xmin: 0, xmax: 1, ymin: 0, ymax: 1, zmin: 100, zmax: 101 } },
},
levels: [
{
level: 0,
gridStep: 0.04,
maxEdgeLength: 0.12,
clouds: [{ role: 'unique', spatialStep: null, pointCount: 1000, pointRatio: 1, binFile: 'levels/L0_full.bin' }],
volume: 12.5,
surface: 100,
addedVolume: 12.5,
removedVolume: 0,
matchingCellsPct: 99,
groundNonMatchingPct: 1,
ceilNonMatchingPct: 0,
diffMapImage: 'images/L0_diffmap.png',
warn: false,
status: 'ok',
computeTimeMs: 500,
},
{
level: 1,
gridStep: 0.08,
maxEdgeLength: 0.24,
clouds: [{ role: 'unique', spatialStep: 0.04, pointCount: 0, pointRatio: 0, binFile: null }],
volume: null,
surface: null,
addedVolume: null,
removedVolume: null,
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
diffMapImage: null,
warn: true,
status: 'skipped',
message: 'decimation, pas assez de points\navec une virgule, ici',
computeTimeMs: 0,
},
],
mergedBinFile: 'test_all_levels.bin',
};
it('genere un en-tete et une ligne par (niveau, role)', () => {
const csv = reportToCsv(constHeightReport);
const lines = csv.trim().split('\n');
expect(lines).toHaveLength(3); // header + 2 niveaux (1 nuage chacun en mode const_height)
expect(lines[0]).toBe(
'level,role,spatialStep,gridStep,maxEdgeLength,pointCount,pointRatio,volume,surface,addedVolume,removedVolume,matchingCellsPct,groundNonMatchingPct,ceilNonMatchingPct,status,warn,computeTimeMs,message',
);
});
it('genere une ligne par nuage (top ET bottom) en mode cloud_compare', () => {
const compareReport: RunReport = {
...constHeightReport,
mode: 'cloud_compare',
levels: [
{
level: 0,
gridStep: 0.04,
maxEdgeLength: 0.12,
clouds: [
{ role: 'top', spatialStep: 0.02, pointCount: 500, pointRatio: 1, binFile: 'levels/L0_top_full.bin' },
{ role: 'bottom', spatialStep: 0.02, pointCount: 480, pointRatio: 1, binFile: 'levels/L0_bottom_full.bin' },
],
volume: 5,
surface: 50,
addedVolume: 5,
removedVolume: 0,
matchingCellsPct: 97,
groundNonMatchingPct: 3,
ceilNonMatchingPct: 0,
diffMapImage: 'images/L0_diffmap.png',
warn: false,
status: 'ok',
computeTimeMs: 300,
},
],
};
const csv = reportToCsv(compareReport);
const lines = csv.trim().split('\n');
expect(lines).toHaveLength(3); // header + 2 lignes (top, bottom) pour le seul niveau
expect(lines[1]).toMatch(/^0,top,/);
expect(lines[2]).toMatch(/^0,bottom,/);
});
it('remplace les sauts de ligne et virgules dans les messages pour ne pas casser le CSV', () => {
const csv = reportToCsv(constHeightReport);
const lines = csv.trim().split('\n');
expect(lines[2]).not.toContain('\n');
const messageField = lines[2].split(',').slice(17).join(',');
expect(messageField).not.toMatch(/,/);
});
it('laisse les champs numeriques null en cellule vide plutot que "null"', () => {
const csv = reportToCsv(constHeightReport);
const lines = csv.trim().split('\n');
expect(lines[2]).not.toContain('null');
});
});