Skip to content

Commit a4c0eaa

Browse files
TMHSDigitalclaude
andcommitted
test: a falsifier whose exit code several checks share must name its check
classify() passed a falsifier on its exit code alone, and in 11 examples (14 falsifiers) that code is returned from several checks, so the falsifier could trip a sibling check while the one it targets had lost the ability to fail. Falsifiers gain an optional expect_output (a substring of the targeted check's message, passed through run_catalog and run_example), and check_smoke_catalog requires it wherever the expected code is returned from more than one place in an example script (falsified against the old catalog: 14 errors). All 14 are pinned. Live-run proven: each falsifier exits its declared code and prints its pinned message on Blender 5.2.1 LTS and 4.5.14 LTS (the two 5.0+ rows skip on 4.5 as before). Closes #532 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXRvikhJZSVGBFM91Dv Signed-off-by: TM Hospitality Strategies <TMHospitalityStrategies@gmail.com>
1 parent a7017a1 commit a4c0eaa

9 files changed

Lines changed: 113 additions & 17 deletions

File tree

‎CLAUDE.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,10 @@ The host runner is `tests/smoke/run_example.py`. Shipped examples are listed in
195195
the sidecar check owns, `{"args": [...], "expect_sidecar_fail": true}`.
196196
A falsifier that targets a version-specific API change takes
197197
`"min_version": "5.2"` and is recorded as SKIP below it (its README must
198-
say it exits 0 there).
198+
say it exits 0 there). When other checks in the example return the same
199+
code, the falsifier also carries `"expect_output"`: a substring of the
200+
targeted check's message, which must appear in the run's output
201+
(`check_smoke_catalog.py` requires it, #532).
199202
`run_catalog.py` runs each after the row's happy path; it passes only on
200203
exactly that exit. `tests/check_smoke_catalog.py` rejects a row without one
201204
and any unknown key. The weekly cron also runs the full showcase sweep

‎CONTRIBUTING.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,9 @@ example needs:
158158
`3` up) and a falsifier: a flag that breaks the contract so one named check
159159
exits with its own code.
160160
2. A `tests/smoke/catalog.json` row with at least one falsifier
161-
(`{"args": ["--your-flag"], "expect_exit": N}`). `tests/check_smoke_catalog.py`
161+
(`{"args": ["--your-flag"], "expect_exit": N}`; add `"expect_output": "<the
162+
check's error message>"` when other checks return the same code).
163+
`tests/check_smoke_catalog.py`
162164
rejects a row without one.
163165
3. An `examples/skills.json` entry naming the skills it proves, then
164166
`python scripts/build_examples_index.py` (Validate fails on a missing entry).

‎tests/check_smoke_catalog.py‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
otherwise be ignored and the falsifier silently never run), and every row
1111
carries at least one falsifier: {"args": [...], "expect_exit": N} with N not
1212
0, 1, 2 (argparse usage) or 77, or {"args": [...], "expect_sidecar_fail": true} on a row that
13-
declares expect_sidecar.
13+
declares expect_sidecar. An expect_exit falsifier may add "expect_output": a
14+
substring of the targeted check's message, required when other checks in the
15+
script exit the same code (#532).
1416
1517
python tests/check_smoke_catalog.py [--root PATH]
1618
@@ -20,9 +22,11 @@
2022
from __future__ import annotations
2123

2224
import argparse
25+
import ast
2326
import json
2427
import re
2528
import sys
29+
from collections import Counter
2630
from pathlib import Path
2731

2832
sys.path.insert(0, str(Path(__file__).resolve().parent / "smoke"))
@@ -33,7 +37,8 @@
3337

3438
ROW_KEYS = {"name", "script", "args", "min_version", "expect_file",
3539
"expect_sidecar", "sidecar_contains", "falsifiers", "timeout"}
36-
FALSIFIER_KEYS = {"args", "expect_exit", "expect_sidecar_fail", "min_version"}
40+
FALSIFIER_KEYS = {"args", "expect_exit", "expect_sidecar_fail", "expect_output",
41+
"min_version"}
3742
# Defined once in tests/smoke/protocol.py, which explains each code.
3843
FORBIDDEN_EXITS = set(FORBIDDEN_FALSIFIER_EXITS)
3944

@@ -70,6 +75,10 @@ def check_schema(row: dict) -> list[str]:
7075
errors.append(f"{where}: expect_exit {code!r} must be an int other than 0, 1, 2, 77")
7176
elif not row.get("expect_sidecar"):
7277
errors.append(f"{where}: expect_sidecar_fail on a row with no expect_sidecar")
78+
eo = f.get("expect_output")
79+
if eo is not None and not (isinstance(eo, str) and eo.strip() and has_exit):
80+
errors.append(f"{where}: expect_output must be a non-empty string on an "
81+
f"expect_exit falsifier")
7382
mv = f.get("min_version")
7483
if mv is not None and not (isinstance(mv, str) and re.fullmatch(r"[0-9]+[.][0-9]+", mv)):
7584
errors.append(f"{where}: min_version {mv!r} must look like '5.2'")
@@ -94,6 +103,41 @@ def check_readme_claims(root: Path, row: dict) -> list[str]:
94103
f"but it is this row's catalog falsifier" for flag in sorted(named & ran)]
95104

