feat(snippets): record merge provenance on the survivor
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 38s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 1m7s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 38s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 1m7s
Merge kept the target's fields, unioned locations and tags, and trashed the sources — recording nothing about what it absorbed. If a variant handled an edge case the survivor doesn't, that difference left the visible record entirely; recovering it meant knowing to go digging in the trash. The survivor now carries `merged_from`: a "**Merged from:** #2, #3" line in the body for humans, and the same list in the `data` mirror for queries, written from one value like every other field (#2087). It accumulates rather than replaces — a target merged twice keeps both histories — and skipped sources (cross-owner, per #231) are excluded, so the record never claims to contain something it never absorbed. Ordinary edits carry it forward. update_snippet recomposes body and mirror from scratch, so an omission there would silently erase the history on the next unrelated edit; that path is pinned by its own test, including the pre-0070 case where the body line is the only copy. Surfaced in the snippet detail view as a "Merged from" row — the view renders parsed fields, not the raw body, so the body line alone would have been invisible to the operator (rule #27). Un-merge, the other half of #2087, stays open: restoring a source from trash still doesn't strip its locations off the survivor, and what partial un-merge should mean is a design question, not a coding one. `merged_from` is the record that makes it tractable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
@@ -20,6 +20,9 @@ export interface SnippetFields {
|
|||||||
path: string;
|
path: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
locations: SnippetLocation[];
|
locations: SnippetLocation[];
|
||||||
|
/** Ids of the snippets folded into this one by merge, oldest first. Read-only:
|
||||||
|
* merge is the only thing that adds to it, and an edit carries it forward. */
|
||||||
|
merged_from: number[];
|
||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const canWrite = computed(() => {
|
|||||||
|
|
||||||
const locations = computed(() => snippet.value?.snippet.locations ?? []);
|
const locations = computed(() => snippet.value?.snippet.locations ?? []);
|
||||||
|
|
||||||
|
// What this record absorbed. Merge keeps the target's fields and trashes the
|
||||||
|
// sources, so this line is the only visible trace that the variants existed.
|
||||||
|
const mergedFrom = computed(() => snippet.value?.snippet.merged_from ?? []);
|
||||||
|
|
||||||
function locParts(loc: { repo: string; path: string; symbol: string }): string[] {
|
function locParts(loc: { repo: string; path: string; symbol: string }): string[] {
|
||||||
return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
|
return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
|
||||||
}
|
}
|
||||||
@@ -110,6 +114,15 @@ async function confirmDelete() {
|
|||||||
<dt>Language</dt>
|
<dt>Language</dt>
|
||||||
<dd>{{ snippet.snippet.language }}</dd>
|
<dd>{{ snippet.snippet.language }}</dd>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="mergedFrom.length">
|
||||||
|
<dt>Merged from</dt>
|
||||||
|
<dd class="merged-from">
|
||||||
|
<span v-for="mid in mergedFrom" :key="mid">#{{ mid }}</span>
|
||||||
|
<span class="merged-hint">
|
||||||
|
folded in here — the originals are in the trash
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</template>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div class="code-block">
|
<div class="code-block">
|
||||||
@@ -274,6 +287,16 @@ async function confirmDelete() {
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.merged-from {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.merged-hint {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.code-block {
|
.code-block {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -243,6 +243,10 @@ async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
|
|||||||
trash (recoverable). The survivor is re-embedded so recall stops surfacing the
|
trash (recoverable). The survivor is re-embedded so recall stops surfacing the
|
||||||
now-merged duplicates.
|
now-merged duplicates.
|
||||||
|
|
||||||
|
The survivor records what it absorbed as `merged_from` (in its `snippet`
|
||||||
|
fields and as a "Merged from: #ids" line in the body), so a variant that got
|
||||||
|
folded in leaves a trace outside the trash. It accumulates across merges.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
target_id: The snippet to keep (the canonical record).
|
target_id: The snippet to keep (the canonical record).
|
||||||
source_ids: Snippet ids to fold into the target and retire. Ids that
|
source_ids: Snippet ids to fold into the target and retire. Ids that
|
||||||
|
|||||||
@@ -120,6 +120,23 @@ def _render_location_block(locations: list[dict]) -> str | None:
|
|||||||
return f"**Locations:**\n{lines}"
|
return f"**Locations:**\n{lines}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_merged_from(ids: list[int] | None) -> list[int]:
|
||||||
|
"""Merge provenance as a clean id list: ints only, no dups, order kept.
|
||||||
|
|
||||||
|
Order is history, not sorting — earlier merges stay first, so the list reads
|
||||||
|
as the sequence of things folded in.
|
||||||
|
"""
|
||||||
|
out: list[int] = []
|
||||||
|
for raw in ids or []:
|
||||||
|
try:
|
||||||
|
i = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if i > 0 and i not in out:
|
||||||
|
out.append(i)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def compose_body(
|
def compose_body(
|
||||||
*,
|
*,
|
||||||
code: str,
|
code: str,
|
||||||
@@ -130,6 +147,7 @@ def compose_body(
|
|||||||
path: str = "",
|
path: str = "",
|
||||||
symbol: str = "",
|
symbol: str = "",
|
||||||
locations: list[dict] | None = None,
|
locations: list[dict] | None = None,
|
||||||
|
merged_from: list[int] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render structured fields into the snippet body markdown. Empty fields are
|
"""Render structured fields into the snippet body markdown. Empty fields are
|
||||||
omitted so the body stays clean.
|
omitted so the body stays clean.
|
||||||
@@ -151,6 +169,14 @@ def compose_body(
|
|||||||
loc_block = _render_location_block(locs)
|
loc_block = _render_location_block(locs)
|
||||||
if loc_block:
|
if loc_block:
|
||||||
header.append(loc_block)
|
header.append(loc_block)
|
||||||
|
merged = _normalize_merged_from(merged_from)
|
||||||
|
if merged:
|
||||||
|
# Human-readable mirror of data["merged_from"]. Without it, a merge folds
|
||||||
|
# variants in and the record of what was absorbed lives only in the trash
|
||||||
|
# — recoverable only by someone who already knows to go looking.
|
||||||
|
header.append(
|
||||||
|
"**Merged from:** " + ", ".join(f"#{i}" for i in merged)
|
||||||
|
)
|
||||||
fence_lang = (language or "").strip().lower()
|
fence_lang = (language or "").strip().lower()
|
||||||
code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```"
|
code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```"
|
||||||
if header:
|
if header:
|
||||||
@@ -166,7 +192,9 @@ _LOC_RE = re.compile(r"^\*\*Location:\*\*\s*(.+?)\s*$", re.MULTILINE)
|
|||||||
_LOCS_RE = re.compile(
|
_LOCS_RE = re.compile(
|
||||||
r"^\*\*Locations:\*\*[ \t]*\n((?:[ \t]*-[ \t]*.+\n?)+)", re.MULTILINE
|
r"^\*\*Locations:\*\*[ \t]*\n((?:[ \t]*-[ \t]*.+\n?)+)", re.MULTILINE
|
||||||
)
|
)
|
||||||
|
_MERGED_RE = re.compile(r"^\*\*Merged from:\*\*\s*(.+?)\s*$", re.MULTILINE)
|
||||||
_CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL)
|
_CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL)
|
||||||
|
_ID_RE = re.compile(r"#(\d+)")
|
||||||
|
|
||||||
|
|
||||||
def _parse_location_str(s: str) -> dict | None:
|
def _parse_location_str(s: str) -> dict | None:
|
||||||
@@ -202,6 +230,7 @@ def parse_snippet_fields(
|
|||||||
"path": "",
|
"path": "",
|
||||||
"symbol": "",
|
"symbol": "",
|
||||||
"locations": [],
|
"locations": [],
|
||||||
|
"merged_from": [],
|
||||||
"code": "",
|
"code": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +264,12 @@ def parse_snippet_fields(
|
|||||||
fields["path"] = locations[0]["path"]
|
fields["path"] = locations[0]["path"]
|
||||||
fields["symbol"] = locations[0]["symbol"]
|
fields["symbol"] = locations[0]["symbol"]
|
||||||
|
|
||||||
|
m = _MERGED_RE.search(body)
|
||||||
|
if m:
|
||||||
|
fields["merged_from"] = _normalize_merged_from(
|
||||||
|
[int(i) for i in _ID_RE.findall(m.group(1))]
|
||||||
|
)
|
||||||
|
|
||||||
m = _CODE_RE.search(body)
|
m = _CODE_RE.search(body)
|
||||||
if m:
|
if m:
|
||||||
fields["language"] = m.group(1).strip()
|
fields["language"] = m.group(1).strip()
|
||||||
@@ -254,7 +289,9 @@ def parse_snippet_fields(
|
|||||||
|
|
||||||
# Fields kept in `data`. Code is deliberately absent: the body already holds it,
|
# Fields kept in `data`. Code is deliberately absent: the body already holds it,
|
||||||
# and copying a blob into the column we index *around* would be pure weight.
|
# and copying a blob into the column we index *around* would be pure weight.
|
||||||
_DATA_FIELDS = ("name", "when_to_use", "signature", "language", "locations")
|
_DATA_FIELDS = (
|
||||||
|
"name", "when_to_use", "signature", "language", "locations", "merged_from",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def compose_data(
|
def compose_data(
|
||||||
@@ -264,6 +301,7 @@ def compose_data(
|
|||||||
signature: str = "",
|
signature: str = "",
|
||||||
language: str = "",
|
language: str = "",
|
||||||
locations: list[dict] | None = None,
|
locations: list[dict] | None = None,
|
||||||
|
merged_from: list[int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Build the `notes.data` mirror of a snippet's structured fields.
|
"""Build the `notes.data` mirror of a snippet's structured fields.
|
||||||
|
|
||||||
@@ -284,6 +322,9 @@ def compose_data(
|
|||||||
locs = _normalize_locations(locations)
|
locs = _normalize_locations(locations)
|
||||||
if locs:
|
if locs:
|
||||||
out["locations"] = locs
|
out["locations"] = locs
|
||||||
|
merged = _normalize_merged_from(merged_from)
|
||||||
|
if merged:
|
||||||
|
out["merged_from"] = merged
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -478,6 +519,9 @@ async def update_snippet(
|
|||||||
code=merged["code"], language=merged["language"],
|
code=merged["code"], language=merged["language"],
|
||||||
signature=merged["signature"], when_to_use=merged["when_to_use"],
|
signature=merged["signature"], when_to_use=merged["when_to_use"],
|
||||||
locations=merged_locations,
|
locations=merged_locations,
|
||||||
|
# Carried, never set here: an ordinary edit must not erase the record
|
||||||
|
# of what was folded in, and only a merge may add to it.
|
||||||
|
merged_from=merged.get("merged_from"),
|
||||||
),
|
),
|
||||||
# Re-derived from the same merged field set as the body, so an edit can't
|
# Re-derived from the same merged field set as the body, so an edit can't
|
||||||
# leave the indexed mirror describing the previous version.
|
# leave the indexed mirror describing the previous version.
|
||||||
@@ -485,6 +529,7 @@ async def update_snippet(
|
|||||||
name=merged["name"], when_to_use=merged["when_to_use"],
|
name=merged["name"], when_to_use=merged["when_to_use"],
|
||||||
signature=merged["signature"], language=merged["language"],
|
signature=merged["signature"], language=merged["language"],
|
||||||
locations=merged_locations,
|
locations=merged_locations,
|
||||||
|
merged_from=merged.get("merged_from"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
# Recompute tags: keep any non-language, non-marker tags the note already had
|
# Recompute tags: keep any non-language, non-marker tags the note already had
|
||||||
@@ -566,8 +611,16 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
|
|||||||
cross-owner merge stays out of scope (#231) — and must itself be writable;
|
cross-owner merge stays out of scope (#231) — and must itself be writable;
|
||||||
sources failing either test are skipped rather than silently half-merged.
|
sources failing either test are skipped rather than silently half-merged.
|
||||||
|
|
||||||
|
The survivor records what it absorbed as `merged_from` — in the `data` mirror
|
||||||
|
and as a `**Merged from:** #ids` line in the body, written from one value like
|
||||||
|
every other field. It accumulates across merges and ordinary edits carry it
|
||||||
|
forward; only a merge adds to it.
|
||||||
|
|
||||||
Returns (merged_target_note, merged_source_ids), or None if the target isn't
|
Returns (merged_target_note, merged_source_ids), or None if the target isn't
|
||||||
a snippet the caller can see."""
|
a snippet the caller can see. Note the returned ids are the sources actually
|
||||||
|
TRASHED, while `merged_from` is everything folded in — they differ only if a
|
||||||
|
source vanished between the field union and the trash call, which leaves the
|
||||||
|
source visible rather than losing anything."""
|
||||||
from scribe.services.access import can_write_note
|
from scribe.services.access import can_write_note
|
||||||
target = await get_snippet(user_id, target_id)
|
target = await get_snippet(user_id, target_id)
|
||||||
if target is None:
|
if target is None:
|
||||||
@@ -596,13 +649,22 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
|
|||||||
parsed_sources = [(snippet_fields(s), s.tags) for s in sources]
|
parsed_sources = [(snippet_fields(s), s.tags) for s in sources]
|
||||||
locations, extra_tags = merge_snippet_fields(tgt_fields, target.tags, parsed_sources)
|
locations, extra_tags = merge_snippet_fields(tgt_fields, target.tags, parsed_sources)
|
||||||
|
|
||||||
|
# Provenance: what this record absorbed, and when it absorbed it, in order.
|
||||||
|
# Merge keeps the target's scalar fields and trashes the sources, so without
|
||||||
|
# this the fact that a variant ever existed survives only in the trash — and
|
||||||
|
# only for someone who already knew to go looking. Accumulated, not replaced:
|
||||||
|
# a target merged twice keeps both histories.
|
||||||
|
merged_from = _normalize_merged_from(
|
||||||
|
list(tgt_fields.get("merged_from") or []) + [s.id for s in sources]
|
||||||
|
)
|
||||||
|
|
||||||
# Owner-scoped write, authorised above — same reason as update_snippet.
|
# Owner-scoped write, authorised above — same reason as update_snippet.
|
||||||
updated = await notes_svc.update_note(
|
updated = await notes_svc.update_note(
|
||||||
owner_id, target_id,
|
owner_id, target_id,
|
||||||
body=compose_body(
|
body=compose_body(
|
||||||
code=tgt_fields["code"], language=tgt_fields["language"],
|
code=tgt_fields["code"], language=tgt_fields["language"],
|
||||||
signature=tgt_fields["signature"], when_to_use=tgt_fields["when_to_use"],
|
signature=tgt_fields["signature"], when_to_use=tgt_fields["when_to_use"],
|
||||||
locations=locations,
|
locations=locations, merged_from=merged_from,
|
||||||
),
|
),
|
||||||
tags=compose_tags(tgt_fields["language"], extra_tags),
|
tags=compose_tags(tgt_fields["language"], extra_tags),
|
||||||
# The survivor's location set grew, so its mirror has to grow with it —
|
# The survivor's location set grew, so its mirror has to grow with it —
|
||||||
@@ -611,7 +673,7 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
|
|||||||
data=compose_data(
|
data=compose_data(
|
||||||
name=tgt_fields["name"], when_to_use=tgt_fields["when_to_use"],
|
name=tgt_fields["name"], when_to_use=tgt_fields["when_to_use"],
|
||||||
signature=tgt_fields["signature"], language=tgt_fields["language"],
|
signature=tgt_fields["signature"], language=tgt_fields["language"],
|
||||||
locations=locations,
|
locations=locations, merged_from=merged_from,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
|
|||||||
@@ -235,20 +235,57 @@ def test_data_and_body_round_trip_to_the_same_fields():
|
|||||||
# serializers get their own argument lists rather than a shared spread.
|
# serializers get their own argument lists rather than a shared spread.
|
||||||
title = s.compose_title(name, when)
|
title = s.compose_title(name, when)
|
||||||
body = s.compose_body(code="const x = 1", language=lang, signature=sig,
|
body = s.compose_body(code="const x = 1", language=lang, signature=sig,
|
||||||
when_to_use=when, locations=locs)
|
when_to_use=when, locations=locs, merged_from=[41, 42])
|
||||||
tags = s.compose_tags(lang)
|
tags = s.compose_tags(lang)
|
||||||
|
|
||||||
from_body = s.snippet_fields(_Note(title=title, body=body, tags=tags))
|
from_body = s.snippet_fields(_Note(title=title, body=body, tags=tags))
|
||||||
from_data = s.snippet_fields(_Note(
|
from_data = s.snippet_fields(_Note(
|
||||||
title=title, body=body, tags=tags,
|
title=title, body=body, tags=tags,
|
||||||
data=s.compose_data(name=name, when_to_use=when, signature=sig,
|
data=s.compose_data(name=name, when_to_use=when, signature=sig,
|
||||||
language=lang, locations=locs),
|
language=lang, locations=locs, merged_from=[41, 42]),
|
||||||
))
|
))
|
||||||
for key in ("name", "when_to_use", "signature", "language", "locations",
|
for key in ("name", "when_to_use", "signature", "language", "locations",
|
||||||
"repo", "path", "symbol", "code"):
|
"merged_from", "repo", "path", "symbol", "code"):
|
||||||
assert from_body[key] == from_data[key], key
|
assert from_body[key] == from_data[key], key
|
||||||
|
|
||||||
|
|
||||||
|
# --- merge provenance (#2087) ------------------------------------------------
|
||||||
|
|
||||||
|
def test_normalize_merged_from_keeps_history_order_and_drops_junk():
|
||||||
|
"""Order is history, not sorting — earlier merges stay first."""
|
||||||
|
assert s._normalize_merged_from([9, 3, 9, "4", None, 0, -2, "x"]) == [9, 3, 4]
|
||||||
|
assert s._normalize_merged_from(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_body_renders_merged_from_and_parse_reads_it_back():
|
||||||
|
body = s.compose_body(code="x = 1", merged_from=[12, 13])
|
||||||
|
assert "**Merged from:** #12, #13" in body
|
||||||
|
got = s.parse_snippet_fields("n — u", body, None)
|
||||||
|
assert got["merged_from"] == [12, 13]
|
||||||
|
# The code fence still comes last — provenance is header metadata.
|
||||||
|
assert body.rstrip().endswith("```")
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_body_omits_merged_from_when_there_is_none():
|
||||||
|
"""A snippet that was never merged says nothing about merging."""
|
||||||
|
assert "Merged from" not in s.compose_body(code="x = 1")
|
||||||
|
assert s.parse_snippet_fields("n — u", "```\nx = 1\n```", None)["merged_from"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_data_mirrors_merged_from():
|
||||||
|
assert s.compose_data(name="f", merged_from=[3, 3, 2])["merged_from"] == [3, 2]
|
||||||
|
assert "merged_from" not in s.compose_data(name="f")
|
||||||
|
|
||||||
|
|
||||||
|
def test_snippet_fields_prefers_the_data_columns_merged_from():
|
||||||
|
"""Same rule as every other mirrored field: `data` wins when it's populated,
|
||||||
|
so a hand-edited body can't silently rewrite the merge history."""
|
||||||
|
body = s.compose_body(code="x = 1", merged_from=[12])
|
||||||
|
note = _Note(title="n — u", body=body, tags=["snippet"],
|
||||||
|
data=s.compose_data(name="n", merged_from=[12, 13]))
|
||||||
|
assert s.snippet_fields(note)["merged_from"] == [12, 13]
|
||||||
|
|
||||||
|
|
||||||
def test_snippet_to_dict_includes_parsed_fields():
|
def test_snippet_to_dict_includes_parsed_fields():
|
||||||
class FakeNote:
|
class FakeNote:
|
||||||
title = "debounce — rate-limit"
|
title = "debounce — rate-limit"
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Merge records what it absorbed (#2087).
|
||||||
|
|
||||||
|
Merging keeps the target's scalar fields, unions locations + tags, and trashes
|
||||||
|
the sources. Without provenance the fact that a variant ever existed survives
|
||||||
|
only in the trash — and only for someone who already knew to go looking. So the
|
||||||
|
survivor carries `merged_from`, written to the body and the indexed mirror from
|
||||||
|
one value, and ordinary edits must carry it forward rather than erase it.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scribe.services import snippets as s
|
||||||
|
|
||||||
|
|
||||||
|
def _snippet(id=1, owner=9, body=None, data=None, tags=None):
|
||||||
|
n = MagicMock()
|
||||||
|
n.id = id
|
||||||
|
n.user_id = owner
|
||||||
|
n.title = "formatDuration — humanize a millisecond count"
|
||||||
|
n.body = body if body is not None else s.compose_body(code="x = 1", language="ts")
|
||||||
|
n.tags = tags if tags is not None else ["ts", "snippet"]
|
||||||
|
n.note_type = "snippet"
|
||||||
|
n.deleted_at = None
|
||||||
|
# Explicitly None — snippet_fields prefers `data` when truthy, and an
|
||||||
|
# auto-MagicMock attribute is truthy (note 2109).
|
||||||
|
n.data = data
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_merge(target, sources, source_ids):
|
||||||
|
"""Drive merge_snippets with the DB stubbed out; return update_note's kwargs."""
|
||||||
|
by_id = {target.id: target, **{x.id: x for x in sources}}
|
||||||
|
|
||||||
|
async def fake_get(_uid, sid):
|
||||||
|
return by_id.get(sid)
|
||||||
|
|
||||||
|
with patch.object(s, "get_snippet", AsyncMock(side_effect=fake_get)), \
|
||||||
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
|
||||||
|
patch.object(s.notes_svc, "update_note",
|
||||||
|
AsyncMock(return_value=target)) as mock_update, \
|
||||||
|
patch("scribe.services.trash.delete", AsyncMock(return_value=object())), \
|
||||||
|
patch.object(s, "_embed_snippet", MagicMock()):
|
||||||
|
await s.merge_snippets(7, target.id, source_ids)
|
||||||
|
return mock_update.await_args.kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_merge_records_what_it_absorbed_in_body_and_mirror():
|
||||||
|
kwargs = await _run_merge(
|
||||||
|
_snippet(id=1), [_snippet(id=2), _snippet(id=3)], [2, 3],
|
||||||
|
)
|
||||||
|
assert "**Merged from:** #2, #3" in kwargs["body"]
|
||||||
|
assert kwargs["data"]["merged_from"] == [2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_merge_accumulates_across_merges():
|
||||||
|
"""A target merged twice keeps both histories — the second merge must not
|
||||||
|
replace the record of the first."""
|
||||||
|
target = _snippet(id=1, data=s.compose_data(name="f", merged_from=[2]))
|
||||||
|
kwargs = await _run_merge(target, [_snippet(id=3)], [3])
|
||||||
|
assert kwargs["data"]["merged_from"] == [2, 3]
|
||||||
|
assert "**Merged from:** #2, #3" in kwargs["body"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_merge_does_not_record_a_skipped_cross_owner_source():
|
||||||
|
"""A source owned by someone else is skipped, not folded in (#231) — so it
|
||||||
|
must not appear in the provenance either, or the record would claim to
|
||||||
|
contain something it never absorbed."""
|
||||||
|
kwargs = await _run_merge(
|
||||||
|
_snippet(id=1, owner=9),
|
||||||
|
[_snippet(id=2, owner=9), _snippet(id=3, owner=42)],
|
||||||
|
[2, 3],
|
||||||
|
)
|
||||||
|
assert kwargs["data"]["merged_from"] == [2]
|
||||||
|
assert "#3" not in kwargs["body"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_ordinary_edit_carries_provenance_forward():
|
||||||
|
"""Only a merge may add to it, but nothing else may drop it — update
|
||||||
|
recomposes body and mirror from scratch, so an omission here would silently
|
||||||
|
erase the history on the next unrelated edit."""
|
||||||
|
note = _snippet(id=1, data=s.compose_data(name="f", language="ts",
|
||||||
|
merged_from=[2, 3]))
|
||||||
|
with patch.object(s, "get_snippet", AsyncMock(return_value=note)), \
|
||||||
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
|
||||||
|
patch.object(s.notes_svc, "update_note",
|
||||||
|
AsyncMock(return_value=note)) as mock_update, \
|
||||||
|
patch.object(s, "_embed_snippet", MagicMock()):
|
||||||
|
await s.update_snippet(7, 1, signature="f(ms) -> string")
|
||||||
|
|
||||||
|
kwargs = mock_update.await_args.kwargs
|
||||||
|
assert kwargs["data"]["merged_from"] == [2, 3]
|
||||||
|
assert "**Merged from:** #2, #3" in kwargs["body"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_pre_0070_row_keeps_provenance_through_the_body():
|
||||||
|
"""Rows written before the `data` column are never backfilled, so the body
|
||||||
|
line is their only copy — and it has to survive an edit too."""
|
||||||
|
body = s.compose_body(code="x = 1", language="ts", merged_from=[5])
|
||||||
|
note = _snippet(id=1, body=body, data=None)
|
||||||
|
with patch.object(s, "get_snippet", AsyncMock(return_value=note)), \
|
||||||
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
|
||||||
|
patch.object(s.notes_svc, "update_note",
|
||||||
|
AsyncMock(return_value=note)) as mock_update, \
|
||||||
|
patch.object(s, "_embed_snippet", MagicMock()):
|
||||||
|
await s.update_snippet(7, 1, when_to_use="humanize a ms count")
|
||||||
|
|
||||||
|
assert "**Merged from:** #5" in mock_update.await_args.kwargs["body"]
|
||||||
Reference in New Issue
Block a user