fix: resolve chain segment thickness in one place

Issue: #234
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-21 19:42:42 +03:00
parent 67505f8343
commit c8e9597228
10 changed files with 590 additions and 241 deletions
File diff suppressed because one or more lines are too long
+110
View File
@@ -0,0 +1,110 @@
/**
* Issue #234: a chain stores the thickness it was drawn with.
*
* The defect: "thickness of segment i" was decided by six independent formulas
* with three different fallbacks, so a chain drawn at 30 cm could be previewed
* at 30 and written as 15. The check below builds a chain whose last segment has
* no recorded thickness — the shape a mid-edit toolbar field used to produce —
* and then reads three things that used to disagree: the committed partitions,
* what the Thickness tool reports for the same stretch, and what a resumed
* legacy draft carries.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
c._setMode('plan');
c._tool = 'draw';
await upd();
const space = sp();
const savedRooms = JSON.parse(JSON.stringify(space.rooms || []));
const savedPartitions = JSON.parse(JSON.stringify(space.partitions || []));
const savedDrafts = space.room_drafts ? JSON.parse(JSON.stringify(space.room_drafts)) : null;
space.rooms = [];
space.partitions = [];
delete space.room_drafts;
await upd();
const g = c._gridPitch;
const pts = [
[100, 100],
[100 + 6 * g, 100],
[100 + 6 * g, 100 + 6 * g],
[100 + 12 * g, 100 + 6 * g],
];
// Two recorded segments at 30 and 25 cm, then a third point whose thickness
// is missing: exactly what a field left mid-edit produced before #234.
c._drawWallField = '30';
await upd();
out.fieldAcceptedThirty = c._drawWallCm === 30;
c._activeDraftId = null;
c._path = [pts[0], pts[1], pts[2], pts[3]];
c._draftSegmentCms = [30, 25];
c._closingWallCm = null;
await upd();
// Preview and the writer must agree on that third segment.
const previewD = (c.shadowRoot || c.renderRoot)
?.querySelector('path.drawwall-preview')?.getAttribute('d') || '';
out.previewRendered = previewD.length > 0;
out.committed = c._finishWallChain();
await upd();
const stored = (sp().partitions || []).map((p) => p.cm);
out.storedThreeSegments = stored.length === 3;
out.storedFirstThirty = stored[0] === 30;
out.storedSecondTwentyFive = stored[1] === 25;
// The gap inherits the previous segment, never the global default.
out.gapInheritedPrevious = stored[2] === 25;
out.nothingFellBackToFifteen = !stored.includes(15);
// The Thickness tool reads the same value it stored: the mismatch between
// highlight and record is how the defect was noticed in the first place.
const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const path = [pts[0], pts[1], pts[2], pts[3]];
const reported = [
c._wallSourceCmAt(mid(pts[0], pts[1]), path, [30, 25]),
c._wallSourceCmAt(mid(pts[1], pts[2]), path, [30, 25]),
c._wallSourceCmAt(mid(pts[2], pts[3]), path, [30, 25]),
];
out.highlightMatchesStored = reported[0] === stored[0]
&& reported[1] === stored[1] && reported[2] === stored[2];
// A draft saved with fewer records than segments (data written before this
// fix) resumes with a full vector instead of carrying a hidden 15 cm.
space.partitions = [];
space.room_drafts = [{
id: 'legacy-234',
points: pts.map((p) => [p[0] / 1000, p[1] / 1000]),
segments: [{ cm: 30 }],
}];
c._resumeDraftBySpace[c._space] = 'legacy-234';
c._path = [];
c._draftSegmentCms = [];
c._activeDraftId = null;
await upd();
c._resumeLastDraft();
await upd();
out.legacyResumedFullVector = c._draftSegmentCms.length === c._path.length - 1;
out.legacyGapsInheritedThirty = c._draftSegmentCms.every((cm) => cm === 30);
c._path = [];
c._draftSegmentCms = [];
c._activeDraftId = null;
delete c._resumeDraftBySpace[c._space];
space.rooms = savedRooms;
space.partitions = savedPartitions;
if (savedDrafts) space.room_drafts = savedDrafts; else delete space.room_drafts;
await upd();
return out;
});
checkAll(res);
await finish(browser);
File diff suppressed because one or more lines are too long
+69 -69
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -2,6 +2,14 @@
## Unreleased
- A chain of walls now stores the thickness it was drawn with. A segment whose
thickness was not recorded — which happened while the toolbar field was being
edited between segments — used to be previewed at the drawn value and saved at
15 cm, and the plan only revealed it later when the Thickness tool reported a
number nobody had chosen. Preview, both writers and the Thickness highlight now
read one resolver, and a missing record inherits the previous segment of the
same chain ([#234](https://github.com/Matysh/houseplan-card/issues/234)).
- Wall hatching no longer changes density with the space's grid scale. The same
15 cm wall used to look different on plans with different `cell_cm` — from a
dense set of stripes to almost none, a 25× spread. The step is now a distance
+8
View File
@@ -8,6 +8,14 @@
## Не выпущено
- Цепочка стен сохраняет ту толщину, которой её нарисовали. Отрезок, толщина
которого не была записана — это случалось, если поле толщины правили между
отрезками, — показывался с нарисованной толщиной, а сохранялся с 15 см, и
расхождение обнаруживалось позже: инструмент «Толщина» подсвечивал значение,
которого никто не выбирал. Теперь превью, оба пути записи и подсветка читают
один резолвер, а пропуск наследует толщину предыдущего отрезка той же цепочки
([#234](https://github.com/Matysh/houseplan-card/issues/234)).
- Плотность штриховки стен больше не зависит от масштаба пространства. Раньше
одна и та же стена 15 см выглядела на планах с разным `cell_cm` по-разному —
от плотной сетки полос до почти пустой полосы, разброс достигал 25 раз.
+41
View File
@@ -40,6 +40,47 @@ import { fileURLToPath } from 'node:url';
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
{
id: 'chain-thickness-falls-back-to-default',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="chainSegmentCms fills a gap" '
+ 'test/wall-face-graph.test.mjs',
because: 'a segment whose thickness was not recorded must inherit the previous segment of '
+ 'its own chain — the value the person watched on screen — instead of the global default '
+ 'that silently replaced 30 cm with 15 cm in the stored plan (#234)',
patches: [{
file: 'src/wall-face-graph.ts',
find: ' const cm = own ?? previous ?? fallbackTail;',
replace: ' const cm = own ?? fallbackTail;',
}],
},
{
id: 'chain-thickness-preview-diverges',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="owns no fallback of its own" '
+ 'test/wall-face-graph.test.mjs',
because: 'the preview and every writer must read one resolver; two formulas for one meaning '
+ 'is what let the editor show 30 cm while the config kept 15 (#234)',
patches: [{
file: 'src/wall-face-graph.ts',
find: ' result.push({ a: [a[0], a[1]], b: [b[0], b[1]], cm: cms[i] });',
replace: ' result.push({ a: [a[0], a[1]], b: [b[0], b[1]], '
+ 'cm: Number.isFinite(cms[i]) && cms[i] >= 0 ? cms[i] : 15 });',
}],
},
{
id: 'chain-thickness-length-invariant-dropped',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="always returns exactly segmentCount" '
+ 'test/wall-face-graph.test.mjs',
because: 'the resolver must return one thickness per segment for any input: the array drifting '
+ 'shorter than the path is the mechanism that produced the whole defect (#234)',
patches: [{
file: 'src/wall-face-graph.ts',
find: ' for (let i = 0; i < count; i++) {',
replace: ' for (let i = 0; i < Math.min(count, (recorded?.length ?? 0)); i++) {',
}],
},
{
id: 'snapn-returns-input-near-node',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+96 -28
View File
@@ -158,7 +158,7 @@ import {
} from './plan-snap-overlay';
import {
atomizeWallSegments, buildWallFaceGraph, findNewWallFacesInGraphs,
normalizeUnifiedWallTool, wallChainSegments,
normalizeUnifiedWallTool, wallChainSegments, chainSegmentCms,
type WallFaceGraph, type WallGraphFace, type WallGraphSourceSegment,
} from './wall-face-graph';
import {
@@ -2617,7 +2617,9 @@ class HouseplanCard extends LitElement {
if (draft) {
this._activeDraftId = draft.id;
this._path = draft.points.map((point) => [...point]);
this._draftSegmentCms = draft.segments.map((segment) => segment.cm);
this._draftSegmentCms = this._adoptDraftCms(
this._path, draft.segments.map((segment: any) => segment.cm), draft.id,
);
this._resumeDraftBySpace[this._space] = draft.id;
} else {
this._activeDraftId = null;
@@ -6574,7 +6576,11 @@ class HouseplanCard extends LitElement {
return true;
}
const segments = wallChainSegments(
this._path, this._draftSegmentCms, DRAW_WALL_DEFAULT_CM,
this._path,
chainSegmentCms(
this._path.length - 1, this._draftSegmentCms,
this._drawWallCm, DRAW_WALL_DEFAULT_CM,
),
);
const segmentCount = segments.length;
if ((sp.partitions || []).length + segmentCount > MAX_PARTITIONS) {
@@ -7268,9 +7274,13 @@ class HouseplanCard extends LitElement {
this._path = endHit.reverse
? [...endHit.draft.points].reverse().map((p) => [...p])
: endHit.draft.points.map((p) => [...p]);
this._draftSegmentCms = endHit.reverse
? [...endHit.draft.segments].reverse().map((s) => s.cm)
: endHit.draft.segments.map((s) => s.cm);
this._draftSegmentCms = this._adoptDraftCms(
this._path,
endHit.reverse
? [...endHit.draft.segments].reverse().map((s) => s.cm)
: endHit.draft.segments.map((s) => s.cm),
endHit.draft.id,
);
return;
}
this._activeDraftId = null;
@@ -7289,8 +7299,16 @@ class HouseplanCard extends LitElement {
return;
}
if (!this._canAppendRoomDraftPoint()) return;
// Точка и толщина её отрезка пишутся вместе (#234). Раньше запись жила в
// отдельном методе, который молча выходил при невалидном поле толщины, и
// тогда `_draftSegmentCms` становился короче числа отрезков: превью
// показывало текущее поле, а запись — 15 см. Инвариант читается прямо
// здесь, а не выводится из двух проверок в разных местах.
const cm = this._drawWallCm;
if (cm == null) { this._showPhysicalRange(100); return; }
const beforePath = this._path.map((point) => [...point]);
this._path = [...this._path, pt];
this._draftSegmentCms = [...this._draftSegmentCms, cm];
this._persistActiveDraftSegment();
this._offerWallFaces(beforePath);
}
@@ -7395,8 +7413,10 @@ class HouseplanCard extends LitElement {
sp.room_drafts.push(saved);
this._activeDraftId = id;
this._resumeDraftBySpace[this._space] = id;
this._draftSegmentCms = persistedSegments.map((s: any) => Number(s.cm));
this._path = persistedPoints;
this._draftSegmentCms = this._adoptDraftCms(
persistedPoints, persistedSegments.map((s: any) => Number(s.cm)), id,
);
this._physicalSel = null;
this._recordGeometry(this._t('history.draft_merge'), before);
this._saveConfig();
@@ -7406,12 +7426,35 @@ class HouseplanCard extends LitElement {
}
}
/** Persist every completed draft segment immediately. */
/**
* Thickness array adopted from storage, brought to the length of the path.
*
* A record written before #234 may be shorter than the path: the resolver
* fills the gaps by the same rule the preview and the writers use, so a
* resumed draft cannot carry a hidden 15 cm into the next save. Reported to
* the console rather than to the user: the person did not cause it and cannot
* fix it.
*/
private _adoptDraftCms(path: readonly (readonly number[])[], recorded: readonly (number | null | undefined)[], id?: string): number[] {
const count = Math.max(0, path.length - 1);
const resolved = chainSegmentCms(count, recorded, this._drawWallCm, DRAW_WALL_DEFAULT_CM);
if (recorded.length !== count) {
console.debug(
`[houseplan] draft ${id ?? '?'}: восстановлено толщин ${count - recorded.length} (#234)`,
);
}
return resolved;
}
/**
* Persist every completed draft segment immediately.
*
* The thickness of the new segment is already recorded by the caller (#234):
* this method must not decide whether to record it, or the array and the path
* drift apart the moment the toolbar field is mid-edit.
*/
private _persistActiveDraftSegment(): void {
if (this._path.length < 2 || !this._curSpaceCfg) return;
const cm = this._drawWallCm;
if (cm == null) return;
this._draftSegmentCms = [...this._draftSegmentCms, cm];
const before = this._geometrySnapshot();
const sp = this._curSpaceCfg as any;
sp.room_drafts ||= [];
@@ -12383,10 +12426,17 @@ class HouseplanCard extends LitElement {
return validCm(model.room_drafts.find((item) => item.id === draftId)?.segments[index]?.cm)
?? DRAW_WALL_DEFAULT_CM;
}
// Толщина отрезка активной цепочки решается тем же резолвером (#234):
// именно это значение подсвечивает инструмент «Толщина», и расхождение с
// записью здесь было тем способом, которым дефект и обнаружился.
const resolved = chainSegmentCms(
Math.max(0, activePath.length - 1), activeCms,
this._drawWallCm, DRAW_WALL_DEFAULT_CM,
);
for (let i = 0; i + 1 < activePath.length; i++) {
const a = activePath[i], b = activePath[i + 1];
if (distToSegment(point, [a[0], a[1], b[0], b[1]]) <= epsilon) {
return validCm(activeCms[i]) ?? DRAW_WALL_DEFAULT_CM;
return resolved[i] ?? DRAW_WALL_DEFAULT_CM;
}
}
return DRAW_WALL_DEFAULT_CM;
@@ -12441,7 +12491,11 @@ class HouseplanCard extends LitElement {
const partitions: Array<{ a: number[]; b: number[]; cm: number }> = [];
if (!accepted.length) {
partitions.push(...wallChainSegments(
batch.activePath, batch.activeCms, DRAW_WALL_DEFAULT_CM,
batch.activePath,
chainSegmentCms(
batch.activePath.length - 1, batch.activeCms,
this._drawWallCm, DRAW_WALL_DEFAULT_CM,
),
));
} else {
const consumed = new Set(accepted.flatMap((decision) => decision.candidate.atomKeys));
@@ -12656,8 +12710,12 @@ class HouseplanCard extends LitElement {
// Draw-session wall thickness: apply to new edges only; keep neighbour cm
// on shared stretches. Split naming does not use the Draw field.
if (!wasSplit) {
const edgeCms = [...this._draftSegmentCms, this._closingWallCm || this._drawWallCm || DRAW_WALL_DEFAULT_CM];
const cm = edgeCms[0] || this._drawWallCm;
const edgeCms = chainSegmentCms(
verts.length,
[...this._draftSegmentCms, this._closingWallCm ?? undefined],
this._drawWallCm, DRAW_WALL_DEFAULT_CM,
);
const cm = edgeCms[0];
if (cm != null) {
this._cfgEpoch++; // the new room must be in the model before keying
const openCuts = this._openCuts();
@@ -12681,7 +12739,7 @@ class HouseplanCard extends LitElement {
return distToSegment(mid, [a[0], a[1], b[0], b[1]]) <= this._gridPitch * 0.02;
});
if (source >= 0) next = setWallThickness(
next, iv.a, iv.b, edgeCms[source] || cm,
next, iv.a, iv.b, edgeCms[source],
this._wallKeyPitch, NORM_W,
);
}
@@ -12759,7 +12817,9 @@ class HouseplanCard extends LitElement {
if (!draft) { delete this._resumeDraftBySpace[this._space]; return; }
this._activeDraftId = id;
this._path = draft.points.map((p) => [...p]);
this._draftSegmentCms = draft.segments.map((s) => s.cm);
this._draftSegmentCms = this._adoptDraftCms(
this._path, draft.segments.map((s: any) => s.cm), draft.id,
);
this._clearPlanSnapHover();
}
@@ -13029,8 +13089,14 @@ class HouseplanCard extends LitElement {
return;
}
const before = this._geometrySnapshot();
const cms = [...this._draftSegmentCms,
this._closingWallCm || this._drawWallCm || DRAW_WALL_DEFAULT_CM];
// Замкнутый контур: отрезков столько же, сколько вершин, и последний —
// закрывающий. Его известное значение подаётся резолверу как запись, всё
// остальное решает единое правило (#234).
const cms = chainSegmentCms(
verts.length,
[...this._draftSegmentCms, this._closingWallCm ?? undefined],
this._drawWallCm, DRAW_WALL_DEFAULT_CM,
);
sp.partitions ||= [];
const seed = Date.now().toString(36);
for (let i = 0; i < verts.length; i++) {
@@ -13039,7 +13105,7 @@ class HouseplanCard extends LitElement {
id: `partition-${seed}-${i}`,
a: [a[0] / NORM_W, a[1] / NORM_W],
b: [b[0] / NORM_W, b[1] / NORM_W],
cm: cms[i] || DRAW_WALL_DEFAULT_CM,
cm: cms[i],
});
}
if (this._activeDraftId && Array.isArray(sp.room_drafts)) {
@@ -18544,15 +18610,17 @@ class HouseplanCard extends LitElement {
if (this._cursorPt) return [...path, this._cursorPt];
return path.length >= 2 ? path : null;
})();
// Превью берёт толщины из того же резолвера, что и запись (#234). Раньше
// здесь была вторая формула, и расходились они ровно на пропуске: на экране
// текущее поле, в конфиге — 15 см.
const previewHalfDepths = previewPts
? previewPts.slice(0, -1).map((_, i) => {
const cm = Number(this._draftSegmentCms[i]) > 0
? Number(this._draftSegmentCms[i])
: this._contourClosed && i === previewPts.length - 2
? (this._closingWallCm || drawCm || DRAW_WALL_DEFAULT_CM)
: (drawCm || DRAW_WALL_DEFAULT_CM);
return wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2;
})
? chainSegmentCms(
previewPts.length - 1,
this._contourClosed
? [...this._draftSegmentCms, this._closingWallCm ?? undefined]
: this._draftSegmentCms,
drawCm, DRAW_WALL_DEFAULT_CM,
).map((cm) => wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2)
: [];
const previewD = previewPts
? drawWallPreviewD(
+54 -3
View File
@@ -55,10 +55,60 @@ export function normalizeUnifiedWallTool(value: unknown): unknown {
}
/** Immutable open-chain projection used by explicit finish and full rejection. */
/**
* Thickness of every segment in a chain — the single answer to that question.
*
* Issue #234: five call sites decided it independently and disagreed in three
* different ways. The preview filled a gap with the toolbar field, the two
* partition writers with a hard-coded 15 cm, the room writer with the first
* edge's value. So a chain drawn at 30 cm was shown at 30 and stored at 15, and
* the owner discovered it much later by hovering a wall. Two formulas for one
* meaning always drift; there is exactly one here now.
*
* A missing record inherits the previous segment of the same chain, then the
* toolbar field, then the default (owner's decision 2026-08-21): that is what
* the person saw on screen while drawing, and a global default is not.
*
* Strictly positive is the validity boundary. The previous `wallChainSegments`
* accepted a recorded zero, which cannot be drawn through the UI (1..100 cm,
* `docs/WALL-THICKNESS.md`) but can sit in an old draft.
*/
export function chainSegmentCms(
segmentCount: number,
recorded: readonly (number | null | undefined)[] | null | undefined,
activeCm: number | null | undefined,
defaultCm: number,
): number[] {
const count = Number.isFinite(segmentCount) && segmentCount > 0
? Math.floor(segmentCount) : 0;
const valid = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
// `defaultCm` — ответственность вызывающего: он передаёт
// DRAW_WALL_DEFAULT_CM. Константа сюда не импортируется намеренно — этот
// модуль не зависит ни от чего, и второе место, где живёт число 15, было бы
// ровно тем дублированием, которое задача и убирает. Невалидный default —
// дефект вызывающего, поэтому он приводится к минимальной допустимой
// толщине (1 см, docs/WALL-THICKNESS.md), а не к выдуманному значению.
const fallbackTail = valid(activeCm) ?? valid(defaultCm) ?? 1;
const out: number[] = [];
let previous: number | null = null;
for (let i = 0; i < count; i++) {
const own = valid(recorded?.[i]);
const cm = own ?? previous ?? fallbackTail;
out.push(cm);
previous = cm;
}
return out;
}
/**
* Drawable segments of a chain. Thickness arrives already resolved (#234): this
* function no longer owns a fallback of its own, because owning one is how the
* disagreement started.
*/
export function wallChainSegments(
path: readonly (readonly number[])[],
cms: readonly number[],
defaultCm: number,
): WallChainSegment[] {
const result: WallChainSegment[] = [];
for (let i = 0; i + 1 < path.length; i++) {
@@ -66,8 +116,9 @@ export function wallChainSegments(
const b = path[i + 1];
if (!finitePoint(a) || !finitePoint(b)
|| Math.hypot(b[0] - a[0], b[1] - a[1]) <= Number.EPSILON) continue;
const cm = Number.isFinite(cms[i]) && cms[i] >= 0 ? cms[i] : defaultCm;
result.push({ a: [a[0], a[1]], b: [b[0], b[1]], cm });
// The resolver guarantees a positive number per index; a caller that skips
// it is a defect, so the value is used as given rather than re-defaulted.
result.push({ a: [a[0], a[1]], b: [b[0], b[1]], cm: cms[i] });
}
return result;
}
+66 -3
View File
@@ -7,6 +7,7 @@ import {
findNewWallFacesInGraphs,
normalizeUnifiedWallTool,
wallChainSegments,
chainSegmentCms,
} from '../test-build/wall-face-graph.js';
const edge = (key, a, b) => ({ key, a, b });
@@ -24,14 +25,76 @@ test('legacy Partition token becomes Walls without mutating other tools', () =>
});
test('open chain converts endpoints and per-segment thickness without mutation', () => {
// Толщины приходят уже разрешёнными (#234): своего fallback у функции нет.
const path = [[0, 0], [100, 0], [100, 50]];
const before = JSON.stringify(path);
assert.deepEqual(wallChainSegments(path, [0, 25], 15), [
{ a: [0, 0], b: [100, 0], cm: 0 },
assert.deepEqual(wallChainSegments(path, [30, 25]), [
{ a: [0, 0], b: [100, 0], cm: 30 },
{ a: [100, 0], b: [100, 50], cm: 25 },
]);
assert.equal(JSON.stringify(path), before);
assert.deepEqual(wallChainSegments([[0, 0]], [], 15), []);
assert.deepEqual(wallChainSegments([[0, 0]], []), []);
});
test('chainSegmentCms fills a gap from the previous segment, then the field (#234)', () => {
// Симптом задачи: цепочка нарисована 30 см, запись последнего отрезка
// потеряна. Раньше запись давала 15, превью — 30; теперь ответ один.
assert.deepEqual(chainSegmentCms(3, [30, 30], 30, 15), [30, 30, 30]);
// Дырка в середине наследует предыдущий, а не следующий и не дефолт.
assert.deepEqual(chainSegmentCms(4, [30, undefined, 20], 12, 15), [30, 30, 20, 20]);
// Нет ни одной записи — текущее поле.
assert.deepEqual(chainSegmentCms(2, [], 22, 15), [22, 22]);
// Нет и поля — дефолт вызывающего.
assert.deepEqual(chainSegmentCms(2, [], null, 15), [15, 15]);
});
test('chainSegmentCms treats only strictly positive records as valid (#234)', () => {
// Прежняя wallChainSegments считала записанный 0 валидным; через UI ноль
// недостижим (1..100 см), но в старом черновике лежать может.
assert.deepEqual(chainSegmentCms(3, [0, 30, 0], 25, 15), [25, 30, 30]);
assert.deepEqual(chainSegmentCms(4, [NaN, -5, null, 'x'], 18, 15), [18, 18, 18, 18]);
assert.deepEqual(chainSegmentCms(2, [Infinity, 40], 18, 15), [18, 40]);
});
test('chainSegmentCms always returns exactly segmentCount positive numbers (#234)', () => {
// Инвариант: длина результата равна числу отрезков при любом входе — именно
// его отсутствие и позволяло массиву разъехаться с путём.
for (const count of [0, 1, 5]) {
for (const recorded of [[], [30], [30, 30, 30, 30, 30, 30, 30], [null, 0, NaN]]) {
const out = chainSegmentCms(count, recorded, 20, 15);
assert.equal(out.length, count);
assert.ok(out.every((cm) => typeof cm === 'number' && cm > 0), JSON.stringify(out));
}
}
// Мусор в самих аргументах длины и дефолта не роняет резолвер.
assert.deepEqual(chainSegmentCms(-1, [30], 20, 15), []);
assert.deepEqual(chainSegmentCms(2, [30], null, NaN), [30, 30]);
assert.deepEqual(chainSegmentCms(2, null, null, null), [1, 1]);
});
test('wallChainSegments owns no fallback of its own (#234)', () => {
// Свойство, а не курьёз: единственный источник значения — резолвер. Если у
// этой функции появится собственный дефолт, вернётся ровно та ситуация, из
// которой выросла задача — две формулы для одного смысла. Поэтому пропуск
// здесь обязан остаться пропуском, а не превратиться в 15 см.
const path = [[0, 0], [100, 0], [100, 50]];
const out = wallChainSegments(path, [30]);
assert.equal(out.length, 2);
assert.equal(out[0].cm, 30);
assert.equal(out[1].cm, undefined);
});
test('the preview and the writers cannot disagree, because the source is one (#234)', () => {
// Это ядро задачи, поэтому проверяется как свойство, а не как пример:
// вектор для превью и вектор для записи строятся одним вызовом.
const path = [[0, 0], [100, 0], [100, 60], [0, 60]];
const recorded = [30, 30];
const field = 30;
const resolved = chainSegmentCms(path.length - 1, recorded, field, 15);
const written = wallChainSegments(path, resolved).map((segment) => segment.cm);
assert.deepEqual(written, resolved);
// И то, что раньше расходилось: последний отрезок больше не 15.
assert.equal(written[written.length - 1], 30);
});
test('atomizes endpoint, T, proper X and collinear overlap with provenance', () => {