96105

106+
def exit_sites(script: Path) -> Counter:
107+
"""How many places in the script return or exit with each integer code."""
108+
tree = ast.parse(script.read_text(encoding="utf-8"))
109+
sites: Counter = Counter()
110+
for node in ast.walk(tree):
111+
value = None
112+
if isinstance(node, ast.Return):
113+
value = node.value
114+
elif isinstance(node, ast.Call) and node.args and ast.unparse(node.func) in (
115+
"sys.exit", "SystemExit", "exit"):
116+
value = node.args[0]
117+
if isinstance(value, ast.Constant) and type(value.value) is int:
118+
sites[value.value] += 1
119+
return sites
120+
121+
122+
def check_shared_exits(root: Path, row: dict) -> list[str]:
123+
"""An example falsifier whose code several checks return must name its own (#532).
124+
125+
The harness passes a falsifier on its exit code; when two checks share it,
126+
the falsifier could trip the other one while the check it targets has lost
127+
the ability to fail. expect_output pins the targeted check's message.
128+
"""
129+
script = root / row["script"]
130+
if Path(row["script"]).parts[0] != "examples" or not script.is_file():
131+
return []
132+
sites = exit_sites(script)
133+
return [f"catalog row {row['name']!r} falsifier {' '.join(f['args'])}: exit "
134+
f"{f['expect_exit']} is returned from {sites[f['expect_exit']]} places in "
135+
f"{row['script']}; add expect_output naming the targeted check's message"
136+
for f in row.get("falsifiers", [])
137+
if isinstance(f, dict) and isinstance(f.get("expect_exit"), int)
138+
and sites[f["expect_exit"]] > 1 and not f.get("expect_output")]
139+
140+
97141
def check(root: Path) -> list[str]:
98142
catalog = json.loads((root / "tests/smoke/catalog.json").read_text(encoding="utf-8"))
99143
if not catalog:
@@ -109,6 +153,7 @@ def check(root: Path) -> list[str]:
109153
if not (root / row["script"]).is_file():
110154
# otherwise only Blender, at runtime, notices a renamed script (#527)
111155
errors.append(f"catalog row {row.get('name')!r}: script {row['script']} does not exist")
156+
errors += check_shared_exits(root, row)
112157
parts = Path(row["script"]).parts
113158
if len(parts) >= 2 and parts[0] in ("examples", "showcase"):
114159
key = f"{parts[0]}/{parts[1]}"

