nearestColourMatch.test.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. /**
  2. * Regression tests for slot-order-dependent colour matching (#2804), frontend side.
  3. *
  4. * Three matchers in the interface fell back to "first spool within tolerance"
  5. * when no exact colour was loaded, so the winner depended on AMS slot order.
  6. * They now share `findNearestSimilar` with the scheduler's equivalent, so the
  7. * dialog cannot promise a spool the backend would not have picked.
  8. *
  9. * The worked example is the maintainer's: required `#3A7BD5`, a purple
  10. * `#6253AD` in tray 1 (40/40/40 off — admitted) and a near-identical `#3B7AD2`
  11. * in tray 3.
  12. *
  13. * Ranking is by CIEDE2000 delta-E, matching the backend's
  14. * `perceptual_color_distance`, so those two are ~18.5 and ~0.5 apart rather
  15. * than the ~69 and ~3 an RGB metric reported. Eligibility is still the
  16. * per-channel RGB box, unchanged — only the ordering within it is perceptual.
  17. */
  18. import { describe, expect, it } from 'vitest';
  19. import { colorDistance, colorsAreSimilar, findNearestSimilar } from '../../utils/amsHelpers';
  20. const REQUIRED = '#3A7BD5';
  21. const NEAR = '3B7AD2FF';
  22. const FAR_BUT_ADMITTED = '6253ADFF';
  23. const tray = (globalTrayId: number, color: string) => ({ globalTrayId, color });
  24. const pick = (candidates: { globalTrayId: number; color: string }[], required = REQUIRED) =>
  25. findNearestSimilar(candidates, required, (c) => c.color)?.globalTrayId;
  26. describe('colorDistance', () => {
  27. it('is zero for the same colour', () => {
  28. expect(colorDistance('#3A7BD5', '3A7BD5FF')).toBe(0);
  29. });
  30. it('ignores alpha so a transparent filament matches itself', () => {
  31. expect(colorDistance('#76D9F4', '76D9F400')).toBe(0);
  32. });
  33. it('is perceptual rather than per-channel', () => {
  34. // CIEDE2000 delta-E, where ~1 is a just-noticeable difference. The backend
  35. // must give the same answer for the same pair.
  36. expect(colorDistance('#000000', '#282828')).toBeCloseTo(9.91, 2);
  37. expect(colorDistance(NEAR, REQUIRED)).toBeCloseTo(0.4797, 3);
  38. expect(colorDistance(FAR_BUT_ADMITTED, REQUIRED)).toBeCloseTo(18.4853, 3);
  39. });
  40. it('ranks a perceptually nearer colour above a numerically nearer one', () => {
  41. // Against a green requirement, a purple is closer by RGB distance
  42. // (49.7 vs 56.9) and much further perceptually. Both are inside the
  43. // tolerance, so the ranking alone decides which one prints.
  44. const green = '#1E4821';
  45. expect(colorDistance('#43683E', green)!).toBeLessThan(colorDistance('#38202F', green)!);
  46. });
  47. it('is symmetric, so which spool is listed first cannot change the answer', () => {
  48. expect(colorDistance(NEAR, REQUIRED)).toBeCloseTo(colorDistance(REQUIRED, NEAR)!, 12);
  49. });
  50. it('returns null for unusable input rather than a number', () => {
  51. expect(colorDistance(undefined, REQUIRED)).toBeNull();
  52. expect(colorDistance('', REQUIRED)).toBeNull();
  53. expect(colorDistance('#abc', REQUIRED)).toBeNull();
  54. expect(colorDistance('#zzzzzz', REQUIRED)).toBeNull();
  55. });
  56. });
  57. describe('findNearestSimilar', () => {
  58. it('picks the closest admitted colour, not the first one', () => {
  59. expect(pick([tray(1, FAR_BUT_ADMITTED), tray(3, NEAR)])).toBe(3);
  60. });
  61. it('gives the same answer whichever order the trays arrive in', () => {
  62. expect(pick([tray(1, FAR_BUT_ADMITTED), tray(3, NEAR)])).toBe(3);
  63. expect(pick([tray(3, NEAR), tray(1, FAR_BUT_ADMITTED)])).toBe(3);
  64. });
  65. it('keeps the incoming order on a tie, so prefer-lowest still decides', () => {
  66. // Same colour in both trays, so the tie is exact. Two *different* colours
  67. // at equal RGB distance are not perceptually tied — that is the whole point
  68. // of the metric — so they cannot be used to test this any more.
  69. expect(pick([tray(2, NEAR), tray(7, NEAR)])).toBe(2);
  70. expect(pick([tray(7, NEAR), tray(2, NEAR)])).toBe(7);
  71. });
  72. it('admits exactly what the tolerance admitted before', () => {
  73. // 41 off on one channel is outside the box and must not be ranked in.
  74. expect(colorsAreSimilar('3A7BFEFF', REQUIRED)).toBe(false);
  75. expect(pick([tray(5, '3A7BFEFF')])).toBeUndefined();
  76. // 40 off on all three is inside it, as it was.
  77. expect(colorsAreSimilar(FAR_BUT_ADMITTED, REQUIRED)).toBe(true);
  78. expect(pick([tray(5, FAR_BUT_ADMITTED)])).toBe(5);
  79. });
  80. it('returns undefined when nothing qualifies', () => {
  81. expect(pick([tray(1, 'FF0000FF')])).toBeUndefined();
  82. });
  83. it('skips candidates with no usable colour instead of throwing', () => {
  84. expect(pick([{ globalTrayId: 1, color: '' }, tray(3, NEAR)])).toBe(3);
  85. });
  86. });