Files
DECNET/tests/realism/test_edit.py
anti b321e29002 feat(realism): EditAction read-modify-write of planted files
Stage 3b of the realism migration. A TODO.md planted on Monday gets a
checkbox flipped on Tuesday; a notes file grows a follow-up line; a
cron log gets a fresh entry tacked on. The synthetic_files row's
edit_count, last_modified, and content_hash advance.

New surface:

- EditAction dataclass (peer of FileAction in scheduler.py): carries
  decky, path, persona, content_class, previous_body, mtime, and
  synthetic_file_uuid for the worker's update path.
- realism.bodies.next_iteration(cls, persona, prev, rng): per-class
  deterministic mutators. TODO flips an unchecked box and/or appends;
  notes/drafts/scripts append; logs are append-only (mirroring real
  log behaviour). Canary, cache_tmp, email raise KeyError —
  unsupported.
- realism.planner.pick gains an edit branch: 60% create, 30% edit
  (when an edit_candidate is supplied), 10% leave-alone. Returns
  None on leave-alone — quiet ticks are realism too.
- scheduler.pick_file pre-fetches a single edit candidate via
  repo.pick_random_synthetic_file_for_edit ~50% of ticks; the
  planner decides whether to use it.
- SSHDriver._run_edit: turns next_iteration output into a
  plant_file call (mtime-bumped, mode 0o644). Stashes new_body in
  result.payload so the worker can hash it for synthetic_files.
- worker._bump_synthetic_file_after_edit: patches edit_count + 1,
  last_modified=now, content_hash, last_body for the row UUID.
  No-op when the row was pruned mid-flight.
- events.to_row / topic_for / event_type_for now recognise
  EditAction (kind="file", action="file:edit").
2026-04-27 16:38:17 -04:00

99 lines
3.1 KiB
Python

"""next_iteration mutators per content class.
Stage 3b — read-modify-write contract: each editor takes a previous
body and returns a plausible next iteration. Append-only for logs;
small in-place edits for user content.
"""
from __future__ import annotations
import random
import pytest
from decnet.realism.bodies import next_iteration
from decnet.realism.taxonomy import ContentClass
def test_todo_edit_can_flip_an_unchecked_box() -> None:
prev = "- [ ] rotate keys\n- [ ] review pr\n"
seen_flip = False
for seed in range(40):
new = next_iteration(
ContentClass.TODO, "admin", prev, rand=random.Random(seed),
)
if "[x]" in new and "rotate" in new and "[x] rotate" in new:
seen_flip = True
if "[x]" in new and "[x] review" in new:
seen_flip = True
if seen_flip:
break
assert seen_flip, "no checkbox flip across 40 seeds — mutator broken"
def test_todo_edit_grows_or_holds_line_count() -> None:
prev = "- [ ] rotate keys\n"
new = next_iteration(
ContentClass.TODO, "admin", prev, rand=random.Random(0),
)
# Mutators may flip a box (same line count) or append (more lines)
# — but never shrink the file.
assert len(new.splitlines()) >= len(prev.splitlines())
def test_log_cron_edit_is_append_only() -> None:
prev = (
"Apr 27 09:00:01 hostname CRON[1234]: (root) CMD (run-parts /etc/cron.daily)\n"
)
new = next_iteration(
ContentClass.LOG_CRON, "admin", prev, rand=random.Random(0),
)
assert new.startswith(prev.rstrip())
assert len(new.splitlines()) > len(prev.splitlines())
def test_log_daemon_edit_is_append_only() -> None:
prev = "Apr 27 09:00:01 hostname systemd[1]: Started Daily apt download activities.\n"
new = next_iteration(
ContentClass.LOG_DAEMON, "admin", prev, rand=random.Random(0),
)
assert new.startswith(prev.rstrip())
def test_note_edit_grows_the_body() -> None:
prev = "remember to ping the on-call\n"
new = next_iteration(
ContentClass.NOTE, "admin", prev, rand=random.Random(0),
)
assert prev in new
assert len(new) > len(prev)
def test_draft_edit_appends_paragraph() -> None:
prev = "Hi team,\n\nQuick update.\n"
new = next_iteration(
ContentClass.DRAFT, "admin", prev, rand=random.Random(0),
)
assert new.startswith(prev.rstrip())
assert len(new) > len(prev)
def test_script_edit_appends_comment() -> None:
prev = "#!/usr/bin/env bash\nset -e\necho 'hi'\n"
new = next_iteration(
ContentClass.SCRIPT, "admin", prev, rand=random.Random(0),
)
assert new.startswith(prev.rstrip())
# New tail must be a comment (the editor's contract); never a
# silently-injected new exec line.
new_tail = new[len(prev.rstrip()):].strip()
assert new_tail.startswith("#")
@pytest.mark.parametrize("cls", [
ContentClass.CACHE_TMP, ContentClass.EMAIL,
ContentClass.CANARY_AWS_CREDS, ContentClass.CANARY_HONEYDOC,
])
def test_unsupported_classes_raise_in_edit(cls: ContentClass) -> None:
with pytest.raises(KeyError):
next_iteration(cls, "admin", "anything")