‎tests/smoke/catalog.json‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@
1212
{"name": "wave-displace", "script": "examples/wave-displace/wave_displace.py", "falsifiers": [{"args": ["--flat"], "expect_exit": 4}]},
1313
{"name": "driver-wave", "script": "examples/driver-wave/driver_wave.py", "falsifiers": [{"args": ["--flat-expr"], "expect_exit": 3}, {"args": ["--simple-expr"], "expect_exit": 5}, {"args": ["--swap-handlers"], "expect_exit": 7}]},
1414
{"name": "bmesh-gear", "script": "examples/bmesh-gear/bmesh_gear.py", "falsifiers": [{"args": ["--no-extrude"], "expect_exit": 3}]},
15-
{"name": "shader-node-group", "script": "examples/shader-node-group/shader_node_group.py", "falsifiers": [{"args": ["--same-tint"], "expect_exit": 8}]},
15+
{"name": "shader-node-group", "script": "examples/shader-node-group/shader_node_group.py", "falsifiers": [{"args": ["--same-tint"], "expect_exit": 8, "expect_output": "ERROR: rendered mug glazes not distinct"}]},
1616
{"name": "temp-override-join", "script": "examples/temp-override-join/temp_override_join.py", "falsifiers": [{"args": ["--no-override"], "expect_exit": 3}]},
1717
{"name": "gn-instance-grid", "script": "examples/gn-instance-grid/gn_instance_grid.py", "falsifiers": [{"args": ["--one-cell"], "expect_exit": 4}]},
1818
{"name": "gn-modifier-inputs", "script": "examples/gn-modifier-inputs/gn_modifier_inputs.py", "falsifiers": [{"args": ["--same-height"], "expect_exit": 8}, {"args": ["--api", "dict"], "expect_exit": 5, "min_version": "5.2"}]},
1919
{"name": "shape-key-blend", "script": "examples/shape-key-blend/shape_key_blend.py", "falsifiers": [{"args": ["--zero-blend"], "expect_exit": 5}]},
2020
{"name": "curve-bevel-arc", "script": "examples/curve-bevel-arc/curve_bevel_arc.py", "falsifiers": [{"args": ["--no-caps"], "expect_exit": 7}]},
21-
{"name": "compositor-glare", "script": "examples/compositor-glare/compositor_glare.py", "falsifiers": [{"args": ["--threshold-high"], "expect_exit": 4}]},
21+
{"name": "compositor-glare", "script": "examples/compositor-glare/compositor_glare.py", "falsifiers": [{"args": ["--threshold-high"], "expect_exit": 4, "expect_output": "ERROR: no bloom halo beyond the silhouette"}]},
2222
{"name": "damped-track-aim", "script": "examples/damped-track-aim/damped_track_aim.py", "falsifiers": [{"args": ["--mute"], "expect_exit": 9}]},
2323
{"name": "color-attribute-wheel", "script": "examples/color-attribute-wheel/color_attribute_wheel.py", "falsifiers": [{"args": ["--point-domain"], "expect_exit": 6}]},
2424
{"name": "parent-inverse-orrery", "script": "examples/parent-inverse-orrery/parent_inverse_orrery.py", "falsifiers": [{"args": ["--skip-mpi"], "expect_exit": 6}]},
25-
{"name": "grease-pencil-rosette", "script": "examples/grease-pencil-rosette/grease_pencil_rosette.py", "falsifiers": [{"args": ["--open-strokes"], "expect_exit": 5}]},
25+
{"name": "grease-pencil-rosette", "script": "examples/grease-pencil-rosette/grease_pencil_rosette.py", "falsifiers": [{"args": ["--open-strokes"], "expect_exit": 5, "expect_output": "ERROR: not every stroke is cyclic"}]},
2626
{"name": "gp-lineart-contour", "script": "examples/gp-lineart-contour/gp_lineart_contour.py", "falsifiers": [{"args": ["--no-contour"], "expect_exit": 5}]},
2727
{"name": "armature-bend", "script": "examples/armature-bend/armature_bend.py", "falsifiers": [{"args": ["--zero-curl"], "expect_exit": 8}]},
2828
{"name": "text-version-stamp", "script": "examples/text-version-stamp/text_version_stamp.py", "falsifiers": [{"args": ["--wrong-body"], "expect_exit": 4}]},
@@ -54,18 +54,18 @@
5454
{"name": "attribute-domain-shear", "script": "examples/attribute-domain-shear/attribute_domain_shear.py", "falsifiers": [{"args": ["--no-overwrite"], "expect_exit": 5}]},
5555
{"name": "degenerate-bevel-weld", "script": "examples/degenerate-bevel-weld/degenerate_bevel_weld.py", "falsifiers": [{"args": ["--both-safe"], "expect_exit": 4}]},
5656
{"name": "modular-kit-snap", "script": "examples/modular-kit-snap/modular_kit_snap.py", "falsifiers": [{"args": ["--seam-gap"], "expect_exit": 5}]},
57-
{"name": "lightmap-uv-channel", "script": "examples/lightmap-uv-channel/lightmap_uv_channel.py", "falsifiers": [{"args": ["--overlap-islands"], "expect_exit": 7}]},
58-
{"name": "socket-attach-points", "script": "examples/socket-attach-points/socket_attach_points.py", "falsifiers": [{"args": ["--skip-mpi"], "expect_exit": 3}]},
57+
{"name": "lightmap-uv-channel", "script": "examples/lightmap-uv-channel/lightmap_uv_channel.py", "falsifiers": [{"args": ["--overlap-islands"], "expect_exit": 7, "expect_output": "overlapping UV1 triangle pairs"}]},
58+
{"name": "socket-attach-points", "script": "examples/socket-attach-points/socket_attach_points.py", "falsifiers": [{"args": ["--skip-mpi"], "expect_exit": 3, "expect_output": "world matrix deviates"}]},
5959
{"name": "vertex-color-ao", "script": "examples/vertex-color-ao/vertex_color_ao.py", "falsifiers": [{"args": ["--uniform-hemisphere"], "expect_exit": 3}]},
60-
{"name": "cross-version-property-delete", "script": "examples/cross-version-property-delete/cross_version_property_delete.py", "falsifiers": [{"args": ["--skip-delete"], "expect_exit": 7}, {"args": ["--subscript-registered"], "expect_exit": 8, "min_version": "5.0"}, {"args": ["--unset-instead"], "expect_exit": 7}]},
60+
{"name": "cross-version-property-delete", "script": "examples/cross-version-property-delete/cross_version_property_delete.py", "falsifiers": [{"args": ["--skip-delete"], "expect_exit": 7}, {"args": ["--subscript-registered"], "expect_exit": 8, "min_version": "5.0", "expect_output": "on 5.0+ a registered bpy.props value is not an ID property"}, {"args": ["--unset-instead"], "expect_exit": 7}]},
6161
{"name": "usd-export-evaluation-mode", "script": "examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py", "falsifiers": [{"args": ["--subdivision", "BEST_MATCH"], "expect_exit": 4}, {"args": ["--evaluation-mode", "VIEWPORT"], "expect_exit": 4}]},
6262
{"name": "gn-zone-iterate", "script": "examples/gn-zone-iterate/gn_zone_iterate.py", "falsifiers": [{"args": ["--unpair-repeat"], "expect_exit": 3}, {"args": ["--no-offset"], "expect_exit": 4}, {"args": ["--unpair-foreach"], "expect_exit": 5}, {"args": ["--foreach-main"], "expect_exit": 5}]},
63-
{"name": "gn-sim-fountain", "script": "examples/gn-sim-fountain/gn_sim_fountain.py", "falsifiers": [{"args": ["--unpair"], "expect_exit": 3}, {"args": ["--euler"], "expect_exit": 4}, {"args": ["--prebake-trap"], "expect_exit": 5}, {"args": ["--calc-to-frame"], "expect_exit": 6}]},
63+
{"name": "gn-sim-fountain", "script": "examples/gn-sim-fountain/gn_sim_fountain.py", "falsifiers": [{"args": ["--unpair"], "expect_exit": 3, "expect_output": "an unpaired zone outputs nothing"}, {"args": ["--euler"], "expect_exit": 4, "expect_output": "miss p0 + v0 t - g t^2/2"}, {"args": ["--prebake-trap"], "expect_exit": 5}, {"args": ["--calc-to-frame"], "expect_exit": 6}]},
6464
{
6565
"name": "gn-bundle-roundtrip",
6666
"script": "examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py",
6767
"min_version": "5.0",
68-
"falsifiers": [{"args": ["--legacy-rna"], "expect_exit": 7}, {"args": ["--mismatch"], "expect_exit": 3}, {"args": ["--bypass"], "expect_exit": 4}, {"args": ["--mismatch-mark"], "expect_exit": 5}]
68+
"falsifiers": [{"args": ["--legacy-rna"], "expect_exit": 7}, {"args": ["--mismatch"], "expect_exit": 3}, {"args": ["--bypass"], "expect_exit": 4}, {"args": ["--mismatch-mark"], "expect_exit": 5, "expect_output": "ERROR: bundle_mark"}]
6969
},
7070
{
7171
"name": "exit-pre-sidecar",
@@ -83,7 +83,7 @@
8383
},
8484
{"name": "mesh-normals-after-41", "script": "examples/mesh-normals-after-41/mesh_normals_after_41.py", "falsifiers": [{"args": ["--legacy-api"], "expect_exit": 3}, {"args": ["--sharp-angle", "10"], "expect_exit": 4}, {"args": ["--flatten-after"], "expect_exit": 5}, {"args": ["--skip-set-sharp"], "expect_exit": 6}]},
8585
{"name": "addon-register-lifecycle", "script": "examples/addon-register-lifecycle/addon_register_lifecycle.py", "falsifiers": [{"args": ["--drop-manifest"], "expect_exit": 3}, {"args": ["--legacy-gets-manifest"], "expect_exit": 4}, {"args": ["--skip-pointer"], "expect_exit": 5}, {"args": ["--classes-first"], "expect_exit": 6}]},
86-
{"name": "ngon-triangulate", "script": "examples/ngon-triangulate/ngon_triangulate.py", "falsifiers": [{"args": ["--no-dissolve"], "expect_exit": 3}, {"args": ["--skip-triangulate"], "expect_exit": 4}]},
86+
{"name": "ngon-triangulate", "script": "examples/ngon-triangulate/ngon_triangulate.py", "falsifiers": [{"args": ["--no-dissolve"], "expect_exit": 3, "expect_output": "ERROR: pathology missing"}, {"args": ["--skip-triangulate"], "expect_exit": 4, "expect_output": "ERROR: triangulate handling failed"}]},
8787
{"name": "unapplied-scale-gltf", "script": "examples/unapplied-scale-gltf/unapplied_scale_gltf.py", "falsifiers": [{"args": ["--identity"], "expect_exit": 3}, {"args": ["--bake"], "expect_exit": 4}]},
8888
{"name": "coincident-vert-weld", "script": "examples/coincident-vert-weld/coincident_vert_weld.py", "falsifiers": [{"args": ["--no-duplicate"], "expect_exit": 3}, {"args": ["--weld"], "expect_exit": 4}]},
8989
{"name": "bake-normal-high-to-low", "script": "examples/bake-normal-high-to-low/bake_normal_high_to_low.py", "falsifiers": [{"args": ["--flat-source"], "expect_exit": 5}, {"args": ["--unselect-target"], "expect_exit": 4, "min_version": "5.0"}]},
@@ -93,9 +93,9 @@
9393
{"name": "timers-modal-threading", "script": "examples/timers-modal-threading/timers_modal_threading.py", "falsifiers": [{"args": ["--windowed-background-child"], "expect_exit": 3}, {"args": ["--return-zero-once"], "expect_exit": 5}, {"args": ["--return-late-once"], "expect_exit": 5}, {"args": ["--apply-in-worker"], "expect_exit": 6}, {"args": ["--no-event-timer"], "expect_exit": 7}, {"args": ["--non-persistent"], "expect_exit": 8}, {"args": ["--stop-after-first"], "expect_exit": 9}, {"args": ["--no-catch"], "expect_exit": 12}]},
9494
{"name": "eval-mesh-datablock-name", "script": "examples/eval-mesh-datablock-name/eval_mesh_datablock_name.py", "falsifiers": [{"args": ["--assume-distinct-names"], "expect_exit": 6, "min_version": "5.2"}]},
9595
{"name": "mesh-automasking-settings", "script": "examples/mesh-automasking-settings/mesh_automasking_settings.py", "falsifiers": [{"args": ["--assume-brush-attrs"], "expect_exit": 5, "min_version": "5.2"}]},
96-
{"name": "ray-cast-space", "script": "examples/ray-cast-space/ray_cast_space.py", "falsifiers": [{"args": ["--world-to-object"], "expect_exit": 4}]},
97-
{"name": "lattice-deform", "script": "examples/lattice-deform/lattice_deform.py", "falsifiers": [{"args": ["--bspline"], "expect_exit": 4}]},
98-
{"name": "solidify-even-thickness", "script": "examples/solidify-even-thickness/solidify_even_thickness.py", "falsifiers": [{"args": ["--no-even"], "expect_exit": 4}]},
96+
{"name": "ray-cast-space", "script": "examples/ray-cast-space/ray_cast_space.py", "falsifiers": [{"args": ["--world-to-object"], "expect_exit": 4, "expect_output": "ERROR: Object.ray_cast ray"}]},
97+
{"name": "lattice-deform", "script": "examples/lattice-deform/lattice_deform.py", "falsifiers": [{"args": ["--bspline"], "expect_exit": 4, "expect_output": "verts off the trilinear closed form"}]},
98+
{"name": "solidify-even-thickness", "script": "examples/solidify-even-thickness/solidify_even_thickness.py", "falsifiers": [{"args": ["--no-even"], "expect_exit": 4, "expect_output": "ERROR: even shell is"}]},
9999
{"name": "boolean-exact-volume", "script": "examples/boolean-exact-volume/boolean_exact_volume.py", "falsifiers": [{"args": ["--float-solver"], "expect_exit": 3}]},
100100
{"name": "extension-template-lifecycle", "script": "tests/smoke/check_extension_template.py", "falsifiers": [{"args": ["--skip-unregister"], "expect_exit": 4}]},
101101
{"name": "shipping-crate", "script": "showcase/shipping-crate/shipping_crate.py", "falsifiers": [{"args": ["--short-skids"], "expect_exit": 16}]},

