R1 cleanup: collAnon and dead code gone, docs current, every peer's scenario in one sweep
Build / Build (push) Successful in 1m7s

Starts the completion program (docs/ROADMAP.md, "Owner decisions on finishing everything", 2026-10-17).

- Dead code: the commented-out collAnon blocks (discussions, seeding, admin user lists, HTTP/3, the old signature
  middleware), the empty PrivaPubWorkersConfiguration, PublicAvatarUsersService and UserPolicyType, unused helpers,
  the second IPasswordHasher registration, the PasswordGenerator package, SignUpAsync/LoginAsync's unused invitation
  parameters. "Starting PrivaPub"; a signed-in login is sent to "/", not to "/discussions".
- Stored fields nothing read: RootUser.Contacts and ResetPasswordToken*, the local Avatar.AvatarServer,
  EmailRecovery.RecoveryCode/RequestIP/CreationDate, MongoSettings.LogsDatabase, and AppConfiguration's
  upload/discussion keys, RequiresFirstTimeSetup and Version (old documents still load: MongoDB.Entities ignores extra
  elements). DmPost's indexes are no longer made. 49 collAnon keys leave FieldsNameResource in every language.
- /clientapi/data/current-version answers the build's ref. UsersIds is a list that can't throw, and the admin routes
  leave the administrator out without mutating the request.
- deploy.yml checks SecureMode on a name no persona can have, and the browser redirect on the directory's first local
  persona: @thepra is the administrator's own now.
- Town: decePub's e2e remnants (expect, client.jsonl, the decePub column) and town/tests go; the dead gaps/load
  commands too. Mobilizon no longer keeps an event for a deleted test.
- tools/pasture/sweep.sh runs every peer's scenario, batch by batch, as CLAUDE.md asks before a change to what
  PrivaPub sends.
- Docs: CLAUDE.md's repo map, route table (every frozen route) and stale lines; ROADMAP's status (setup, v1.25.0,
  v1.26.0, the program C), the 2026-10-17 decisions, and dated notes where earlier text no longer holds; FEDERATION.md's
  FEP list (7888, f228, 7628, ae0c and 8967 supported) and the deploy's persona.

Verified: dotnet build PrivaPub.sln -c Release (0 errors, no new warnings); bash -n on the touched scripts; py_compile
on the town; the pasture's gts, mastodon and lemmy scenarios on this build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 02:56:57 +02:00
1 parent 6727e1817a
commit ff5e635dda
71 files changed
+238 -4767

No files matched your search

