Muting both image loaders is a legal way to run this — the optional inputs vanish from the prompt and core reads that as text-to-video — but with no image to match, the canvas came from ratio arithmetic and landed on 768x1312. Legal, and 41 rows short of the canvas the model itself ships with, for no reason anyone could explain. Each match tier now names its own portrait fallback: 768x1344, 480x832, 352x608. Tests assert all three are legal, portrait, and that full quality is the model's own default rather than whatever the arithmetic produces.
376 lines
20 KiB
Python
376 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Everything about this pack that can be checked without a GPU.
|
|
|
|
python tests/test_offline.py
|
|
|
|
No pytest, no torch, no ComfyUI: it exercises the tables, the snapping, the
|
|
graph walk and the guards, which is where the bugs that reach a user actually
|
|
live. The sampling call is not covered here — that needs the model, and a real
|
|
render is the only honest test of it.
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from simpleshot import _core # noqa: E402
|
|
from simpleshot.h3_first_last import ( # noqa: E402
|
|
NODE_CLASS_MAPPINGS, SimpleShotH3FirstLast)
|
|
|
|
PASS = []
|
|
FAIL = []
|
|
|
|
|
|
def check(name, condition, detail=""):
|
|
(PASS if condition else FAIL).append(name)
|
|
print("%-4s %s%s" % ("ok" if condition else "FAIL", name,
|
|
"" if condition or not detail else " <- " + detail))
|
|
|
|
|
|
def raises(fn, needle):
|
|
try:
|
|
fn()
|
|
except Exception as exc:
|
|
return needle in str(exc)
|
|
return False
|
|
|
|
|
|
class FakeImage:
|
|
"""Only `.shape` is read, so only `.shape` is provided."""
|
|
def __init__(self, w, h):
|
|
self.shape = (1, h, w, 3)
|
|
|
|
|
|
# ── the tables must already be legal, so core never has to correct them ──────
|
|
for label, frames in _core.DURATIONS.items():
|
|
check("duration on the 17k+5 grid: %s" % label, frames % 17 == 5,
|
|
"%d %% 17 == %d" % (frames, frames % 17))
|
|
check("duration snaps to itself: %s" % label,
|
|
_core.align_frame_count(frames) == frames)
|
|
|
|
# The canvas rules are the ones core does NOT enforce on this path, so every
|
|
# entry has to be legal on its own. An off-multiple entry here would reach the
|
|
# user as a tensor-shape crash from deep inside the sampler.
|
|
for label, (w, h) in _core.RESOLUTIONS.items():
|
|
ok, why = _core.canvas_is_legal(w, h)
|
|
check("canvas is legal without core's help: %s" % label, ok, why)
|
|
check("canvas is a multiple of 32: %s" % label, w % 32 == 0 and h % 32 == 0)
|
|
check("canvas within the area the model works in: %s" % label,
|
|
w * h <= _core.MAX_PIXELS, "%.2f MP" % (w * h / 1e6))
|
|
|
|
check("defaults are in their menus",
|
|
_core.DURATION_DEFAULT in _core.DURATIONS
|
|
and _core.RESOLUTION_DEFAULT in _core.RESOLUTION_NAMES)
|
|
check("the menu offers both ways of choosing a canvas",
|
|
set(_core.RESOLUTION_NAMES) == set(_core.MATCH_TARGETS) | set(_core.RESOLUTIONS))
|
|
check("matching is the default, since it is the one that cannot distort",
|
|
_core.RESOLUTION_DEFAULT in _core.MATCH_TARGETS)
|
|
check("the menu has a cheap end (a full-quality-only menu was the first mistake)",
|
|
min(w * h for w, h in _core.RESOLUTIONS.values()) < 250_000,
|
|
"smallest fixed size is %.2f MP"
|
|
% (min(w * h for w, h in _core.RESOLUTIONS.values()) / 1e6))
|
|
|
|
# ── matching: the aspect comes from the frame, the size from the menu ────────
|
|
# The reference numbers are the official H3 template's own table: 0.2 MP 16:9 ->
|
|
# 608x352 and 0.4 MP -> ~864x480. Landing on them is the check that this is the
|
|
# same arithmetic and not an invention.
|
|
for label, (sw, sh) in (("16:9", (960, 544)), ("9:16 phone", (1080, 1920)),
|
|
("square", (1024, 1024)), ("extreme wide", (2560, 640)),
|
|
("tall strip", (640, 2560))):
|
|
for option, budget in _core.MATCH_TARGETS.items():
|
|
_f, w, h, notes = _core.resolve_shape(
|
|
"5 s (124 frames)", option, FakeImage(sw, sh), None)
|
|
ok, why = _core.canvas_is_legal(w, h)
|
|
check("matched canvas is legal: %s / %s" % (label, option.split("-")[1].strip()),
|
|
ok, why)
|
|
src_ratio, out_ratio = sw / sh, w / h
|
|
# 32-pixel rounding is coarse at draft sizes, so the tolerance scales.
|
|
tol = 0.12 if budget <= 250_000 else 0.06
|
|
check("matched canvas keeps the aspect: %s / %.1f MP"
|
|
% (label, budget / 1e6),
|
|
abs(src_ratio - out_ratio) / src_ratio < tol
|
|
or w * h >= _core.MAX_PIXELS * 0.95,
|
|
"%dx%d (%.3f) from %dx%d (%.3f)" % (w, h, out_ratio, sw, sh, src_ratio))
|
|
check("matched canvas says what it did: %s / %.1f MP" % (label, budget / 1e6),
|
|
any("matched to your" in n for n in notes), str(notes))
|
|
|
|
_f, w, h, notes = _core.resolve_shape(
|
|
"5 s (124 frames)", "match my image - draft (~0.2 MP)", FakeImage(960, 544), None)
|
|
check("0.2 MP 16:9 lands on the template's own 608x352", (w, h) == (608, 352),
|
|
"%dx%d" % (w, h))
|
|
check("a matched canvas never exceeds the model's area",
|
|
_core.canvas_from_image(4000, 500, 1_000_000)[0]
|
|
* _core.canvas_from_image(4000, 500, 1_000_000)[1] <= _core.MAX_PIXELS,
|
|
str(_core.canvas_from_image(4000, 500, 1_000_000)))
|
|
check("every match option has a text-to-video fallback",
|
|
set(_core.MATCH_FALLBACKS) == set(_core.MATCH_TARGETS))
|
|
for option, (fw, fh) in _core.MATCH_FALLBACKS.items():
|
|
ok, why = _core.canvas_is_legal(fw, fh)
|
|
check("text-to-video fallback is legal: %s" % option.split("-")[1].strip(), ok, why)
|
|
check("text-to-video fallback is portrait: %s" % option.split("-")[1].strip(),
|
|
fh > fw, "%dx%d" % (fw, fh))
|
|
check("full-quality text-to-video uses the model's own canvas",
|
|
_core.MATCH_FALLBACKS["match my image - full quality (~1.0 MP)"] == (768, 1344))
|
|
check("the fallback is used, not the ratio arithmetic",
|
|
_core.resolve_shape("5 s (124 frames)",
|
|
"match my image - full quality (~1.0 MP)")[1:3] == (768, 1344))
|
|
check("matching falls back and SAYS SO when there is no image",
|
|
any("no image to match" in n for n in _core.resolve_shape(
|
|
"5 s (124 frames)", "match my image - fast (~0.4 MP)", None, None)[3]))
|
|
check("matching reads the last frame when only that is given",
|
|
any("matched to your" in n for n in _core.resolve_shape(
|
|
"5 s (124 frames)", "match my image - fast (~0.4 MP)",
|
|
None, FakeImage(960, 544))[3]))
|
|
check("smaller budgets really are smaller",
|
|
_core.canvas_from_image(960, 544, 200_000)[0]
|
|
< _core.canvas_from_image(960, 544, 1_000_000)[0])
|
|
|
|
# ── snapping reports itself ──────────────────────────────────────────────────
|
|
frames, w, h, notes = _core.resolve_shape("5 s (124 frames)", "portrait 768 x 1344")
|
|
check("resolve_shape returns the asked values", (frames, w, h) == (124, 768, 1344),
|
|
str((frames, w, h)))
|
|
check("resolve_shape reports no adjustment for a legal pair", notes == [], str(notes))
|
|
check("align_frame_count snaps up", _core.align_frame_count(100) == 107,
|
|
str(_core.align_frame_count(100)))
|
|
check("align_frame_count floors at 5", _core.align_frame_count(1) == 5)
|
|
check("an odd canvas is called illegal, not corrected",
|
|
_core.canvas_is_legal(700, 1280)[0] is False)
|
|
check("an oversized canvas is called illegal",
|
|
_core.canvas_is_legal(1344, 1344)[0] is False)
|
|
check("the illegal-canvas reason says what is wrong",
|
|
"multiple of 32" in _core.canvas_is_legal(700, 1280)[1])
|
|
|
|
# ── a linked value bypasses the menu, so it is checked in code ───────────────
|
|
check("unknown duration is refused by name",
|
|
raises(lambda: _core.resolve_shape("7 s", "portrait 768 x 1344"),
|
|
"duration='7 s'"))
|
|
check("unknown resolution is refused by name",
|
|
raises(lambda: _core.resolve_shape("5 s (124 frames)", "4k"), "resolution='4k'"))
|
|
check("refusals carry the pack name",
|
|
raises(lambda: _core.resolve_shape("7 s", "portrait 768 x 1344"), "[SimpleShot]"))
|
|
|
|
# ── walking back to the loader through the shapes a real graph takes ─────────
|
|
DIRECT = {
|
|
"nodes": [
|
|
{"id": 1, "type": "UNETLoader",
|
|
"widgets_values": ["minimax_h3_fl2va_pruned_fp8_scaled.safetensors", "default"]},
|
|
{"id": 2, "type": "SimpleShotH3FirstLast",
|
|
"inputs": [{"name": "model", "link": 10}]},
|
|
],
|
|
"links": [[10, 1, 0, 2, 0, "MODEL"]],
|
|
}
|
|
check("walk back: direct link",
|
|
_core._walk_back(DIRECT, 2, "model")
|
|
== ("UNETLoader", "minimax_h3_fl2va_pruned_fp8_scaled.safetensors"))
|
|
|
|
REROUTED = {
|
|
"nodes": [
|
|
{"id": 1, "type": "UNETLoader", "widgets_values": ["h3_fl2va.safetensors"]},
|
|
{"id": 5, "type": "Reroute", "inputs": [{"name": "", "link": 10}]},
|
|
{"id": 2, "type": "SimpleShotH3FirstLast", "inputs": [{"name": "model", "link": 11}]},
|
|
],
|
|
"links": [[10, 1, 0, 5, 0, "MODEL"], [11, 5, 0, 2, 0, "MODEL"]],
|
|
}
|
|
check("walk back: through a reroute, whose input has no name",
|
|
_core._walk_back(REROUTED, 2, "model") == ("UNETLoader", "h3_fl2va.safetensors"),
|
|
str(_core._walk_back(REROUTED, 2, "model")))
|
|
check("walk back: two reroutes in a row",
|
|
_core._walk_back({
|
|
"nodes": [{"id": 1, "type": "UNETLoader", "widgets_values": ["h3_fl2va.safetensors"]},
|
|
{"id": 5, "type": "Reroute", "inputs": [{"name": "", "link": 10}]},
|
|
{"id": 6, "type": "Reroute", "inputs": [{"name": "", "link": 11}]},
|
|
{"id": 2, "type": "X", "inputs": [{"name": "model", "link": 12}]}],
|
|
"links": [[10, 1, 0, 5, 0, "MODEL"], [11, 5, 0, 6, 0, "MODEL"],
|
|
[12, 6, 0, 2, 0, "MODEL"]]}, 2, "model")
|
|
== ("UNETLoader", "h3_fl2va.safetensors"))
|
|
check("walk back: a cycle of reroutes cannot hang the guard",
|
|
_core._walk_back({
|
|
"nodes": [{"id": 5, "type": "Reroute", "inputs": [{"name": "", "link": 11}]},
|
|
{"id": 6, "type": "Reroute", "inputs": [{"name": "", "link": 10}]},
|
|
{"id": 2, "type": "X", "inputs": [{"name": "model", "link": 12}]}],
|
|
"links": [[10, 5, 0, 6, 0, "MODEL"], [11, 6, 0, 5, 0, "MODEL"],
|
|
[12, 6, 0, 2, 0, "MODEL"]]}, 2, "model") == (None, None))
|
|
|
|
BYPASSED = {
|
|
"nodes": [
|
|
{"id": 1, "type": "UNETLoader", "widgets_values": ["h3_ref2va.safetensors"]},
|
|
{"id": 5, "type": "LoraLoaderModelOnly", "mode": 4,
|
|
"inputs": [{"name": "model", "link": 10}], "widgets_values": ["x.safetensors", 1.0]},
|
|
{"id": 2, "type": "SimpleShotH3FirstLast", "inputs": [{"name": "model", "link": 11}]},
|
|
],
|
|
"links": [[10, 1, 0, 5, 0, "MODEL"], [11, 5, 0, 2, 0, "MODEL"]],
|
|
}
|
|
check("walk back: past a bypassed node to the real loader",
|
|
_core._walk_back(BYPASSED, 2, "model") == ("UNETLoader", "h3_ref2va.safetensors"),
|
|
str(_core._walk_back(BYPASSED, 2, "model")))
|
|
|
|
check("walk back: nothing connected", _core._walk_back(DIRECT, 2, "clip") == (None, None))
|
|
check("walk back: unknown node id", _core._walk_back(DIRECT, 99, "model") == (None, None))
|
|
check("walk back: empty workflow", _core._walk_back({}, 2, "model") == (None, None))
|
|
check("walk back: malformed links survive",
|
|
_core._walk_back({"nodes": [{"id": 2, "inputs": [{"name": "model", "link": 1}]}],
|
|
"links": [None, [1], "junk"]}, 2, "model") == (None, None))
|
|
|
|
|
|
# ── the family guard: hard when it knows, soft when it does not ──────────────
|
|
class FakeModel:
|
|
def __init__(self, name=None):
|
|
if name is not None:
|
|
self.model = type("M", (), {"h3_checkpoint_name": name})()
|
|
|
|
|
|
check("guard: fl2va checkpoint passes and is named",
|
|
"fl2va" in _core.checkpoint_note(FakeModel("MiniMax-H3-fl2va-Q8_0.gguf"), None, None))
|
|
check("guard: ref2va checkpoint is refused with an instruction",
|
|
raises(lambda: _core.checkpoint_note(FakeModel("MiniMax-H3-ref2va-curve.gguf"), None, None),
|
|
"wrong checkpoint"))
|
|
check("guard: the refusal names the file",
|
|
raises(lambda: _core.checkpoint_note(FakeModel("MiniMax-H3-ref2va-curve.gguf"), None, None),
|
|
"MiniMax-H3-ref2va-curve.gguf"))
|
|
check("guard: an unprovable name warns instead of refusing",
|
|
"unproven" in _core.checkpoint_note(FakeModel("my_model.safetensors"), None, None))
|
|
check("guard: no name at all admits it did not check",
|
|
"UNCHECKED" in _core.checkpoint_note(FakeModel(), None, None))
|
|
check("guard: reads the name out of the workflow when the object has none",
|
|
"fl2va" in _core.checkpoint_note(FakeModel(), {"workflow": DIRECT}, 2))
|
|
check("guard: refuses a ref2va file found in the workflow",
|
|
raises(lambda: _core.checkpoint_note(FakeModel(), {"workflow": BYPASSED}, 2),
|
|
"wrong checkpoint"))
|
|
check("guard: a broken extra_pnginfo never crashes the run",
|
|
"UNCHECKED" in _core.checkpoint_note(FakeModel(), {"workflow": "not a graph"}, 2))
|
|
|
|
# ── the LoRA slots: read from the graph, since the MODEL cannot be asked ─────
|
|
#
|
|
# The chain matters for TWO answers, and getting it wrong broke the second one
|
|
# during development: with a LoRA in the model path, a one-hop walk reported the
|
|
# LORA's filename as the checkpoint and failed the family guard on a perfectly
|
|
# good graph. Both are asserted here.
|
|
|
|
def lora_graph(*slots):
|
|
"""A UNETLoader, N LoRA slots chained, then our node. slots = (mode, name, s)."""
|
|
nodes = [{"id": 1, "type": "UNETLoader",
|
|
"widgets_values": ["minimax_h3_fl2va_pruned_int8_convrot.safetensors",
|
|
"default"]}]
|
|
links = []
|
|
prev, link_id = 1, 1
|
|
for index, (mode, name, strength) in enumerate(slots):
|
|
nid = 20 + index
|
|
nodes.append({"id": nid, "type": "LoraLoaderModelOnly", "mode": mode,
|
|
"inputs": [{"name": "model", "link": link_id}],
|
|
"widgets_values": [name, strength]})
|
|
links.append([link_id, prev, 0, nid, 0, "MODEL"])
|
|
prev, link_id = nid, link_id + 1
|
|
nodes.append({"id": 7, "type": "SimpleShotH3FirstLast",
|
|
"inputs": [{"name": "model", "link": link_id}]})
|
|
links.append([link_id, prev, 0, 7, 0, "MODEL"])
|
|
return {"nodes": nodes, "links": links}
|
|
|
|
|
|
BOTH_OFF = lora_graph((4, "put_your_lora_here.safetensors", 1.0),
|
|
(4, "put_your_lora_here.safetensors", 1.0))
|
|
ONE_ON = lora_graph((4, "put_your_lora_here.safetensors", 1.0),
|
|
(0, "my_style.safetensors", 0.8))
|
|
TWO_ON = lora_graph((0, "character_v2.safetensors", 1.0),
|
|
(0, "my_style.safetensors", 0.55))
|
|
|
|
check("lora: nothing said when the graph has no slots",
|
|
_core.lora_note({"workflow": DIRECT}, 2) is None)
|
|
check("lora: all-off says so, and counts them",
|
|
_core.lora_note({"workflow": BOTH_OFF}, 7)
|
|
== "no LoRA in effect — 2 slots present, all switched off",
|
|
str(_core.lora_note({"workflow": BOTH_OFF}, 7)))
|
|
check("lora: an active one is named with its strength",
|
|
"my_style.safetensors @ 0.8" in _core.lora_note({"workflow": ONE_ON}, 7))
|
|
check("lora: the remaining off slot is mentioned",
|
|
"1 slot switched off" in _core.lora_note({"workflow": ONE_ON}, 7))
|
|
check("lora: two active are both listed",
|
|
all(x in _core.lora_note({"workflow": TWO_ON}, 7)
|
|
for x in ("character_v2.safetensors @ 1.0", "my_style.safetensors @ 0.55")))
|
|
check("lora: no crash on a broken workflow",
|
|
_core.lora_note({"workflow": "junk"}, 7) is None
|
|
or isinstance(_core.lora_note({"workflow": "junk"}, 7), str))
|
|
check("lora: nothing said without the graph", _core.lora_note(None, None) is None)
|
|
|
|
for label, graph in (("both off", BOTH_OFF), ("one on", ONE_ON), ("two on", TWO_ON)):
|
|
check("guard reads THE CHECKPOINT past the LoRA slots: %s" % label,
|
|
"int8_convrot" in _core.checkpoint_note(FakeModel(), {"workflow": graph}, 7),
|
|
_core.checkpoint_note(FakeModel(), {"workflow": graph}, 7))
|
|
|
|
REF_THROUGH_LORA = {
|
|
"nodes": [{"id": 1, "type": "UNETLoader",
|
|
"widgets_values": ["MiniMax-H3-ref2va-curve.gguf"]},
|
|
{"id": 20, "type": "LoraLoaderModelOnly", "mode": 0,
|
|
"inputs": [{"name": "model", "link": 1}],
|
|
"widgets_values": ["x.safetensors", 1.0]},
|
|
{"id": 7, "type": "SimpleShotH3FirstLast",
|
|
"inputs": [{"name": "model", "link": 2}]}],
|
|
"links": [[1, 1, 0, 20, 0, "MODEL"], [2, 20, 0, 7, 0, "MODEL"]],
|
|
}
|
|
check("guard still refuses a ref2va checkpoint through an active LoRA",
|
|
raises(lambda: _core.checkpoint_note(FakeModel(), {"workflow": REF_THROUGH_LORA}, 7),
|
|
"wrong checkpoint"))
|
|
check("model chain walks loader-ward, nearest node first",
|
|
[k for k, _m, _w in _core._model_chain(TWO_ON, 7)]
|
|
== ["LoraLoaderModelOnly", "LoraLoaderModelOnly", "UNETLoader"])
|
|
check("model chain cannot run away on a cycle",
|
|
len(_core._model_chain(
|
|
{"nodes": [{"id": 7, "type": "A", "inputs": [{"name": "model", "link": 1}]},
|
|
{"id": 8, "type": "B", "inputs": [{"name": "model", "link": 2}]}],
|
|
"links": [[1, 8, 0, 7, 0, "MODEL"], [2, 7, 0, 8, 0, "MODEL"]]}, 7)) <= 16)
|
|
|
|
# ── what happens to the images, said before the render ──────────────────────
|
|
matched = _core.image_notes(FakeImage(736, 1280), None, 736, 1280)
|
|
check("images: a matching first frame is confirmed",
|
|
any("matches the canvas" in n for n in matched), str(matched))
|
|
stretched = _core.image_notes(FakeImage(1280, 736), None, 736, 1280)
|
|
check("images: a mismatched first frame says stretched, and says it shows",
|
|
any("stretched" in n and "ASPECT DIFFERS" in n for n in stretched), str(stretched))
|
|
cropped = _core.image_notes(None, FakeImage(1280, 736), 736, 1280)
|
|
check("images: a mismatched last frame says cropped",
|
|
any("centre-cropped" in n for n in cropped), str(cropped))
|
|
both = _core.image_notes(FakeImage(736, 1280), FakeImage(512, 512), 736, 1280)
|
|
check("images: two different sizes are called out",
|
|
any("different sizes" in n for n in both), str(both))
|
|
none = _core.image_notes(None, None, 736, 1280)
|
|
check("images: nothing supplied is legal and says so",
|
|
any("text-to-video" in n for n in none), str(none))
|
|
check("images: a same-aspect resize does not shout",
|
|
not any("ASPECT DIFFERS" in n
|
|
for n in _core.image_notes(FakeImage(368, 640), None, 736, 1280)))
|
|
|
|
# ── the positional contract: widget order may only ever grow ────────────────
|
|
spec = SimpleShotH3FirstLast.INPUT_TYPES()
|
|
WIDGETS_AS_PUBLISHED = ["prompt", "duration", "resolution", "steps", "seed"]
|
|
LINKS_AS_PUBLISHED = ["model", "clip", "vae", "audio_vae"]
|
|
required = list(spec["required"])
|
|
widgets = [k for k in required if k not in LINKS_AS_PUBLISHED]
|
|
check("widget order is unchanged (widgets_values is POSITIONAL)",
|
|
widgets[:len(WIDGETS_AS_PUBLISHED)] == WIDGETS_AS_PUBLISHED, str(widgets))
|
|
check("link inputs are unchanged and first",
|
|
required[:len(LINKS_AS_PUBLISHED)] == LINKS_AS_PUBLISHED, str(required))
|
|
check("both frames stayed optional", set(spec["optional"]) == {"first_frame", "last_frame"})
|
|
check("the graph's hidden inputs are declared",
|
|
spec["hidden"] == {"unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO"})
|
|
check("seed re-rolls per run", spec["required"]["seed"][1]["control_after_generate"] is True)
|
|
check("every input carries a tooltip",
|
|
all("tooltip" in v[1] for group in ("required", "optional")
|
|
for v in spec[group].values()), "a tooltip is the only manual most users read")
|
|
|
|
# ── registration ────────────────────────────────────────────────────────────
|
|
check("one node registered, under its published id",
|
|
list(NODE_CLASS_MAPPINGS) == ["SimpleShotH3FirstLast"])
|
|
check("outputs are labelled and documented",
|
|
len(SimpleShotH3FirstLast.RETURN_TYPES)
|
|
== len(SimpleShotH3FirstLast.RETURN_NAMES)
|
|
== len(SimpleShotH3FirstLast.OUTPUT_TOOLTIPS) == 3)
|
|
check("no second custom pack is imported",
|
|
not any("custom_nodes" in line
|
|
for line in open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"..", "simpleshot", "_core.py"))))
|
|
|
|
print("\n%d passed, %d failed" % (len(PASS), len(FAIL)))
|
|
if FAIL:
|
|
for name in FAIL:
|
|
print(" FAILED: " + name)
|
|
sys.exit(1 if FAIL else 0)
|