‎tests/smoke/protocol.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def classify(
8282
sidecar_contains: Optional[str] = None,
8383
expect_exit: Optional[int] = None,
8484
expect_sidecar_fail: bool = False,
85+
expect_output: Optional[str] = None,
8586
) -> Tuple[str, str]:
8687
"""Return (PASS|SKIP|FAIL, detail).
8788
@@ -91,7 +92,10 @@ def classify(
9192
9293
Falsifier runs invert the verdict. With ``expect_exit=N`` the run passes
9394
only when Blender exits exactly N: not 0 (the check did not fire), not
94-
another code (an earlier check fired), not 1 (it crashed). With
95+
another code (an earlier check fired), not 1 (it crashed). When several
96+
checks share N, ``expect_output`` names the targeted check's message, which
97+
must appear in the output too, so a different check exiting N cannot stand
98+
in for the one the falsifier was written to break (#532). With
9599
``expect_sidecar_fail`` the run passes only when the script exits 0 and
96100
the post-exit sidecar check then fails (falsifiers the harness, not the
97101
script, is meant to catch). A legal skip stays a skip either way.
@@ -112,6 +116,9 @@ def classify(
112116
if expect_exit in FORBIDDEN_FALSIFIER_EXITS:
113117
return FAIL, f"falsifier expects exit {expect_exit}, which names no check"
114118
if proc_exit == expect_exit:
119+
if expect_output and expect_output not in output:
120+
return FAIL, (f"falsifier exited {proc_exit} as declared, but not from the "
121+
f"targeted check: output lacks {expect_output!r}")
115122
return PASS, f"falsifier exited {proc_exit} as declared"
116123
if proc_exit == 0:
117124
return FAIL, f"falsifier did not fail (exit 0, expected {expect_exit})"

‎tests/smoke/run_catalog.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ def row_cmd(item, run_name, extra, falsifier=None):
9494
cmd.append("--expect-sidecar-fail")
9595
else:
9696
cmd.extend(["--expect-exit", str(falsifier["expect_exit"])])
97+
if falsifier.get("expect_output"):
98+
cmd.extend(["--expect-output", falsifier["expect_output"]])
9799
if extra:
98100
cmd.append("--")
99101
cmd.extend(extra)

0 commit comments

Comments
 (0)