-4
View File
@@ -1,13 +1,9 @@
{
"MongoSettings": {
"Database": "PrivaPub",
"LogsDatabase": "logs",
"ConnectionString": "mongodb://mongo:27017/?directConnection=true"
},
"AppConfiguration": {
"Version": "0.0.0",
"MaxAllowedUploadFiles": 3,
"MaxAllowedFileSize": 2097152,
"SupportedLanguages": [ "en" ],
"BackendBaseAddress": "https://privapub.test",
"FrontendBaseAddress": "https://privapub.test",
+3 -2
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# Drives PrivaPub and each peer (started by run.sh) through each other's federation, as their users would, then checks
# that PrivaPub's statistics saw it all without naming anyone.
# usage: tools/pasture/interop.sh [peer...] peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy,
# peertube, crawler
# usage: tools/pasture/interop.sh [scenario...] gts (default), and every other file in scenarios/: a peer's (with
# that peer up) or one across peers (threads, relay, restore, ...)
# tools/pasture/sweep.sh [A|B|C|D ...] every peer's scenario, a batch of peers at a time
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$here/lib/interop.sh"
+1
View File
@@ -5,6 +5,7 @@
# usage: tools/pasture/run.sh up [peer...] | add <peer...> | down [--purge] | logs <name> | ps | stats
# up starts everything afresh (PrivaPub republished, Mongo empty); add starts more peers next to a running pasture.
# peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy, decepub, and every other file in peers/
# tools/pasture/sweep.sh runs every peer's scenario, a batch of peers at a time (up, interop.sh, down).
set -euo pipefail
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/pasture.sh"
+2 -1
View File
@@ -192,7 +192,8 @@ curl -s -o /dev/null -X POST -H "$LH" "$P/api/v1/follow_requests/$requester/auth
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$locked_on_gts" | j "print(d[0][\"following\"])")" = "True" ]' && ok "an authorized request makes gtsuser a follower" || ko "authorization not applied on GoToSocial"
echo "smoke"
"$here/../smoke/mastodon-api.sh" "$P" "$PT" >/dev/null 2>&1 && ok "the deploy's Mastodon smoke check passes, signed in" || ko "the Mastodon smoke check fails"
# tools/smoke/mastodon-api.sh: the deploy runs it anonymously; here a persona's token adds its signed-in checks
"$here/../smoke/mastodon-api.sh" "$P" "$PT" >/dev/null 2>&1 && ok "the Mastodon smoke check passes, anonymously and with a persona's token" || ko "the Mastodon smoke check fails"
echo "interaction policies"
# gtsuser's post asks before anyone but its author replies or likes, and lets nobody else boost it: PrivaPub shows the
+3 -13
View File
@@ -1,8 +1,8 @@
# Mobilizon 5.2.4: a group and its events. alice_mobilizon follows the group, which announces the events its members
# organise; an event arrives as an Event with its dates and place; she joins it and leaves; comments both ways; an edit
# and comments closed; deletes; a group post; an event kept for decePubClient's e2e; the unfollow. An event made through its API without options has its comments closed
# (`commentsEnabled: false`), which PrivaPub honours, so this one opens them. Mobilizon's API is GraphQL (/api), signed in by its login mutation; what it holds is read from
# its PostGIS (database mobilizon).
# and comments closed; deletes; a group post; the unfollow. An event made through its API without options has its
# comments closed (`commentsEnabled: false`), which PrivaPub honours, so this one opens them. Mobilizon's API is GraphQL
# (/api), signed in by its login mutation; what it holds is read from its PostGIS (database mobilizon).
MZ=https://mobilizon.test:6443
mz_sql() { podman exec pasture-postgis psql -U pasture -d mobilizon -tAc "$1" 2>/dev/null; }
# mz <query> [variables as JSON]: a GraphQL request as mzuser, its JSON answer
@@ -91,16 +91,6 @@ mz 'mutation($g:ID!,$t:String!){createPost(attributedToId:$g,title:$t,body:"<p>t
"{\"g\":\"$group\",\"t\":\"$post_title\"}" >/dev/null
until_true 60 '[ -n "$(p_home_has "$post_title")" ]' && ok "the group's post reaches alice_mobilizon's home, with its title" || ko "the group's post never arrived"
echo " an event kept for decePub's tests"
# decePubClient's e2e joins it from its card (tests/e2e/Actions/EventTests.cs); the one a run before this one kept goes
kept="$here/.state/mobilizon-kept-event.json"
[ -s "$kept" ] && mz 'mutation($e:ID!){deleteEvent(eventId:$e){id}}' "{\"e\":\"$(j "print(d['id'])" < "$kept")\"}" >/dev/null
kept_title="A concert kept for decePub $(date +%s)"
kept_begins=$(date -u -d '+10 days' +%Y-%m-%dT19:00:00Z); kept_ends=$(date -u -d '+10 days' +%Y-%m-%dT22:00:00Z)
mz 'mutation($t:String!,$b:DateTime!,$e:DateTime!,$o:ID!,$g:ID){createEvent(title:$t,description:"<p>come and listen</p>",beginsOn:$b,endsOn:$e,organizerActorId:$o,attributedToId:$g,visibility:PUBLIC,joinOptions:FREE,options:{commentModeration:ALLOW_ALL},physicalAddress:{description:"Piazza del Popolo",locality:"Roma",country:"Italia",geom:"12.4763;41.9109"}){id url}}' \
"{\"t\":\"$kept_title\",\"b\":\"$kept_begins\",\"e\":\"$kept_ends\",\"o\":\"$me\",\"g\":\"$group\"}" | j "print(json.dumps(d['data']['createEvent']))" > "$kept"
until_true 60 '[ -n "$(p_home_has "$kept_title")" ]' && ok "an event kept for decePub's tests reaches PrivaPub" || ko "the kept event never arrived"
echo " unfollow"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$group_on_p/unfollow"
until_true 45 '[ "$(mz_sql "select count(*) from followers f join actors a on a.id = f.actor_id where a.url like '"'%alice_mobilizon%'"'")" = "0" ]' \
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Every peer's scenario, a batch of peers at a time: a change to what PrivaPub sends (the @context, an actor's
# properties, an activity's shape, a delivery's headers) runs against all of them before it is committed, since one
# reader stricter than the rest fails on it alone. Each batch starts its own pasture afresh (run.sh up), runs its
# scenarios (interop.sh) and takes the pasture down (run.sh down), and the next batch runs whatever the one before did.
# PrivaPub runs with SecureMode on, as in production, unless PRIVAPUB_ENV says otherwise; DOTNET and PASTURE_PORT reach
# run.sh as they are. Each batch's output is kept in out/sweep-<batch>.log.
# usage: tools/pasture/sweep.sh [A|B|C|D ...] (default: every batch, in that order)
# A gts mastodon misskey sharkey akkoma, with relay and aoderelay; scenarios: those five, threads followsync pins
# moves relay (relay's covers both relays)
# B lemmy lemmy19 piefed mbin nodebb
# C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale
# D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla
# Not swept: restore (run it alone: it restores the whole server), crawler (it needs the crawler's own settings) and
# persona-archive; hollo, iceshrimp, pleroma, flood and decepub have no scenario of their own.
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PRIVAPUB_ENV=${PRIVAPUB_ENV:-Federation__SecureMode=true}
batch_peers() {
case "$1" in
A) echo "gts mastodon misskey sharkey akkoma relay aoderelay" ;;
B) echo "lemmy lemmy19 piefed mbin nodebb" ;;
C) echo "peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale" ;;
D) echo "friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla" ;;
esac
}
batch_scenarios() {
case "$1" in
A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay" ;;
*) batch_peers "$1" ;;
esac
}
[ $# -gt 0 ] || set -- A B C D
for batch in "$@"; do
[ -n "$(batch_peers "$batch")" ] || { echo "no such batch: $batch (A, B, C or D)" >&2; exit 2; }
for peer in $(batch_peers "$batch"); do
[ -f "$here/peers/$peer.sh" ] || { echo "batch $batch: no such peer: $peer" >&2; exit 2; }
done
for scenario in $(batch_scenarios "$batch"); do
[ -f "$here/scenarios/$scenario.sh" ] || { echo "batch $batch: no scenario for $scenario" >&2; exit 2; }
done
done
mkdir -p "$here/out"
summary=(); bad=0
for batch in "$@"; do
log="$here/out/sweep-$batch.log"
echo "== batch $batch: $(batch_peers "$batch")"
# shellcheck disable=SC2046
if ! "$here/run.sh" up $(batch_peers "$batch") 2>&1 | tee "$log"; then
summary+=("$batch the pasture did not start (out/sweep-$batch.log)"); bad=1
"$here/run.sh" down || true
continue
fi
# shellcheck disable=SC2046
"$here/interop.sh" $(batch_scenarios "$batch") 2>&1 | tee -a "$log"
status=${PIPESTATUS[0]}
"$here/run.sh" down || true
passed=$(grep -c '^ ok ' "$log"); failed=$(grep -c '^ FAIL ' "$log"); xfailed=$(grep -c '^ xf ' "$log")
line="$batch ok $passed ko $failed xf $xfailed ($(batch_scenarios "$batch"))"
if ! grep -qE '^[0-9]+ passed, [0-9]+ failed, [0-9]+ expected failures$' "$log"; then
line="$line stopped before its end (exit $status)"; bad=1
fi
[ "$failed" = 0 ] || bad=1
summary+=("$line")
done
echo; echo "sweep:"
printf ' %s\n' "${summary[@]}"
[ "$bad" = 0 ]
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# The town: a fake community seeded across every pasture peer, then checked for coherence (see town/town.py).
# usage: tools/pasture/town.sh <command> [args] commands: selftest, seed, check, report, backlog, gaps, drive, stored, seen,
# usage: tools/pasture/town.sh <command> [args] commands: selftest, seed, check, report, backlog, drive, stored, seen,
# renew <peer> (its accounts signed in again)
exec python3 "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/town/town.py" "$@"
+3 -4
View File
@@ -1,5 +1,5 @@
"""docs/INTEROP-BACKLOG.md from the latest town results, the bash scenarios and decePub's e2e cells: new failures (to fix
or to record in gaps.json), known gaps by phase, known gaps now passing (close them), and the scenarios' failures.
"""docs/INTEROP-BACKLOG.md from the latest town results and the bash scenarios: new failures (to fix or to record in
gaps.json), known gaps by phase, known gaps now passing (close them), and the scenarios' failures.
town.sh backlog [run] [--write]"""
import json
@@ -27,9 +27,8 @@ def main(args):
with open(os.path.join(run_dir, "results.json")) as f:
result = json.load(f)
gaps = {g["id"]: g for g in check.load_gaps()}
client = check.classify(check.latest_client([dict(c) for c in load_jsonl(os.path.join(seed.OUT, "client.jsonl"))]), list(gaps.values()))
scenarios = load_jsonl(os.path.join(seed.OUT, "scenarios.jsonl"))
checks = result["checks"] + client
checks = result["checks"]
new = defaultdict(list)
known = defaultdict(list)
-15
View File
@@ -478,21 +478,6 @@ def canary_hits(platform, canaries):
return sorted(c for c in canaries if c in dump)
def latest_client(lines):
"""decePub's e2e cells (out/client.jsonl, appended by every run) as each cell's latest run left them: the rows from
within an hour of the cell's newest, the newest per test. A run that picked other accounts leaves no stale failure."""
from datetime import datetime, timedelta
at = lambda c: datetime.fromisoformat(c["t"].replace("Z", "+00:00"))
newest = {}
for c in lines:
newest[c["cell"]] = max(newest.get(c["cell"], at(c)), at(c))
latest = {}
for c in sorted(lines, key=at):
if at(c) >= newest[c["cell"]] - timedelta(hours=1):
latest[(c["cell"], c.get("ref"))] = c
return list(latest.values())
def load_gaps():
try:
with open(os.path.join(HERE, "gaps.json")) as f:
-63
View File
@@ -1,63 +0,0 @@
"""What the town's objects should look like to a reader, for decePub's e2e tests: for every object PrivaPub holds and some
town persona may see, its ref, URI, PrivaPub id, origin, kind, visibility, the text marker, content warning, media alt
texts, poll options, quoted and parent URIs, and the personas that may see it.
town.sh expect [run] [--persona <username>]"""
import json
import sys
import check
import seed
from dialects import driver
def edited_cw(world, ref, args):
"""The warning an object shows now: an edit made before plans kept warnings replaced the post without one."""
edit = next((s for s in world.planner.steps if s["verb"] == "edit" and s["args"]["target"] == ref), None)
if edit is None or ref not in world.edited:
return args.get("cw")
return edit["args"].get("cw")
def objects(world):
steps = {s["ref"]: s for s in world.planner.steps if s.get("ref") and s["verb"] == "post"}
held = driver("privapub").stored([o["uri"] for o in world.objects.values()])
personas = [k for k in world.planner.accounts if k.startswith("privapub/")]
out = []
for ref, o in sorted(world.objects.items(), key=lambda kv: int(kv[0][1:])):
row = held.get(o["uri"])
if not row or not row.exists or row.deleted or ref in world.deleted:
continue
recipients = world.recipients(o)
readers = [p.split("/")[1] for p in personas
if p == o["author"] or p in recipients or o["visibility"] in ("public", "unlisted", "community")]
if not readers:
continue
args = steps[ref]["args"]
out.append({
"ref": ref, "uri": o["uri"], "id": row.local_id, "origin": o["origin"], "author": o["author"],
"kind": o["kind"], "visibility": o["visibility"], "marker": f"({ref})", "cw": edited_cw(world, ref, args),
"alts": [m["alt"] for m in args.get("media", [])], "poll": (args.get("poll") or {}).get("options"),
"quote": world.objects.get(o.get("quote") or "", {}).get("uri"),
"parent": world.objects.get(o.get("parent") or "", {}).get("uri"),
"edited": ref in world.edited, "title": args.get("title"), "readers": readers,
"followers_of_author": sorted(p.split("/")[1] for p in world.follows.get(o["author"], set()) if p.startswith("privapub/")),
})
return out
def main(args):
name = next((a for a in args if not a.startswith("--") and not a.startswith("privapub/")), "village")
persona = None
if "--persona" in args:
persona = args[args.index("--persona") + 1].split("/")[-1]
world = check.World(seed.run_dir(name))
items = objects(world)
if persona:
items = [i for i in items if persona in i["readers"]]
print(json.dumps(items, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+1 -1
View File
@@ -75,7 +75,7 @@
"code": "misskey-dev/misskey packages/backend/src/core/NoteDeleteService.ts: only repliesCount is decremented on delete",
"opened": "2026-10-05",
"status": "open",
"note": "the renote row is gone after our Undo (checked in its database); renoteCount stays up. Sharkey decrements it."
"note": "the renote row is gone after our Undo (checked in its database); renoteCount stays up. Sharkey decrements it. Its match named decePub's e2e cells (client.action.boost), deleted 2026-10-17, so it matches nothing until a town cell checks Misskey's renote count."
},
{
"id": "G-0006",
+1 -1
View File
@@ -1,4 +1,4 @@
"""The plan: a fake community drawn from a spec and its seed, the same on every run (tests/test_gen.py pins its hash).
"""The plan: a fake community drawn from a spec and its seed, the same on every run.
A plan is a list of steps, each {n, phase, round, actor, verb, args, ref}. Accounts are named "<platform>/<username>",
objects by a ref ("p12"); the seeder turns refs into URIs as it goes. Phases run in order:
+2 -15
View File
@@ -12,7 +12,7 @@ import gen
DIRS = ["in", "out", "via", "control", "local"]
DIR_LABEL = {"in": "into PrivaPub", "out": "from PrivaPub", "via": "through PrivaPub", "control": "peer to peer",
"local": "on its own server"}
GROUPS = [("client", "decePub"), ("deliver", "Delivery"), ("relay", "Replies passed on"), ("confine", "Confinement"),
GROUPS = [("deliver", "Delivery"), ("relay", "Replies passed on"), ("confine", "Confinement"),
("see", "Seen by the audience"), ("hide", "Hidden from outsiders"), ("count", "Counts"), ("thread", "Threads"),
("edit", "Edits"), ("delete", "Deletes"), ("graph", "Follows"), ("privacy", "Privacy")]
STATUS_ORDER = ["fail", "xpass", "xfail", "pass"]
@@ -84,7 +84,7 @@ def render(result, scenarios, title):
cells = result["cells"]
totals = Counter(result["totals"])
observers = [p for p in gen.HOSTS if any(k.split("|")[2] == p for k in cells)] + \
[o for o in ("*", "decepub") if any(k.split("|")[2] == o for k in cells)]
[o for o in ("*",) if any(k.split("|")[2] == o for k in cells)]
features = sorted({k.split("|")[0] for k in cells})
out = [f"<title>{html.escape(title)}</title>",
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;600&display=swap">',
@@ -169,19 +169,6 @@ def main(args):
if os.path.exists(path):
with open(path) as f:
scenarios = [json.loads(line) for line in f if line.strip()]
# a client's cells, when any, join the servers' in the same matrix, under the observer "decepub"
client = os.path.join(seed.OUT, "client.jsonl")
if os.path.exists(client):
import check
with open(client) as f:
lines = [json.loads(line) for line in f if line.strip()]
checks = check.classify(check.latest_client(lines), check.load_gaps())
for cell, v in check.summarise(checks).items():
result["cells"][cell] = v
from collections import Counter
totals = Counter(result["totals"])
totals.update(Counter(c["status"] for c in checks))
result["totals"] = totals
page = render(result, scenarios, "PrivaPub Town Matrix")
target = os.path.join(run_dir, "report.html")
with open(target, "w") as f:
Whitespace-only changes.
-79
View File
@@ -1,79 +0,0 @@
"""The town's own logic, with no pasture: python3 -m unittest discover -s tools/pasture/town/tests"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import check # noqa: E402
import gen # noqa: E402
from core.rng import Rng # noqa: E402
class RngTests(unittest.TestCase):
def test_the_same_seed_and_name_give_the_same_stream(self):
a, b = Rng(7, "x"), Rng(7, "x")
self.assertEqual([a.next64() for _ in range(5)], [b.next64() for _ in range(5)])
def test_substreams_do_not_disturb_each_other(self):
root = Rng(7)
before = Rng(7).sub("graph").next64()
root.sub("content").next64()
self.assertEqual(before, root.sub("graph").next64())
def test_below_stays_in_range(self):
r = Rng(1)
self.assertTrue(all(0 <= r.below(3) < 3 for _ in range(1000)))
class PlanTests(unittest.TestCase):
def test_a_spec_always_plans_the_same(self):
spec = gen.load("village")
self.assertEqual(gen.digest(gen.plan(spec)), gen.digest(gen.plan(spec)))
def test_every_post_names_its_marker_and_every_reply_a_parent_planned_before_it(self):
planner = gen.Planner(gen.load("village"))
steps = planner.plan()
made = set()
for s in steps:
if s["verb"] != "post":
continue
self.assertIn(f"({s['ref']})", s["args"]["text"])
if s["args"].get("reply_to"):
self.assertIn(s["args"]["reply_to"], made)
made.add(s["ref"])
def test_lemmy_accounts_never_follow_people_and_platforms_get_only_what_they_can_do(self):
steps = gen.plan(gen.load("village"))
for s in steps:
platform = s["actor"].split("/")[0]
if s["verb"] == "follow":
self.assertNotEqual("lemmy", platform)
if s["verb"] in ("react", "edit", "vote", "boost"):
self.assertIn({"react": "react", "edit": "edit", "vote": "vote", "boost": "boost"}[s["verb"]], gen.CAPS[platform])
def test_a_stored_plan_rebuilds_the_same_world(self):
planner = gen.Planner(gen.load("village"))
planner.plan()
stored = gen.Stored(gen.snapshot(planner))
self.assertEqual(planner.objects.keys(), stored.objects.keys())
self.assertEqual(planner.objects["p1"]["mentions"], stored.objects["p1"]["mentions"])
class GapTests(unittest.TestCase):
def test_a_known_gap_turns_failures_into_xfail_and_passes_into_xpass(self):
gaps = [{"id": "G-9", "match": {"feature": r"count\.like\..*", "observer": "privapub"}, "status": "open"}]
checks = check.classify([
{"feature": "count.like.followers", "observer": "privapub", "ok": False},
{"feature": "count.like.public", "observer": "privapub", "ok": True},
{"feature": "count.like.public", "observer": "gts", "ok": False},
], gaps)
self.assertEqual(["xfail", "xpass", "fail"], [c["status"] for c in checks])
def test_a_closed_gap_matches_nothing(self):
gaps = [{"id": "G-9", "match": {"feature": ".*"}, "status": "closed"}]
self.assertEqual("fail", check.classify([{"feature": "x", "ok": False}], gaps)[0]["status"])
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -59,7 +59,7 @@ def main(argv):
s = state.session(args[0], args[1])
print(json.dumps(driver(args[0]).seen(s, args[2:])))
return 0
if cmd in ("plan", "seed", "check", "report", "backlog", "gaps", "expect", "load"):
if cmd in ("plan", "seed", "check", "report", "backlog"):
module = __import__(cmd if cmd not in ("plan",) else "gen")
return module.main(args)
print(f"unknown command {cmd}", file=sys.stderr)