Compare commits

..
Author SHA1 Message Date
ausboss 8911fd9eb3 Add edge-case tests and fix three small bugs they found
- Integer rounding: adding 0.5 before flooring could round large
  floats the wrong way; compare the fraction instead.
- H3 prompt sections: Windows line endings (CRLF) kept the six part
  names from being found; split with splitlines().
- Media routes: NaN, infinity or a huge number in a query parameter
  raised or passed through; fall back to the default.

26 new CPU-only tests cover resize and pad math, media paths, route
parameters, rounding and prompt parsing.
2026-11-04 00:07:46 -06:00
16 changed files with 331 additions and 399 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ export function restoreOutputLinks(node, outputs, graph) {
const targetNode = graph.getNodeById(target.nodeId); const targetNode = graph.getNodeById(target.nodeId);
if (!targetNode) continue; if (!targetNode) continue;
const current = lookupLink(graph, targetNode.inputs?.[target.slot]?.link); const current = lookupLink(graph, targetNode.inputs?.[target.slot]?.link);
if (current?.origin_id === node.id && current.origin_slot === output.slot) continue; // never stolen if (current?.origin_id === node.id) continue; // never stolen
linkSlots(node, output.slot, targetNode, target.slot); linkSlots(node, output.slot, targetNode, target.slot);
} }
} }
+1 -1
View File
@@ -37,7 +37,7 @@ def split_sections(text):
"""The six parts of a prompt by name, or None when one is missing.""" """The six parts of a prompt by name, or None when one is missing."""
found = {} found = {}
current = None current = None
for line in text.split("\n"): for line in text.splitlines():
name = line.strip().rstrip(":") name = line.strip().rstrip(":")
if line.strip().endswith(":") and name in SECTIONS: if line.strip().endswith(":") and name in SECTIONS:
current = found.setdefault(name, []) current = found.setdefault(name, [])
+4 -3
View File
@@ -177,9 +177,10 @@ def round_half_away_from_zero(value: float) -> int:
Python's round() rounds halves to even (2.5 -> 2), which reads as a bug Python's round() rounds halves to even (2.5 -> 2), which reads as a bug
when the number is on screen next to the float it came from. when the number is on screen next to the float it came from.
""" """
if value >= 0: fraction, whole = math.modf(value)
return int(math.floor(value + 0.5)) # Compare the fraction directly: adding 0.5 can round the float before
return int(math.ceil(value - 0.5)) # we round it, including changing an already whole large number.
return int(whole) + (1 if fraction >= 0.5 else -1 if fraction <= -0.5 else 0)
__all__ = [ __all__ = [
+3 -2
View File
@@ -847,12 +847,13 @@ def _safe_route_error(exc: Exception) -> str:
def _query_int(request, name: str, default: int) -> int: def _query_int(request, name: str, default: int) -> int:
try: try:
return int(float(request.query.get(name, default))) return int(float(request.query.get(name, default)))
except (TypeError, ValueError): except (TypeError, ValueError, OverflowError):
return default return default
def _query_float(request, name: str, default: float) -> float: def _query_float(request, name: str, default: float) -> float:
try: try:
return float(request.query.get(name, default)) value = float(request.query.get(name, default))
return value if math.isfinite(value) else default
except (TypeError, ValueError): except (TypeError, ValueError):
return default return default
-53
View File
@@ -3,8 +3,6 @@ import assert from "node:assert/strict";
import { import {
BRUSH_SIZE, BRUSH_SIZE,
ADJUST_DEAD_ZONE,
adjustAxis,
adjustedBrush, adjustedBrush,
brushAdjusts, brushAdjusts,
brushFlips, brushFlips,
@@ -17,7 +15,6 @@ import {
moveAdjust, moveAdjust,
startAdjust, startAdjust,
steppedBrushSize, steppedBrushSize,
steppedSoft,
toolKey, toolKey,
} from "../js/shared/brush.mjs"; } from "../js/shared/brush.mjs";
@@ -104,53 +101,3 @@ test("the brush under the pointer fades as its marks do", () => {
assert.ok(brushProfile(brushReach(80, 1), 80, 1) < 0.01); assert.ok(brushProfile(brushReach(80, 1), 80, 1) < 0.01);
for (let d = 0; d < 50; d += 5) assert.ok(brushProfile(d, 80, 0.6) >= brushProfile(d + 5, 80, 0.6), "never brighter further out"); for (let d = 0; d < 50; d += 5) assert.ok(brushProfile(d, 80, 0.6) >= brushProfile(d + 5, 80, 0.6), "never brighter further out");
}); });
test("brush values round, clamp and fall back without producing NaN", () => {
assert.equal(clampBrushSize("25.6"), 26);
assert.equal(clampBrushSize(1), BRUSH_SIZE[0]);
for (const value of [undefined, "large", NaN, Infinity, -Infinity]) {
assert.equal(clampBrushSize(value, [4, 1000]), 4);
assert.equal(clampSoft(value, 20), 20);
}
assert.equal(clampSoft("24.6"), 25);
assert.equal(clampSoft(-5), 0);
assert.equal(clampSoft(101), 100);
assert.equal(clampSoft(undefined, 120), 100);
});
test("brush keys change small sizes by at least two pixels and stop at the bounds", () => {
assert.equal(steppedBrushSize(2, true), 4);
assert.equal(steppedBrushSize(4, false), 2);
assert.equal(steppedBrushSize(2, false), 2);
assert.equal(steppedBrushSize(100, false), 84);
assert.equal(steppedBrushSize(2000, true), 2000);
assert.equal(steppedSoft(50, true), 75);
assert.equal(steppedSoft(50, false), 25);
assert.equal(steppedSoft(90, true), 100);
assert.equal(steppedSoft(10, false), 0);
});
test("brush adjustment starts at the dead-zone boundary and breaks diagonal ties by size", () => {
const edge = ADJUST_DEAD_ZONE;
assert.equal(adjustAxis(edge - 1, -(edge - 1)), null);
for (const direction of [-1, 1]) {
assert.equal(adjustAxis(direction * edge, 0), "size");
assert.equal(adjustAxis(0, direction * edge), "soft");
assert.equal(adjustAxis(direction * edge, -edge), "size");
}
});
test("a tiny blur keeps the hard-brush edge, while a soft brush scales with its size", () => {
// Below a quarter pixel of blur the preview uses a solid disc.
assert.equal(brushProfile(1, 2, 0.5), 1);
assert.equal(brushProfile(1.01, 2, 0.5), 0);
assert.equal(brushReach(2, 0.5), 1);
for (const soft of [0.25, 0.5, 1]) {
assert.equal(brushReach(160, soft), 2 * brushReach(80, soft));
for (const distance of [0, 10, 20, 40, 80]) {
const value = brushProfile(distance, 80, soft);
assert.ok(value >= 0 && value <= 1);
assert.equal(brushProfile(distance * 2, 160, soft), value);
}
}
});
-57
View File
@@ -89,60 +89,3 @@ test("a failed lookup says why in plain words", () => {
assert.equal(civitaiFailureText(new Error("Civitai answered 500.")), "Civitai answered 500."); assert.equal(civitaiFailureText(new Error("Civitai answered 500.")), "Civitai answered 500.");
assert.equal(civitaiFailureText(null), "The Civitai lookup failed."); assert.equal(civitaiFailureText(null), "The Civitai lookup failed.");
}); });
test("parsing accepts an object at the size limit and rejects oversized or non-text bodies", () => {
const body = '{"name":"' + "x".repeat(MAX_CIVITAI_BYTES - 11) + '"}';
assert.equal(body.length, MAX_CIVITAI_BYTES);
assert.equal(parseCivitaiBody(body).name.length, MAX_CIVITAI_BYTES - 11);
assert.throws(() => parseCivitaiBody(body + " "), /too large/);
for (const value of [null, undefined, {}, 1]) {
assert.throws(() => parseCivitaiBody(value), /too large/);
}
for (const text of ["true", "false", "0", '"model"']) {
assert.throws(() => parseCivitaiBody(text), /not what we expected/);
}
assert.throws(() => parseCivitaiBody('{"id":'), SyntaxError);
});
test("numeric picture ratings take priority over legacy words and booleans", () => {
for (const level of [1, 2, "2"]) {
assert.equal(isMildPicture({ nsfwLevel: level, nsfw: "Mature" }), true);
}
for (const level of [4, 8, 16, 3]) {
assert.equal(isMildPicture({ nsfwLevel: level, nsfw: false }), false);
}
assert.equal(isMildPicture({ nsfw: false }), true);
assert.equal(isMildPicture({ nsfw: true }), false);
assert.equal(isMildPicture({ nsfwLevel: 0, nsfw: "SOFT" }), true);
assert.equal(isMildPicture(null), false);
});
test("picture selection skips malformed entries even when any rating is allowed", () => {
const still = { url: picture("still") };
const info = { images: [
null, {}, { url: 42 },
{ url: picture("clip"), type: "video", nsfwLevel: 1 },
{ url: "https://example.org/picture.jpeg", nsfwLevel: 1 },
still,
{ url: picture("later"), nsfwLevel: 1 },
] };
assert.equal(pickCivitaiPicture(info, true), still, "the first usable still wins without copying it");
assert.equal(pickCivitaiPicture(info), info.images[6], "an unrated still is skipped in mild mode");
for (const images of [null, {}, "pictures", []]) {
assert.equal(pickCivitaiPicture({ images }, true), null);
}
});
test("picture resizing drops query and fragment and refuses incomplete picture paths", () => {
assert.equal(smallPictureUrl(picture("a") + "?width=999&token=example#preview"),
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/0b1c2d3e/width=450/a.jpeg");
assert.equal(smallPictureUrl("https://image.civitai.com/a.jpeg"), null);
assert.equal(smallPictureUrl("https://image.civitai.com/"), null);
for (const url of [
"https://civitai.com.example.org/file.jpeg",
"https://civitai.com@other.example/file.jpeg",
"file:///tmp/file.jpeg", "not a url",
]) {
assert.equal(isCivitaiAddress(url), false, url);
}
});
-79
View File
@@ -145,82 +145,3 @@ test("restoreOutputLinks hands back only the links a replacement took", () => {
assert.equal(graph.links.get(saver.inputs[0].link).origin_id, 1); assert.equal(graph.links.get(saver.inputs[0].link).origin_id, 1);
assert.equal(preview.inputs[0].link, untouched, "a link that was never taken stays as it is"); assert.equal(preview.inputs[0].link, untouched, "a link that was never taken stays as it is");
}); });
test("lookupLink keeps link id zero and tolerates absent tables and stale ids", () => {
const link = { id: 0 };
for (const links of [new Map([[0, link]]), { 0: link }]) {
assert.equal(lookupLink({ links }, 0), link);
assert.equal(lookupLink({ links }, 99), null);
assert.equal(lookupLink({ links }, undefined), null);
}
assert.equal(lookupLink({}, 0), null);
});
test("snapshotLinks skips stale links in an older plain-object graph", () => {
const graph = { links: {
0: { origin_id: 1, origin_slot: 2, target_id: 5, target_slot: 0 },
8: { origin_id: 5, origin_slot: 1, target_id: 9, target_slot: 3 },
} };
const node = {
inputs: [{ name: "image", link: 0 }, { name: "empty", link: null }, { name: "stale", link: 99 }],
outputs: [{ name: "unused" }, { name: "mask", links: [99, 8] }, { name: "stale", links: [77] }],
};
const snapshot = snapshotLinks(node, graph);
assert.deepEqual(snapshot, {
inputs: [{ name: "image", originId: 1, originSlot: 2 }],
outputs: [{ name: "mask", slot: 1, targets: [{ nodeId: 9, slot: 3 }] }],
});
graph.links[0].origin_slot = 4;
graph.links[8].target_slot = 0;
node.inputs[0].name = "changed";
assert.equal(snapshot.inputs[0].originSlot, 2, "the snapshot owns its slot values");
assert.equal(snapshot.inputs[0].name, "image");
assert.equal(snapshot.outputs[0].targets[0].slot, 3);
assert.deepEqual(snapshotLinks({}, graph), { inputs: [], outputs: [] });
});
test("linkSlots refuses redirects outside the target's inputs before calling connectSlots", () => {
const { makeNode } = makeGraph();
const source = makeNode(1, [], ["out"]);
const sink = makeNode(2, ["in"], []);
source.connectSlots = () => assert.fail("a refused redirect must not connect");
for (const redirect of [null, false, "0", -1, 4, NaN]) {
sink.onBeforeConnectInput = function (slot, requested) {
assert.equal(this, sink);
assert.equal(slot, 0);
assert.equal(requested, 0);
return redirect;
};
assert.equal(linkSlots(source, 0, sink, 0), null, String(redirect));
}
});
test("restoreOutputLinks skips missing targets and reconnects a disconnected input", () => {
const { graph, makeNode } = makeGraph();
const original = makeNode(1, [], ["image", "mask"]);
const sink = makeNode(2, ["mask"], []);
restoreOutputLinks(original, [{ slot: 1, targets: [
{ nodeId: 99, slot: 0 }, { nodeId: 2, slot: 0 }, { nodeId: 2, slot: 99 },
] }], graph);
const link = lookupLink(graph, sink.inputs[0].link);
assert.equal(link.origin_id, original.id);
assert.equal(link.origin_slot, 1);
assert.equal(graph.links.size, 1);
});
test("restoreOutputLinks restores the output slot even when the source node is unchanged", () => {
const { graph, makeNode } = makeGraph();
const original = makeNode(1, [], ["first image", "second image"]);
const sink = makeNode(2, ["image"], []);
linkSlots(original, 1, sink, 0);
const { outputs } = snapshotLinks(original, graph);
linkSlots(original, 0, sink, 0);
restoreOutputLinks(original, outputs, graph);
const restored = lookupLink(graph, sink.inputs[0].link);
assert.equal(restored.origin_id, original.id);
assert.equal(restored.origin_slot, 1);
assert.deepEqual(original.outputs[0].links, []);
assert.deepEqual(original.outputs[1].links, [restored.id]);
assert.equal(graph.links.size, 1);
});
-88
View File
@@ -4,7 +4,6 @@ import test from "node:test";
import { import {
ensureNodeMinHeight, ensureNodeMinHeight,
fillNodeHeight, fillNodeHeight,
growToNodeFloor,
holdNodeMinWidth, holdNodeMinWidth,
holdNodeMinHeight, holdNodeMinHeight,
holdVueNodeMinWidth, holdVueNodeMinWidth,
@@ -320,90 +319,3 @@ test("a new node hugs its floor and a loading workflow keeps its saved height",
assert.equal(nodeHeightAfterCardChange({ floor: 469, current: 700, change: "x" }), 700); assert.equal(nodeHeightAfterCardChange({ floor: 469, current: 700, change: "x" }), 700);
assert.equal(nodeHeightAfterCardChange(), 0); assert.equal(nodeHeightAfterCardChange(), 0);
}); });
test("a failed width probe restores the widget and allows the next measurement", () => {
const own = () => ({ minWidth: 320, minHeight: 80 });
const widget = { computeLayoutSize: own };
assert.equal(measureLayoutWidthPadding({ computeSize() { throw new Error("not laid out"); } }, widget), null);
assert.equal(widget.computeLayoutSize, own);
assert.equal(measureLayoutWidthPadding(frontendNode([widget]), widget), 104);
assert.equal(widget.computeLayoutSize, own);
});
test("a width probe cannot recursively start another probe", () => {
const widget = { computeLayoutSize: () => ({ minWidth: 320, minHeight: 80 }) };
const own = widget.computeLayoutSize;
const node = {
computeSize() {
assert.equal(measureLayoutWidthPadding(this, widget), null);
return [widget.computeLayoutSize().minWidth + 80, 200];
},
};
assert.equal(measureLayoutWidthPadding(node, widget), 80);
assert.equal(widget.computeLayoutSize, own);
});
test("growing to a changing width floor preserves height and leaves wider nodes alone", () => {
const sizes = [];
const dirty = [];
const host = { style: {} };
const node = {
size: [240, 480],
setSize(size) { sizes.push(size); this.size = size; },
setDirtyCanvas(...args) { dirty.push(args); },
};
const widget = { node, element: { closest: () => host } };
let floor = 320;
holdNodeMinWidth(widget, () => floor);
assert.equal(growToNodeFloor(widget, 1), true);
assert.deepEqual(node.size, [320, 480]);
assert.equal(host.style.minWidth, "320px");
assert.deepEqual(dirty, [[true, true]]);
assert.equal(growToNodeFloor(widget, 1), false);
floor = 280;
assert.equal(growToNodeFloor(widget, 1), false);
assert.equal(host.style.minWidth, "280px", "the CSS floor can shrink without shrinking the node");
floor = 400;
assert.equal(growToNodeFloor(widget, 1), true);
assert.deepEqual(sizes, [[320, 480], [400, 480]]);
node.size[0] = NaN;
assert.equal(growToNodeFloor(widget, 1), false);
assert.equal(growToNodeFloor({ node: { size: [100, 100] } }), false);
assert.equal(growToNodeFloor(null), false);
});
test("growing a node measures its floor again on the next frame with a bounded retry", () => {
const saved = globalThis.requestAnimationFrame;
const frames = [];
globalThis.requestAnimationFrame = (callback) => { frames.push(callback); };
try {
let floor = 320;
const node = { size: [240, 480], setSize(size) { this.size = size; } };
const widget = { node };
holdNodeMinWidth(widget, () => floor);
assert.equal(growToNodeFloor(widget), true);
assert.equal(frames.length, 1);
floor = 360;
frames.shift()();
assert.deepEqual(node.size, [360, 480]);
assert.equal(frames.length, 0, "the second pass must not schedule another frame");
} finally {
if (saved === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = saved;
}
});
test("dynamic panel floors sanitize invalid sizes and retain unrelated options", () => {
let width = "240";
let height = "160";
const widget = { options: { serialize: false } };
fillNodeHeight(widget, { minWidth: () => width, minHeight: () => height });
assert.deepEqual(widget.computeLayoutSize(), { minWidth: 240, minHeight: 160 });
for (const invalid of [-4, NaN, Infinity, undefined, "wide"]) {
width = invalid;
height = invalid;
assert.deepEqual(widget.computeLayoutSize(), { minWidth: 0, minHeight: 0 });
}
assert.equal(widget.options.serialize, false);
assert.equal(fillNodeHeight(null), null);
});
-68
View File
@@ -214,71 +214,3 @@ test("a number box reads a typed sum only where a plain number fails", () => {
const source = readFileSync(join(ROOT, "js", "shared", "scrub_input.mjs"), "utf-8"); const source = readFileSync(join(ROOT, "js", "shared", "scrub_input.mjs"), "utf-8");
assert.match(source, /Number\.isFinite\(number\) \? number : sumValue\(input\.value\)/); assert.match(source, /Number\.isFinite\(number\) \? number : sumValue\(input\.value\)/);
}); });
test("formatting keeps significant digits while trimming only fractional zeroes", () => {
for (const [value, options, expected] of [
[undefined, {}, ""],
["12.340", { decimals: 3, minDecimals: 0 }, "12.34"],
[-120, { decimals: 3, minDecimals: 0 }, "-120"],
[-1.2346, { decimals: 3, minDecimals: 1 }, "-1.235"],
[0.0001, { decimals: 4, minDecimals: 0 }, "0.0001"],
[12, { decimals: -2 }, "12"],
[1, { decimals: 2, minDecimals: -1 }, "1"],
]) {
assert.equal(formatScrubValue(value, options), expected);
}
});
test("invalid scrub values use a finite floor or zero before rounding", () => {
for (const value of [undefined, NaN, Infinity, -Infinity, "not a number"]) {
assert.equal(quantizeScrubValue(value), 0);
assert.equal(quantizeScrubValue(value, { min: -2, max: 4 }), -2);
}
assert.equal(quantizeScrubValue("2.718", { decimals: 2 }), 2.72);
assert.equal(quantizeScrubValue(-10, { min: -2, max: 4 }), -2);
assert.equal(quantizeScrubValue(10, { min: -2, max: 4 }), 4);
});
test("snapped steps cross zero and take several steps from an off-grid value", () => {
for (const [start, count, expected] of [
[-9, 1, -8], [-9, -1, -16], [-9, 3, 8],
[9, -3, -8], [9, 3, 32], [0, -1, -8], [9, 0, 9],
]) {
assert.equal(steppedValue(start, count, 8, true), expected, `${start}, ${count}`);
}
assert.equal(steppedValue("9", -2, 8), -7);
});
test("gesture boundaries accept equal diagonal travel only past the dead zone", () => {
for (const direction of [-1, 1]) {
assert.equal(isScrubGesture(direction * SCRUB_DEAD_ZONE, 0), false);
assert.equal(isScrubGesture(direction * 4, -4), true);
assert.equal(isScrubGesture(direction * 4, 5), false);
}
});
test("Shift slows dragging when the supplied fine step is equal or larger", () => {
const delta = SCRUB_DEAD_ZONE + SCRUB_PIXELS_PER_STEP * SCRUB_SLOW_FACTOR * 2;
for (const fineStep of [null, 1, 2]) {
const options = { step: 1, fineStep, decimals: 0 };
assert.equal(scrubbedValue(10, delta, true, options), 12);
assert.equal(scrubbedValue(10, -delta, true, options), 8);
assert.equal(scrubbedValue(10, delta, false, options), 18);
}
});
test("step precision accepts negative and string increments and rounds at six decimals", () => {
for (const [step, expected] of [[-0.025, 3], ["0.000001", 6], [0.1234567, 6], [0, 0], [undefined, 0]]) {
assert.equal(stepDecimals(step), expected, String(step));
}
});
test("typed sums associate division and subtraction left to right", () => {
assert.equal(sumValue("24/4/2"), 3);
assert.equal(sumValue("10-3-2"), 5);
assert.equal(sumValue("20%6*2"), 4);
assert.equal(sumValue("\t-(2 + +3) * -2\t"), 10);
assert.equal(sumValue("1" + " ".repeat(199)), 1);
assert.equal(sumValue("1" + " ".repeat(200)), null);
assert.equal(sumValue("1/(2-2)"), null);
});
+26
View File
@@ -20,6 +20,7 @@ from nodes._h3_prompt_helpers import (
clean_words, clean_words,
find_beats, find_beats,
join_sections, join_sections,
next_label,
snap_cuts, snap_cuts,
split_sections, split_sections,
write_prompt, write_prompt,
@@ -87,6 +88,31 @@ def count_moments(line):
return 1 + len(re.findall(r"\bAt \d\d:", line)) return 1 + len(re.findall(r"\bAt \d\d:", line))
class PromptTextHelperTests(unittest.TestCase):
def test_sections_round_trip_unicode_and_windows_newlines(self):
parts = split_sections(SIX)
parts["summary"] = '[reference generation] Café 雨 — "stay."'
parts["detailed_description"] += "\n\nA sign reads: café."
text = join_sections(parts)
self.assertEqual(split_sections(text), parts)
self.assertEqual(split_sections(text.replace("\n", "\r\n")), parts)
def test_incomplete_sections_are_not_mistaken_for_a_complete_prompt(self):
text = SIX.replace("non_diegetic_music:", "music:")
self.assertIsNone(split_sections(text))
def test_next_label_uses_the_highest_number_of_the_requested_kind(self):
text = "<Audio 2> <Audio 9> <Audio 2> <Picture 30> <Audio 4>"
self.assertEqual(next_label(text, "Audio"), 10)
self.assertEqual(next_label(text, "Video"), 1)
def test_blank_words_and_punctuation_stay_well_formed(self):
for text, expected in ((None, ""), ("\t\n ", ""), ("<>", ""),
(" café\n 雨! ", "café 雨!"), ("go.", "go.")):
with self.subTest(text=text):
self.assertEqual(clean_words(text), expected)
class ReadingTests(unittest.TestCase): class ReadingTests(unittest.TestCase):
def test_every_line_is_a_moment_in_the_order_typed(self): def test_every_line_is_a_moment_in_the_order_typed(self):
self.assertEqual(moments(LINES), ["She lowers the camera", "She looks at us and smiles", "She raises the camera again and a bright flash fires"]) self.assertEqual(moments(LINES), ["She lowers the camera", "She looks at us and smiles", "She raises the camera again and a bright flash fires"])
+40
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import math
import sys import sys
import unittest import unittest
@@ -79,10 +80,37 @@ class EvaluateExpressionTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "parsed"): with self.assertRaisesRegex(ValueError, "parsed"):
evaluate_expression("1 +") evaluate_expression("1 +")
def test_non_finite_results_are_refused(self):
for expression, variables in (
("1e309", None),
("1e308 * 2", None),
("a + 1", {"a": float("inf")}),
("a", {"a": float("nan")}),
):
with self.subTest(expression=expression, variables=variables):
with self.assertRaisesRegex(ValueError, "non-finite"):
evaluate_expression(expression, variables)
def test_negative_division_and_power_precedence(self):
for expression, expected in (("-7 // 2", -4), ("-7 % 2", 1),
("-2 ** 2", -4), ("2 ** 3 ** 2", 512)):
with self.subTest(expression=expression):
self.assertEqual(evaluate_expression(expression), expected)
class ExpressionSafetyTests(unittest.TestCase): class ExpressionSafetyTests(unittest.TestCase):
"""The expression is attacker-controlled; everything here must be refused.""" """The expression is attacker-controlled; everything here must be refused."""
def test_non_text_input_is_refused(self):
for value in (None, 12, True, ["1 + 2"]):
with self.subTest(value=value), self.assertRaisesRegex(ValueError, "as text"):
evaluate_expression(value)
def test_bitwise_operators_and_assignment_are_refused(self):
for expression in ("1 << 2", "4 >> 1", "1 | 2", "1 & 2", "1 ^ 2", "~1", "(a := 1)"):
with self.subTest(expression=expression), self.assertRaises(ValueError):
evaluate_expression(expression)
def test_rejects_import(self): def test_rejects_import(self):
with self.assertRaisesRegex(ValueError, "__import__"): with self.assertRaisesRegex(ValueError, "__import__"):
evaluate_expression("__import__('os')") evaluate_expression("__import__('os')")
@@ -150,6 +178,18 @@ class ExpressionSafetyTests(unittest.TestCase):
class RoundHalfAwayTests(unittest.TestCase): class RoundHalfAwayTests(unittest.TestCase):
def test_values_immediately_below_a_half_do_not_round_up(self):
below = math.nextafter(0.5, 0.0)
for value in (below, -below):
with self.subTest(value=value):
self.assertEqual(round_half_away_from_zero(value), 0)
def test_large_exact_integers_are_unchanged(self):
# Adding 0.5 to an odd integer here rounds it to the next float.
for value in (2**52 + 1, -(2**52 + 1), 2**53, -(2**53)):
with self.subTest(value=value):
self.assertEqual(round_half_away_from_zero(float(value)), value)
def test_halves_round_away_from_zero(self): def test_halves_round_away_from_zero(self):
self.assertEqual(round_half_away_from_zero(2.5), 3) self.assertEqual(round_half_away_from_zero(2.5), 3)
self.assertEqual(round_half_away_from_zero(-2.5), -3) self.assertEqual(round_half_away_from_zero(-2.5), -3)
+39
View File
@@ -124,6 +124,45 @@ class TempFolders(unittest.TestCase):
class InputFolderTests(TempFolders): class InputFolderTests(TempFolders):
def test_symlink_chains_stay_inside_the_selected_folder(self):
for kind in ("input", "output", "temp"):
root = getattr(self, kind)
target = self.touch(root / "target.png")
(root / "first.png").symlink_to(target)
(root / "second.png").symlink_to(root / "first.png")
for resolve in (resolve_input_path, resolve_server_file):
with self.subTest(folder=kind, resolver=resolve.__name__):
self.assertEqual(resolve(f"second.png [{kind}]"), target)
def test_similarly_named_sibling_and_cross_folder_links_are_refused(self):
sibling = self.root / "input-backup"
sibling.mkdir()
target = self.touch(sibling / "pic.png")
(self.input / "sibling.png").symlink_to(target)
(self.input / "output.png").symlink_to(self.made)
for resolve in (resolve_input_path, resolve_server_file):
for name in ("sibling.png", "output.png"):
with self.subTest(name=name, resolver=resolve.__name__):
with self.assertRaisesRegex(ValueError, WHOLE_PATH_REFUSAL):
resolve(name)
def test_annotated_backslash_traversal_is_refused_before_disk_access(self):
for resolve in (resolve_input_path, resolve_server_file):
for kind in ("input", "output", "temp"):
for name in (f"sub\\..\\pic.png [{kind}]", f"..\\secret.png [{kind}]",
f"sub/../pic.png [{kind}]"):
with self.subTest(name=name, resolver=resolve.__name__):
with patch.object(Path, "resolve") as disk:
with self.assertRaisesRegex(ValueError, WHOLE_PATH_REFUSAL):
resolve(name)
disk.assert_not_called()
def test_directories_cannot_be_selected_as_files(self):
for resolve in (resolve_input_path, resolve_server_file):
with self.subTest(resolver=resolve.__name__):
with self.assertRaisesRegex(ValueError, "no longer"):
resolve("sub [input]")
def test_files_inside_the_input_folder_resolve(self): def test_files_inside_the_input_folder_resolve(self):
self.assertEqual(resolve_input_path("pic.png"), self.inside) self.assertEqual(resolve_input_path("pic.png"), self.inside)
self.assertEqual(resolve_input_path("sub/pic.png"), self.nested) self.assertEqual(resolve_input_path("sub/pic.png"), self.nested)
+137
View File
@@ -0,0 +1,137 @@
"""Exercise preview handlers offline, with decoding replaced by small fakes."""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
from aiohttp import web
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes import _media_helpers as media
class QueryNumberTests(unittest.TestCase):
def test_missing_and_malformed_values_use_the_default(self):
for parser, default in ((media._query_int, 17), (media._query_float, 2.5)):
for query in ({}, {"value": ""}, {"value": "words"}, {"value": None}):
with self.subTest(parser=parser.__name__, query=query):
self.assertEqual(parser(types.SimpleNamespace(query=query), "value", default), default)
def test_non_finite_numbers_use_the_default(self):
for parser, default in ((media._query_int, 17), (media._query_float, 2.5)):
for value in ("nan", "NaN", "inf", "-inf", "1e999"):
with self.subTest(parser=parser.__name__, value=value):
request = types.SimpleNamespace(query={"value": value})
self.assertEqual(parser(request, "value", default), default)
def test_finite_decimal_and_exponent_values_keep_existing_conversion(self):
request = types.SimpleNamespace(query={"value": " -1.25e1 "})
self.assertEqual(media._query_int(request, "value", 0), -12)
self.assertEqual(media._query_float(request, "value", 0.0), -12.5)
class VideoRouteTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
# Keep the worker boundary awaitable without starting decoder threads.
async def run_worker(executor, function, *args):
return function(*args)
patcher = patch.object(asyncio.get_running_loop(), "run_in_executor", run_worker)
patcher.start()
self.addCleanup(patcher.stop)
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.dirs = {kind: self.root / kind for kind in ("input", "output", "temp")}
for root in self.dirs.values():
root.mkdir()
(root / "clip.mp4").write_bytes(b"placeholder")
folders = types.SimpleNamespace(**{
f"get_{kind}_directory": lambda root=root: str(root)
for kind, root in self.dirs.items()
})
patcher = patch.object(media, "folder_paths", folders)
patcher.start()
self.addCleanup(patcher.stop)
# Only collect route decorators; no server or listener is started.
instance = types.SimpleNamespace(routes=web.RouteTableDef())
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(instance=instance)
with patch.dict(sys.modules, {"server": server}):
media.register_video_routes()
self.handlers = {route.path: route.handler for route in instance.routes}
async def call(self, endpoint, **query):
request = types.SimpleNamespace(query=query)
return await self.handlers[f"/ausboss/transform/video/{endpoint}"](request)
async def test_annotated_names_select_each_allowed_root(self):
for kind, root in self.dirs.items():
with self.subTest(folder=kind):
with patch.object(media, "cached_video_metadata", return_value={"fps": 24}) as worker:
response = await self.call("metadata", video=f"clip.mp4 [{kind}]")
self.assertEqual(response.status, 200)
self.assertEqual(json.loads(response.text), {"fps": 24})
worker.assert_called_once_with(root / "clip.mp4")
async def test_invalid_sources_never_reach_preview_workers(self):
outside = self.root / "outside.mp4"
outside.write_bytes(b"keep")
(self.dirs["input"] / "escape.mp4").symlink_to(outside)
sources = (
{"video": "../outside.mp4"},
{"video": str(outside)},
{"video": "escape.mp4"},
{"source_mode": "local path", "local_path": "../outside.mp4 [output]"},
{"source_mode": "unexpected", "video": "clip.mp4"},
)
for endpoint, name in (("metadata", "cached_video_metadata"),
("frame", "cached_preview"), ("storyboard", "storyboard_payload")):
for query in sources:
with self.subTest(endpoint=endpoint, query=query):
with patch.object(media, name) as worker:
response = await self.call(endpoint, **query)
self.assertEqual(response.status, 400)
self.assertIn("error", json.loads(response.text))
self.assertNotIn(str(self.root), response.text)
worker.assert_not_called()
async def test_non_finite_frame_parameters_are_normalized_before_decoding(self):
with patch.object(media, "cached_preview", return_value=(b"jpeg", 0, 0.0)) as worker:
response = await self.call(
"frame", video="clip.mp4", frame_index="inf", frame_time="nan",
max_width="1e999", max_height="-inf",
)
self.assertEqual(response.status, 200, response.text)
worker.assert_called_once_with(
self.dirs["input"] / "clip.mp4", "frame index", 0, 0.0, 1600, 1600
)
self.assertEqual(response.body, b"jpeg")
async def test_decoder_errors_do_not_reveal_server_paths(self):
for endpoint, name in (("metadata", "cached_video_metadata"),
("frame", "cached_preview"), ("storyboard", "storyboard_payload")):
with self.subTest(endpoint=endpoint):
error = ValueError(f"Cannot decode '{self.dirs['input'] / 'clip.mp4'}'")
with patch.object(media, name, side_effect=error):
response = await self.call(endpoint, video="clip.mp4")
self.assertEqual(response.status, 400)
self.assertEqual(json.loads(response.text), {"error": "The media request failed."})
if __name__ == "__main__":
unittest.main()
+19
View File
@@ -36,6 +36,25 @@ PADS = (3, 5, 2, 4) # left, top, right, bottom
class MaskContractTests(unittest.TestCase): class MaskContractTests(unittest.TestCase):
def test_rgba_single_pixel_axes_allow_padding_larger_than_the_source(self):
for height, width in ((1, 1), (1, 3), (3, 1)):
colors = torch.tensor([[0.2, 0.4, 0.6, 0.25], [0.8, 0.6, 0.4, 0.75]])
image = colors[:, None, None, :].expand(2, height, width, 4).clone()
original = image.clone()
for mode in PAD_MODES:
with self.subTest(size=(height, width), mode=mode):
out, mask = pad_image(image, 4, 3, 5, 6, mode, backdrop_blur=0.0)
self.assertEqual(tuple(out.shape), (2, height + 9, width + 9, 4))
self.assertEqual(tuple(mask.shape), (2, height + 9, width + 9))
self.assertTrue(torch.equal(out[:, 3:3 + height, 4:4 + width], image))
expected_mask = torch.ones_like(mask)
expected_mask[:, 3:3 + height, 4:4 + width] = 0
self.assertTrue(torch.equal(mask, expected_mask))
self.assertTrue(torch.isfinite(out).all())
if mode != "color":
self.assertTrue(torch.allclose(out, colors[:, None, None, :].expand_as(out)))
self.assertTrue(torch.equal(image, original))
def test_every_mode_shares_the_geometry_and_mask_contract(self): def test_every_mode_shares_the_geometry_and_mask_contract(self):
image = rand_image(2, 16, 20, seed=1) image = rand_image(2, 16, 20, seed=1)
left, top, right, bottom = PADS left, top, right, bottom = PADS
+60
View File
@@ -184,6 +184,18 @@ class PlanResizeTests(unittest.TestCase):
class ResampleBatchTests(unittest.TestCase): class ResampleBatchTests(unittest.TestCase):
def test_rgba_and_single_pixel_axes_keep_frames_and_channels_separate(self):
for batch_size in (1, 3):
colors = torch.arange(batch_size * 4, dtype=torch.float32).reshape(batch_size, 1, 1, 4) / 12
for source_h, source_w in ((1, 7), (7, 1), (3, 5)):
# A strided view exercises both the PIL and torch paths.
image = colors.expand(batch_size, source_h, source_w * 2, 4)[:, :, ::2]
for mode in INTERPOLATION_MODES:
with self.subTest(batch=batch_size, source=(source_h, source_w), mode=mode):
out = resample_batch(image, 9, 5, mode)
self.assertEqual(tuple(out.shape), (batch_size, 5, 9, 4))
self.assertTrue(torch.allclose(out, colors.expand_as(out), atol=1e-6))
def test_every_mode_returns_the_requested_shape(self): def test_every_mode_returns_the_requested_shape(self):
image = rand_image(2, 16, 16, seed=1) image = rand_image(2, 16, 16, seed=1)
for mode in INTERPOLATION_MODES: for mode in INTERPOLATION_MODES:
@@ -257,6 +269,54 @@ def run_resize(image, mask=None, **overrides):
class ApplyResizeTests(unittest.TestCase): class ApplyResizeTests(unittest.TestCase):
def test_nearest_scaling_keeps_each_image_and_mask_pixel_together(self):
mask = torch.arange(30, dtype=torch.float32).reshape(2, 3, 5) / 30
image = torch.stack((mask, 1 - mask, mask / 2, mask), dim=-1)
out, out_mask, width, height = run_resize(
image, mask, width=9, height=5, keep_proportion="stretch"
)
# Nearest pixel centres on this 5x3 -> 9x5 resize, written out so
# the check does not reuse the resampling helper's geometry.
rows = [0, 0, 1, 2, 2]
cols = [0, 0, 1, 1, 2, 3, 3, 4, 4]
self.assertEqual((width, height), (9, 5))
self.assertTrue(torch.equal(out, image[:, rows][:, :, cols]))
self.assertTrue(torch.equal(out_mask, mask[:, rows][:, :, cols]))
def test_odd_center_crop_keeps_rgba_and_each_mask_aligned(self):
for batch_size in (1, 3):
values = torch.arange(batch_size * 5 * 8, dtype=torch.float32).reshape(batch_size, 5, 8)
mask = values / values.numel()
image = mask.unsqueeze(-1).expand(-1, -1, -1, 4).clone()
original = image.clone()
out, out_mask, width, height = run_resize(
image, mask, width=5, height=5, keep_proportion="cover_crop"
)
self.assertEqual((width, height), (5, 5))
# Three excess columns: one comes off the left, two off the right.
self.assertTrue(torch.equal(out, image[:, :, 1:6]))
self.assertTrue(torch.equal(out_mask, mask[:, :, 1:6]))
self.assertTrue(torch.equal(image, original))
def test_odd_padding_keeps_rgba_and_single_or_per_frame_masks(self):
image = torch.zeros((3, 3, 5, 4))
image[..., 3] = 0.25
for mask_batch in (1, 3):
mask = torch.arange(mask_batch * 15, dtype=torch.float32).reshape(mask_batch, 3, 5) / 45
original = mask.clone()
out, out_mask, width, height = run_resize(
image, mask, width=5, height=6, keep_proportion="pad", fill_color="#ff0000"
)
self.assertEqual((width, height), (5, 6))
self.assertEqual(tuple(out.shape), (3, 6, 5, 4))
self.assertEqual(tuple(out_mask.shape), (mask_batch, 6, 5))
self.assertTrue(torch.equal(out[:, 1:4], image))
self.assertTrue(torch.equal(out_mask[:, 1:4], mask))
for rows in (slice(0, 1), slice(4, 6)):
self.assertTrue(torch.equal(out[:, rows], torch.tensor([1., 0., 0., 1.]).expand_as(out[:, rows])))
self.assertTrue(torch.all(out_mask[:, rows] == 1))
self.assertTrue(torch.equal(mask, original))
def test_stretch_hits_the_exact_target_and_reports_it(self): def test_stretch_hits_the_exact_target_and_reports_it(self):
image = rand_image(2, 24, 32, seed=3) image = rand_image(2, 24, 32, seed=3)
out, mask, width, height = run_resize( out, mask, width, height = run_resize(
+1 -47
View File
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
import { import {
CARD_PADDING, GROUP_HEIGHT, ROW_GAP, ROW_HEIGHT, SECTION_HEIGHT, SLOT_OFFSET, SOCKETLESS_CHOICE, SOCKETLESS_TEXT, CARD_PADDING, GROUP_HEIGHT, ROW_GAP, ROW_HEIGHT, SECTION_HEIGHT, SLOT_OFFSET, SOCKETLESS_CHOICE, SOCKETLESS_TEXT,
cardHeight, choiceLabel, comboValues, commitWidgetValue, holdsUnknownValue, markSocketless, resetUnknownValues, rowHeight, rowInLook, rowKind, rowMuted, rowTops, cardHeight, choiceLabel, commitWidgetValue, holdsUnknownValue, markSocketless, resetUnknownValues, rowHeight, rowInLook, rowKind, rowMuted, rowTops,
scrubSteps, segmentFits, socketWidgetY, visibleRows, widgetTooltip, scrubSteps, segmentFits, socketWidgetY, visibleRows, widgetTooltip,
} from "../js/shared/widget_card_math.mjs"; } from "../js/shared/widget_card_math.mjs";
@@ -418,49 +418,3 @@ test("a choice's words come from the row: a map, or a function for lists only th
// The pill or the list is chosen from the words a person reads, so two long file names still make a short pill. // The pill or the list is chosen from the words a person reads, so two long file names still make a short pill.
assert.equal(rowKind({ widget: "fine_edges", labels: row.labels }, { type: "combo", options: { values: ["off", "vitmatte-small-composition-1k/model.safetensors"] } }), "segment"); assert.equal(rowKind({ widget: "fine_edges", labels: row.labels }, { type: "combo", options: { values: ["off", "vitmatte-small-composition-1k/model.safetensors"] } }), "segment");
}); });
test("dynamic combo values receive the widget and preserve numeric and boolean choices", () => {
const values = [0, false, "0"];
const widget = { type: "combo", value: 0, options: { values: (own) => {
assert.equal(own, widget);
return values;
} } };
assert.equal(comboValues(widget), values);
assert.equal(holdsUnknownValue(widget), false);
widget.value = false;
assert.equal(holdsUnknownValue(widget), false);
widget.value = true;
assert.equal(holdsUnknownValue(widget), true);
for (const value of [undefined, null, "choices", {}]) {
assert.deepEqual(comboValues({ options: { values: () => value } }), []);
}
assert.deepEqual(comboValues(null), []);
});
test("choice labels keep explicit empty and numeric labels and use values only as a fallback", () => {
const row = { labels: { off: "", zero: 0, missing: null } };
assert.equal(choiceLabel(row, "off"), "");
assert.equal(choiceLabel(row, "zero"), 0);
assert.equal(choiceLabel(row, "missing"), "missing");
assert.equal(choiceLabel({ labels: () => undefined }, 0), "0");
assert.equal(choiceLabel(null, false), "false");
});
test("segmented choices include the exact label-length limit and reject the next character", () => {
assert.equal(segmentFits(["a".repeat(13), "b".repeat(13)]), true);
assert.equal(segmentFits(["a".repeat(13), "b".repeat(14)]), false);
assert.equal(segmentFits([0, false]), true);
assert.equal(segmentFits([]), false);
assert.equal(segmentFits(null), false);
});
test("scrub steps honor legacy increments, explicit precision and the pixel-range boundary", () => {
assert.deepEqual(scrubSteps(), { step: 0.1, fineStep: 0.01, decimals: 2 });
assert.deepEqual(scrubSteps({}, false), { step: 1, fineStep: 1, decimals: 0 });
assert.deepEqual(scrubSteps({ step: 0.5 }), { step: 0.5, fineStep: 0.05, decimals: 2 });
assert.deepEqual(scrubSteps({ step: 5, step2: 0.25, precision: 3, min: 0, max: 2 }),
{ step: 0.25, fineStep: 0.25, decimals: 3 });
assert.equal(scrubSteps({ min: 0, max: 2048 }, false, true).step, 1);
assert.equal(scrubSteps({ min: 0, max: 2049 }, false, true).step, 8);
assert.equal(scrubSteps({}, false, true).step, 1, "an unknown range is not assumed to be a wide pixel size");
});