mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
905876af04
* feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b) Phase 2b of PLAN-1593 (TASK-1597). Completes the wiki-link reverse index by indexing and surfacing `[[workspace::REF]]` cross-workspace references. Builds on Phase 2a's title work (PR #621). Phase 3 (TASK-1596) owns the UI/MCP/CLI rendering changes. What changed - internal/store/backlinks_visibility.go (new): request-independent ACL helper `Store.ResolveBacklinksVisibility(userID, workspaceID, includeDeletedItems)`. Mirrors the role-determination + collection- merge logic from server.guestResourceFilterCore but doesn't depend on a request context, so cross-ws traversal can compute per-source- workspace ACLs without a `workspaceRole(r)` lookup. The Codex planning-round review caught the prior plan reusing the request- scoped helper as a hidden architectural cost; this is the resolution. - internal/server/server.go: guestResourceFilterCore refactored to delegate to the new store helper. Keeps the request-scoped wrapper signature stable for all existing handler call sites; only the internals move. - internal/links/extract.go: lift the Phase-2a workspace_ref emit gate. WikiLinkKindWorkspaceRef now flows through ExtractWikiLinks alongside ref and title kinds. parseBody recognition was already in place from earlier rounds. - internal/store/wiki_links.go: WikiLinkKindWorkspaceRef branch in replaceWikiLinks stores (target_workspace_id, target_ref) verbatim, resolving the slug→ID via new resolveWorkspaceSlugTx (with per-call cache so repeated `[[ws::X]]` in one body don't re-query). Unknown slugs persist with target_workspace_id=NULL — broken-link semantics, identical to existing ref/title patterns. - internal/store/wiki_links.go: new `Store.GetCrossWorkspaceBacklinks` enumerates accessible workspaces via Store.GetUserWorkspaces (which includes guest-only access — broader than membership query), then per-workspace computes visibility via ResolveBacklinksVisibility and runs the SQL backlinks query with the per-ws (FullCollectionIDs, GrantedItemIDs) predicate inline. Results sorted by updated_at DESC in Go, paginated globally. Per-workspace safety cap (offset+limit) prevents one workspace from dominating the global slice. - internal/store/wiki_links.go: new `Store.CountBacklinks` for same-ws pagination boundary detection. Needed so the handler knows where the cross-ws tier begins for pages 2+. - internal/models/backlink.go: new `SourceWorkspaceSlug string` (omitempty) field. Populated only by cross-ws rows; same-ws rows leave it empty so the existing wire shape is preserved. - internal/server/handlers_backlinks.go: union pagination across same-ws and cross-ws tiers. Same-ws first (matches the renderer's UI mental model — your own workspace's links at the top of the panel). Count-based slice math handles pages 2+ correctly when same-ws is exhausted. Tests - internal/links/extract_test.go: workspace_ref forms emit correctly (bare, display alias, mixed case, invalid-slug fallback to title). - internal/store/wiki_links_xws_test.go (new): six cross-ws scenarios plus a role-matrix test: - end-to-end cross-ws index + query - non-member sees nothing - guest with collection grant sees only that collection - guest with item grant sees only the granted item - unknown workspace slug → broken row, no query results - same-ws rows leave SourceWorkspaceSlug empty - ResolveBacklinksVisibility role matrix (admin/full member/guest with grants/non-member non-grant) Out of scope (Phase 3 / TASK-1596) UI rendering of cross-ws backlinks (workspace badge + workspace- prefixed ref), MCP `pad_item.action: backlinks` cross-ws fields, CLI display tweaks. PLAN-1593 / TASK-1597. * fix(backlinks): admin enumeration + cross-prefix ref fallback + unbounded perWsCap (Codex round 1) Three P2 findings from Codex round 1 against PR #622: Finding 1 — admin users miss cross-ws backlinks. `GetUserWorkspaces` returns only memberships + grant-only guest workspaces, but RequireWorkspaceAccess (middleware_auth.go:481) gives admins implicit access to every workspace. An admin querying for backlinks would silently miss links from workspaces they're not explicitly a member of. Fix: in GetCrossWorkspaceBacklinks, branch on user.Role: - admin → s.ListWorkspaces() (every non-deleted workspace) - non-admin → s.GetUserWorkspaces (memberships + grants) Stale user IDs return empty result rather than erroring. Finding 2 — cross-ws ref matching doesn't handle cross-prefix moves. Same-ws is immune because target_item_id is resolved at parse time and survives renames/moves; cross-ws resolves at query time, so a `[[other-ws::OLD-42]]` row written before the target moved from OLD→NEW collection wouldn't match a query under the NEW ref. Fix: in queryCrossWorkspaceBacklinksForWorkspace, dual ref-match clause: exact `LOWER(wl.target_ref) = LOWER(?)` OR `LOWER(wl.target_ref) LIKE LOWER('%-N')` where N is the item_number from the target ref. Pad prefixes are alphanumeric with no internal `-`, so trailing `-N` uniquely identifies the number suffix — no false positives like "TASK-142" matching "%-42" (LIKE anchors to the trailing literal). Finding 3 — per-workspace cap of 1000 silently broke pagination beyond offset>=1000. The 1000 ceiling was defensive paranoia; the correct math is offset+limit per workspace (worst case all rows come from one workspace and the global slice still needs that many). Fix: drop the 1000 ceiling. perWsCap = offset+limit unconditionally. For runaway offsets the per-workspace transfer cost is proportional; documented as a known characteristic (callers shouldn't be paging past offset=10000 anyway). Regression tests: - TestWikiLinks_CrossWorkspaceAdminSeesAllWorkspaces: admin sees cross-ws backlink without being a workspace member. - TestWikiLinks_CrossWorkspaceRefNumberFallback: move target to new collection, query under new ref, old-ref-stored row still surfaces. PLAN-1593 / TASK-1597. * fix(backlinks): honor OAuth/MCP token workspace allow-list (Codex round 2) Codex round 2 P1: cross-workspace backlinks bypassed the OAuth/MCP token's workspace allow-list (TASK-952). A token consented for workspace A but with the underlying user having access to B would still surface source rows from B via the cross-ws query — leaking data outside the token's consent scope. Fix: thread `allowedWorkspaceSlugs []string` through GetCrossWorkspaceBacklinks. Handler populates it from TokenAllowedWorkspacesFromContext(r.Context()): - nil → no token gate (PAT or pre-TASK-952 token, allow all) - "*" wildcard → allow all - explicit list → strict slug membership Workspace enumeration skips any source workspace whose slug isn't in the allowlist. The same-ws path is unchanged because RequireWorkspaceAccess already gated the target workspace against the allow-list (so we only reach this handler when the target IS in the list). Regression test in wiki_links_xws_test.go covers four shapes: nil, wildcard, target-only (blocks cross-ws), explicit source-workspace (allows cross-ws). PLAN-1593 / TASK-1597. * fix(backlinks): normalize limit at handler boundary (Codex round 3) Codex round 3 P2: the backlinks handler parsed ?limit=N but didn't normalize it before computing the same-ws/cross-ws pagination split. GetBacklinks and GetCrossWorkspaceBacklinks each clamp >300 internally, but the handler's 'remaining := limit - len(sameWs)' used the original (potentially huge) value. With ?limit=301 and more than 50 same-ws backlinks, the first page would mix cross-ws in before same-ws was exhausted, violating the documented tier order. Fix: clamp 'limit' to <=300 at the handler boundary, before any pagination math runs. PLAN-1593 / TASK-1597. * fix(backlinks): normalize same-workspace [[ws::REF]] to ref-kind (Codex round 4) Codex round 4 P2: `[[<current-ws>::TASK-1]]` was being indexed as a workspace_ref row with target_workspace_id = current workspace. But the same-ws GetBacklinks query requires target_item_id (workspace_ref rows leave it NULL), AND GetCrossWorkspaceBacklinks explicitly skips the target workspace — so the link rendered and navigated correctly in the UI but no backlink ever surfaced. The renderer's L307 short-circuits same-workspace fully-qualified form to behave identically to `[[REF]]`; the index must follow. Fix: in replaceWikiLinks, normalize a workspace_ref link to ref-kind when its slug resolves to the current workspace. The promotion canonicalizes the ref (via new links.CanonicalizeRef exported alias) so `[[ws::task-5]]` stores the same canonical shape as `[[TASK-5]]`. Tests: - TestWikiLinks_CrossWorkspaceSameWorkspaceQualifiedNormalized: same-ws fully-qualified `[[ws::REF]]` surfaces in same-ws backlinks and is absent from cross-ws backlinks. PLAN-1593 / TASK-1597. * fix(backlinks): same-ws qualified ref miss doesn't title-fallback (Codex round 5) Codex round 5 P2: my round-4 normalization was too aggressive. It promoted `[[<current-ws>::REF]]` to ref-kind and let the regular ref branch handle it — including the title-fallback path that runs on ref miss. But the renderer's same-ws qualified branch (markdown.ts:472-481) does NOT title-fallback: a ref miss in that path returns the wiki-link verbatim (broken). Only the bare `[[REF]]` path (markdown.ts:513) falls through to title lookup. So my normalization could create ghost backlinks for source bodies like `[[ws::ISO-9001]]` when an item titled "ISO-9001" exists but no ISO collection — the renderer renders broken text, but the index would point at the title-matching item. Fix: handle same-ws qualified refs inline at the top of the loop, BEFORE the switch dispatches. Insert as ref-kind row (resolved or NULL) and `continue` past the switch. Bypasses the title-fallback path entirely, mirroring the renderer's behavior. Regression test in wiki_links_xws_test.go pairs same-ws qualified miss (must NOT title-fallback) with bare ref miss (SHOULD title-fallback) to lock the asymmetry in. PLAN-1593 / TASK-1597.
902 lines
30 KiB
Go
902 lines
30 KiB
Go
package links
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestExtractWikiLinks_RefForm covers the Phase 1 happy path: ref-form
|
|
// links outside any code region are extracted with correct kind, ref,
|
|
// display, and position.
|
|
func TestExtractWikiLinks_RefForm(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
want []WikiLinkRef
|
|
}{
|
|
{
|
|
name: "single bare ref",
|
|
content: "See [[TASK-5]] for context.",
|
|
want: []WikiLinkRef{
|
|
{Kind: WikiLinkKindRef, Ref: "TASK-5", Position: 4},
|
|
},
|
|
},
|
|
{
|
|
name: "ref with display alias",
|
|
content: "Per [[TASK-5|the auth fix]] we...",
|
|
want: []WikiLinkRef{
|
|
{Kind: WikiLinkKindRef, Ref: "TASK-5", Display: "the auth fix", Position: 4},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple refs same content",
|
|
content: "[[TASK-1]] depends on [[BUG-2]] and [[IDEA-3]].",
|
|
want: []WikiLinkRef{
|
|
{Kind: WikiLinkKindRef, Ref: "TASK-1", Position: 0},
|
|
{Kind: WikiLinkKindRef, Ref: "BUG-2", Position: 22},
|
|
{Kind: WikiLinkKindRef, Ref: "IDEA-3", Position: 36},
|
|
},
|
|
},
|
|
{
|
|
name: "ref repeated in same body",
|
|
content: "[[FOO-1]] and again [[FOO-1]].",
|
|
want: []WikiLinkRef{
|
|
{Kind: WikiLinkKindRef, Ref: "FOO-1", Position: 0},
|
|
{Kind: WikiLinkKindRef, Ref: "FOO-1", Position: 20},
|
|
},
|
|
},
|
|
{
|
|
name: "long collection prefix",
|
|
content: "Linked to [[PLAYBOOK-12345]].",
|
|
want: []WikiLinkRef{
|
|
{Kind: WikiLinkKindRef, Ref: "PLAYBOOK-12345", Position: 10},
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := ExtractWikiLinks(tc.content)
|
|
assertLinks(t, got, tc.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExtractWikiLinks_TitleForms covers Phase 2a title emission:
|
|
// plain `[[Title]]` and `[[collection/Title]]` are now returned with
|
|
// kind=title and the body stored verbatim. The collection-qualified
|
|
// form is stored AS-WRITTEN (e.g. "docs/Setup") so the resolver can
|
|
// try the renderer's order — full-key title match first, `/`-split
|
|
// only on miss — without losing information about how the link was
|
|
// typed. Codex finding #3 from the planning round.
|
|
func TestExtractWikiLinks_TitleForms(t *testing.T) {
|
|
t.Run("plain title", func(t *testing.T) {
|
|
got := ExtractWikiLinks("Click [[Some Title]] here.")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 link, got %d: %+v", len(got), got)
|
|
}
|
|
if got[0].Kind != WikiLinkKindTitle {
|
|
t.Errorf("Kind: got %q, want title", got[0].Kind)
|
|
}
|
|
if got[0].Title != "Some Title" {
|
|
t.Errorf("Title: got %q, want %q", got[0].Title, "Some Title")
|
|
}
|
|
if got[0].Position != 6 {
|
|
t.Errorf("Position: got %d, want 6", got[0].Position)
|
|
}
|
|
})
|
|
|
|
t.Run("collection-qualified title stored verbatim", func(t *testing.T) {
|
|
got := ExtractWikiLinks("Look at [[docs/Setup]] for help.")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(got))
|
|
}
|
|
if got[0].Kind != WikiLinkKindTitle {
|
|
t.Errorf("Kind: got %q, want title", got[0].Kind)
|
|
}
|
|
// Verbatim — DON'T pre-split. An item literally titled
|
|
// "docs/Setup" must resolve before the qualified-form
|
|
// fallback fires; storing the split here would lose that.
|
|
if got[0].Title != "docs/Setup" {
|
|
t.Errorf("Title: got %q, want %q", got[0].Title, "docs/Setup")
|
|
}
|
|
})
|
|
|
|
t.Run("title with display alias", func(t *testing.T) {
|
|
got := ExtractWikiLinks("See [[Some Title|the page]] for context.")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(got))
|
|
}
|
|
if got[0].Kind != WikiLinkKindTitle {
|
|
t.Errorf("Kind: got %q, want title", got[0].Kind)
|
|
}
|
|
if got[0].Title != "Some Title" {
|
|
t.Errorf("Title: got %q, want %q", got[0].Title, "Some Title")
|
|
}
|
|
if got[0].Display != "the page" || !got[0].HasDisplay {
|
|
t.Errorf("Display: got %q (has=%v), want %q (true)",
|
|
got[0].Display, got[0].HasDisplay, "the page")
|
|
}
|
|
})
|
|
|
|
t.Run("multiple titles in one body", func(t *testing.T) {
|
|
got := ExtractWikiLinks("[[First]] and [[Second Title]] and [[third]].")
|
|
if len(got) != 3 {
|
|
t.Fatalf("expected 3 links, got %d", len(got))
|
|
}
|
|
wantTitles := []string{"First", "Second Title", "third"}
|
|
for i, w := range wantTitles {
|
|
if got[i].Kind != WikiLinkKindTitle {
|
|
t.Errorf("[%d] Kind: got %q, want title", i, got[i].Kind)
|
|
}
|
|
if got[i].Title != w {
|
|
t.Errorf("[%d] Title: got %q, want %q", i, got[i].Title, w)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestExtractWikiLinks_TitlePreservesWhitespace regresses Codex
|
|
// round 9 P2 against PR #621. The renderer doesn't trim before
|
|
// title matching (web/src/lib/utils/markdown.ts:541-543), so an
|
|
// item titled "Foo" doesn't match `[[ Foo ]]`. The extractor must
|
|
// preserve whitespace in the title kind too, or the index would
|
|
// surface backlinks the UI can't actually click on. Ref/workspace_ref
|
|
// shape detection still trims (the renderer does the same).
|
|
func TestExtractWikiLinks_TitlePreservesWhitespace(t *testing.T) {
|
|
t.Run("leading and trailing whitespace preserved in title", func(t *testing.T) {
|
|
got := ExtractWikiLinks("See [[ Foo ]] for details.")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(got))
|
|
}
|
|
if got[0].Kind != WikiLinkKindTitle {
|
|
t.Errorf("Kind: got %q, want title", got[0].Kind)
|
|
}
|
|
if got[0].Title != " Foo " {
|
|
t.Errorf("Title should preserve whitespace, got %q want %q", got[0].Title, " Foo ")
|
|
}
|
|
})
|
|
|
|
t.Run("padded ref still parses as ref (whitespace forgiveness)", func(t *testing.T) {
|
|
got := ExtractWikiLinks("See [[ TASK-5 ]] please.")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(got))
|
|
}
|
|
if got[0].Kind != WikiLinkKindRef {
|
|
t.Errorf("padded ref: Kind got %q want ref", got[0].Kind)
|
|
}
|
|
if got[0].Ref != "TASK-5" {
|
|
t.Errorf("padded ref: Ref got %q want TASK-5", got[0].Ref)
|
|
}
|
|
})
|
|
|
|
t.Run("internal whitespace preserved in title", func(t *testing.T) {
|
|
got := ExtractWikiLinks("See [[Project Goals]] (two spaces inside).")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(got))
|
|
}
|
|
if got[0].Title != "Project Goals" {
|
|
t.Errorf("internal whitespace lost: got %q want %q", got[0].Title, "Project Goals")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestExtractWikiLinks_WorkspaceRefForms covers Phase 2b emission
|
|
// (TASK-1597). The gate that suppressed workspace_ref kinds in Phase
|
|
// 2a is lifted now that the cross-workspace query path and request-
|
|
// independent ACL helper are in place. Each `[[ws::REF]]` body
|
|
// produces a WikiLinkKindWorkspaceRef row with the parsed workspace
|
|
// slug + ref.
|
|
func TestExtractWikiLinks_WorkspaceRefForms(t *testing.T) {
|
|
t.Run("bare workspace ref", func(t *testing.T) {
|
|
got := ExtractWikiLinks("Cross [[other-ws::TASK-9]] over.")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(got))
|
|
}
|
|
if got[0].Kind != WikiLinkKindWorkspaceRef {
|
|
t.Errorf("Kind: got %q, want workspace_ref", got[0].Kind)
|
|
}
|
|
if got[0].WorkspaceSlug != "other-ws" {
|
|
t.Errorf("WorkspaceSlug: got %q, want %q", got[0].WorkspaceSlug, "other-ws")
|
|
}
|
|
if got[0].Ref != "TASK-9" {
|
|
t.Errorf("Ref: got %q, want TASK-9", got[0].Ref)
|
|
}
|
|
})
|
|
|
|
t.Run("workspace ref with display alias", func(t *testing.T) {
|
|
got := ExtractWikiLinks("Cross [[other-ws::TASK-9|see this]] too.")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(got))
|
|
}
|
|
if got[0].Kind != WikiLinkKindWorkspaceRef {
|
|
t.Errorf("Kind: got %q, want workspace_ref", got[0].Kind)
|
|
}
|
|
if got[0].Display != "see this" || !got[0].HasDisplay {
|
|
t.Errorf("Display: got %q (has=%v), want %q (true)",
|
|
got[0].Display, got[0].HasDisplay, "see this")
|
|
}
|
|
})
|
|
|
|
t.Run("mixed-case ref canonicalized in workspace_ref kind", func(t *testing.T) {
|
|
// parseBody validates the ref segment against refPattern
|
|
// case-insensitively but does NOT canonicalize for the
|
|
// workspace_ref kind — the cross-ws resolver compares against
|
|
// the foreign workspace's own canonical prefix at query time.
|
|
// What we store IS the verbatim ref the user wrote; matching
|
|
// happens via LOWER() at lookup. Document this so a future
|
|
// reviewer doesn't try to "fix" it.
|
|
got := ExtractWikiLinks("[[other-ws::task-9]]")
|
|
if len(got) != 1 || got[0].Kind != WikiLinkKindWorkspaceRef {
|
|
t.Fatalf("expected workspace_ref kind, got %+v", got)
|
|
}
|
|
// Verbatim preserved (lowercase here).
|
|
if got[0].Ref != "task-9" {
|
|
t.Errorf("Ref: got %q, want task-9 (verbatim)", got[0].Ref)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid workspace slug falls through to title", func(t *testing.T) {
|
|
// `INVALID-SLUG::TASK-1` — uppercase slug doesn't match the
|
|
// workspace slug pattern. parseBody falls through to title
|
|
// kind, body stored verbatim.
|
|
got := ExtractWikiLinks("[[INVALID::TASK-1]]")
|
|
if len(got) != 1 || got[0].Kind != WikiLinkKindTitle {
|
|
t.Fatalf("expected title fallback for invalid slug, got %+v", got)
|
|
}
|
|
if got[0].Title != "INVALID::TASK-1" {
|
|
t.Errorf("Title: got %q, want %q", got[0].Title, "INVALID::TASK-1")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestExtractWikiLinks_TitleCodeBlockExclusion ensures Phase 2a
|
|
// titles are excluded from fenced / inline code just like refs were
|
|
// in Phase 1. The exclusion happens at the gate-independent
|
|
// outer scan (linkStart vs ranges), so it's worth a smoke test to
|
|
// catch any regression from lifting the gate.
|
|
func TestExtractWikiLinks_TitleCodeBlockExclusion(t *testing.T) {
|
|
content := "Real: [[Outside Title]]\n" +
|
|
"```\n" +
|
|
"Fake: [[Inside Title]]\n" +
|
|
"```\n" +
|
|
"Inline `[[Also Inside]]` then [[Last One]]."
|
|
got := ExtractWikiLinks(content)
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 titles, got %d: %+v", len(got), got)
|
|
}
|
|
if got[0].Title != "Outside Title" || got[1].Title != "Last One" {
|
|
t.Errorf("got titles %q / %q, want %q / %q",
|
|
got[0].Title, got[1].Title, "Outside Title", "Last One")
|
|
}
|
|
}
|
|
|
|
// TestExtractWikiLinks_CodeBlocksExcluded asserts the headline behavior
|
|
// decision from PLAN-1593: [[REF]] inside fenced or inline code is NOT
|
|
// a real link and must be skipped.
|
|
func TestExtractWikiLinks_CodeBlocksExcluded(t *testing.T) {
|
|
t.Run("fenced block excludes refs inside", func(t *testing.T) {
|
|
content := "Real: [[OUTSIDE-1]]\n" +
|
|
"```\n" +
|
|
"Example: [[INSIDE-1]] and [[INSIDE-2]]\n" +
|
|
"```\n" +
|
|
"After: [[OUTSIDE-2]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"OUTSIDE-1", "OUTSIDE-2"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("fenced block with language tag", func(t *testing.T) {
|
|
content := "Before: [[A-1]]\n" +
|
|
"```bash\n" +
|
|
"echo [[B-1]]\n" +
|
|
"```\n" +
|
|
"After: [[C-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"A-1", "C-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("inline code excludes ref inside", func(t *testing.T) {
|
|
content := "The `[[FAKE-1]]` syntax has shipped; see [[REAL-2]] for examples."
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"REAL-2"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("unclosed fence runs to EOF", func(t *testing.T) {
|
|
// A draft with an opened-but-never-closed fence should treat
|
|
// everything after the fence as code (matches markdown
|
|
// rendering of the same draft).
|
|
content := "Before: [[A-1]]\n" +
|
|
"```\n" +
|
|
"After: [[B-1]] // inside dangling fence — must be skipped"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"A-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("inline code spans single newline (CommonMark §6.1)", func(t *testing.T) {
|
|
// Per CommonMark, an inline-code span can cross a single
|
|
// newline. The renderer treats `pre\n[[INSIDE-1]]\npost`
|
|
// as a single code span — the embedded link must NOT be
|
|
// indexed. Codex round-9 P1.
|
|
content := "Outside [[A-1]]\n" +
|
|
"start `pre\n[[INSIDE-1]]\npost` end\n" +
|
|
"Outside [[B-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"A-1", "B-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("inline code breaks at blank line (paragraph boundary)", func(t *testing.T) {
|
|
// A blank line terminates the paragraph, so a still-open
|
|
// inline-code span must end at the blank-line break.
|
|
// Refs in subsequent paragraphs are NOT part of the span
|
|
// and must be indexed.
|
|
content := "Outside [[A-1]]\n" +
|
|
"open `unclosed-span\n" +
|
|
"\n" +
|
|
"new paragraph [[REAL-1]] more text\n" +
|
|
"Outside [[B-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"A-1", "REAL-1", "B-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("inline code breaks at whitespace-only blank line", func(t *testing.T) {
|
|
// CommonMark treats a line containing only spaces/tabs as
|
|
// blank. Span must still terminate there.
|
|
content := "open `unclosed-span\n" +
|
|
" \t \n" +
|
|
"after-blank [[REAL-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"REAL-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("inline code closer matches opener length", func(t *testing.T) {
|
|
// CommonMark §6.1: a span opened with N backticks closes
|
|
// only on a run of EXACTLY N backticks. Stray single
|
|
// backticks inside a ``...`` span are code text, not
|
|
// closers. Without matching-run semantics, the extractor
|
|
// would close on the first single backtick and leak the
|
|
// inner [[X]]. Codex round-7 finding #2.
|
|
content := "Before [[A-1]] ``code with ` inside and [[INSIDE-1]]`` after [[B-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"A-1", "B-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("single-backtick span unaffected by adjacent multi-backtick run", func(t *testing.T) {
|
|
// `code` is a normal one-backtick span that closes on the
|
|
// next single backtick. A double-backtick run is NOT a
|
|
// valid closer for a single-backtick opener.
|
|
content := "Try `code [[INSIDE-1]] ``not-closer` then [[REAL-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"REAL-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("multi-backtick inline code excludes ref", func(t *testing.T) {
|
|
// CommonMark inline-code spans support multi-backtick
|
|
// delimiters (`code` and ``code`` are both spans). Our
|
|
// permissive matcher treats any backtick run as an opener
|
|
// and closes on the next backtick run — which covers this
|
|
// case correctly: ``see [[X]]`` becomes a single [0, end)
|
|
// range, the embedded link is excluded.
|
|
content := "``see [[INSIDE-1]]`` and [[REAL-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"REAL-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("tilde fence excludes refs inside", func(t *testing.T) {
|
|
// CommonMark / marked accept ~~~ fences alongside ```.
|
|
// Refs inside a tilde fence render as code in the UI and
|
|
// must NOT be indexed. Codex round-6 finding #1.
|
|
content := "Real: [[OUTSIDE-1]]\n" +
|
|
"~~~\n" +
|
|
"Example: [[INSIDE-1]]\n" +
|
|
"~~~\n" +
|
|
"After: [[OUTSIDE-2]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"OUTSIDE-1", "OUTSIDE-2"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("tilde fence with language tag", func(t *testing.T) {
|
|
content := "Before [[A-1]]\n" +
|
|
"~~~python\n" +
|
|
"# echo [[B-1]]\n" +
|
|
"~~~\n" +
|
|
"After [[C-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"A-1", "C-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("mixed fence types don't pair", func(t *testing.T) {
|
|
// A backtick opener must NOT be closed by a tilde line and
|
|
// vice versa. If the wrong char appears, the fence stays
|
|
// open until the right char (or EOF) is found.
|
|
content := "Before [[A-1]]\n" +
|
|
"```\n" +
|
|
"~~~ // not a closer for the ``` opener\n" +
|
|
"[[INSIDE-1]]\n" +
|
|
"```\n" +
|
|
"After [[B-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"A-1", "B-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("closer-line strictness — backticks plus other text is not a closer", func(t *testing.T) {
|
|
// CommonMark §4.5: a closing fence line must contain only
|
|
// the fence + optional trailing spaces. A line like
|
|
// `\`\`\`not-closed` inside an open fence does NOT
|
|
// terminate the block — later refs in the same fence stay
|
|
// excluded. Codex round-6 finding #2.
|
|
content := "Real: [[OUTSIDE-1]]\n" +
|
|
"```\n" +
|
|
"```not-closed\n" +
|
|
"[[INSIDE-1]] still inside the fence\n" +
|
|
"```\n" +
|
|
"After: [[OUTSIDE-2]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"OUTSIDE-1", "OUTSIDE-2"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("closer-line strictness — trailing spaces OK", func(t *testing.T) {
|
|
// A real closer may have trailing whitespace.
|
|
content := "Before [[A-1]]\n" +
|
|
"```\n" +
|
|
"[[INSIDE-1]]\n" +
|
|
"``` \n" +
|
|
"After [[B-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"A-1", "B-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("indented fenced block (CommonMark 0-3 spaces)", func(t *testing.T) {
|
|
// CommonMark allows fenced code blocks indented 0-3 spaces;
|
|
// marked() (the renderer's markdown parser) implements this.
|
|
// Our extractor must mirror or we false-positive on every
|
|
// indented code example users write.
|
|
content := "Before [[A-1]]\n" +
|
|
" ```\n" +
|
|
" echo [[INSIDE-1]]\n" +
|
|
" ```\n" +
|
|
"After [[B-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"A-1", "B-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("inline code adjacent to real link", func(t *testing.T) {
|
|
// `code-with-link` then [[REAL-1]] — closer of inline span
|
|
// must not accidentally include the real link.
|
|
content := "Try `[[A-1]]` and then [[REAL-1]]."
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"REAL-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
|
|
t.Run("fenced block at start of content (no leading newline)", func(t *testing.T) {
|
|
content := "```\n[[INSIDE-1]]\n```\n[[OUTSIDE-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
refs := refStrings(got)
|
|
want := []string{"OUTSIDE-1"}
|
|
if !equalStringSlices(refs, want) {
|
|
t.Errorf("got refs %v, want %v", refs, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestExtractWikiLinks_Edge covers malformed/weird inputs the renderer
|
|
// gracefully falls through on. Our extractor must do the same.
|
|
func TestExtractWikiLinks_Edge(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
content string
|
|
}{
|
|
{"empty content", ""},
|
|
{"no links", "Plain text with no wiki links at all."},
|
|
{"empty brackets", "Here is [[]] which shouldn't match."},
|
|
{"single bracket", "[A-1] should not match."},
|
|
{"nested brackets", "[[[A-1]]] shouldn't either."},
|
|
{"number-led not a ref", "[[5-task]] is not a ref shape."},
|
|
{"missing number", "[[TASK]] needs a number."},
|
|
{"bracket with newline body", "[[A-1\nB-2]] is malformed."},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := ExtractWikiLinks(c.content)
|
|
for _, g := range got {
|
|
// Phase 2a: emitted kinds are ref or title; cross-ws
|
|
// is still gated. Whatever the kind, the matching
|
|
// identifier field must be non-empty so downstream
|
|
// consumers can rely on it.
|
|
switch g.Kind {
|
|
case WikiLinkKindRef:
|
|
if g.Ref == "" {
|
|
t.Errorf("emitted ref-kind with empty Ref: %+v", g)
|
|
}
|
|
case WikiLinkKindTitle:
|
|
if g.Title == "" {
|
|
t.Errorf("emitted title-kind with empty Title: %+v", g)
|
|
}
|
|
default:
|
|
t.Errorf("emitted unexpected kind in Phase 2a: %+v", g)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExtractWikiLinks_PositionIsByteOffset asserts that Position
|
|
// points at the opening `[[` in the ORIGINAL content — the handler
|
|
// uses this to extract a ~80-char snippet centered on the match.
|
|
func TestExtractWikiLinks_PositionIsByteOffset(t *testing.T) {
|
|
content := "Some prose. [[TASK-99]] more prose."
|
|
got := ExtractWikiLinks(content)
|
|
if len(got) != 1 {
|
|
t.Fatalf("want 1 link, got %d", len(got))
|
|
}
|
|
pos := got[0].Position
|
|
if got := content[pos : pos+2]; got != "[[" {
|
|
t.Errorf("Position should point at `[[`, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestExtractWikiLinks_RefVsTitleFallback exercises the parseBody
|
|
// decision tree. After Codex round-1 P2, the refPattern is
|
|
// case-insensitive — `[[Task-5]]` and `[[task-5]]` now parse as the
|
|
// ref kind (and normalize to "TASK-5" for storage), matching the
|
|
// renderer's behavior. Inputs that don't look like a ref at all
|
|
// still fall through to title.
|
|
func TestExtractWikiLinks_RefVsTitleFallback(t *testing.T) {
|
|
t.Run("uppercase ref-shaped body parses as ref", func(t *testing.T) {
|
|
got := ExtractWikiLinks("[[TASK-5]]")
|
|
if len(got) != 1 || got[0].Kind != WikiLinkKindRef {
|
|
t.Errorf("expected single ref kind, got %+v", got)
|
|
}
|
|
if got[0].Ref != "TASK-5" {
|
|
t.Errorf("Ref: got %q, want TASK-5", got[0].Ref)
|
|
}
|
|
})
|
|
t.Run("mixed-case body parses as ref, normalized to uppercase", func(t *testing.T) {
|
|
got := ExtractWikiLinks("[[Task-5]]")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 ref-kind result (case-insensitive match), got %d: %+v", len(got), got)
|
|
}
|
|
if got[0].Kind != WikiLinkKindRef {
|
|
t.Errorf("Kind: got %q, want ref", got[0].Kind)
|
|
}
|
|
if got[0].Ref != "TASK-5" {
|
|
t.Errorf("Ref should be canonicalized to uppercase: got %q want TASK-5", got[0].Ref)
|
|
}
|
|
})
|
|
t.Run("lowercase body parses as ref, normalized to uppercase", func(t *testing.T) {
|
|
got := ExtractWikiLinks("[[task-5]]")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 ref-kind result, got %d", len(got))
|
|
}
|
|
if got[0].Ref != "TASK-5" {
|
|
t.Errorf("Ref: got %q want TASK-5", got[0].Ref)
|
|
}
|
|
})
|
|
t.Run("non-ref body falls to title (Phase 2a emits)", func(t *testing.T) {
|
|
// "5-Task" (number-led) doesn't match REF_PATTERN even
|
|
// with the relaxed case rule; parseBody returns a
|
|
// title-kind ref; Phase 2a now emits title kinds, so the
|
|
// body is preserved verbatim as the Title field.
|
|
got := ExtractWikiLinks("[[5-Task]]")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 title-kind row, got %d: %+v", len(got), got)
|
|
}
|
|
if got[0].Kind != WikiLinkKindTitle {
|
|
t.Errorf("Kind: got %q, want title", got[0].Kind)
|
|
}
|
|
if got[0].Title != "5-Task" {
|
|
t.Errorf("Title: got %q, want %q", got[0].Title, "5-Task")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestExtractWikiLinks_EscapedBodyChars regresses Codex rounds
|
|
// 4/7/10 P2: the editor's wikiLinksToMarkdown can produce bodies
|
|
// containing `\]`, `\|`, `\\` escapes (markdown.ts:461). The
|
|
// extractor must parse those — both the regex and the body parser —
|
|
// so the resulting link is indexed with the unescaped display text.
|
|
func TestExtractWikiLinks_EscapedBodyChars(t *testing.T) {
|
|
t.Run("escaped closing bracket in display", func(t *testing.T) {
|
|
// [[TASK-1|see \] bracket]] — display is "see ] bracket".
|
|
got := ExtractWikiLinks(`[[TASK-1|see \] bracket]]`)
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 ref, got %d: %+v", len(got), got)
|
|
}
|
|
if got[0].Ref != "TASK-1" {
|
|
t.Errorf("Ref: got %q want TASK-1", got[0].Ref)
|
|
}
|
|
if got[0].Display != "see ] bracket" {
|
|
t.Errorf("Display: got %q want %q", got[0].Display, "see ] bracket")
|
|
}
|
|
})
|
|
|
|
t.Run("escaped pipe in display", func(t *testing.T) {
|
|
// [[TASK-2|A \| B]] — display is "A | B"; the unescaped
|
|
// pipe doesn't split the body.
|
|
got := ExtractWikiLinks(`[[TASK-2|A \| B]]`)
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 ref, got %d", len(got))
|
|
}
|
|
if got[0].Display != "A | B" {
|
|
t.Errorf("Display: got %q want %q", got[0].Display, "A | B")
|
|
}
|
|
})
|
|
|
|
t.Run("escaped backslash in display", func(t *testing.T) {
|
|
// [[TASK-3|a \\ b]] — display is "a \ b".
|
|
got := ExtractWikiLinks(`[[TASK-3|a \\ b]]`)
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 ref, got %d", len(got))
|
|
}
|
|
if got[0].Display != `a \ b` {
|
|
t.Errorf("Display: got %q want %q", got[0].Display, `a \ b`)
|
|
}
|
|
})
|
|
|
|
t.Run("non-escape backslash passes through", func(t *testing.T) {
|
|
// `\n` (or any `\X` where X isn't ]|\) is left alone.
|
|
got := ExtractWikiLinks(`[[TASK-4|a \n b]]`)
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 ref, got %d", len(got))
|
|
}
|
|
if got[0].Display != `a \n b` {
|
|
t.Errorf("Display: got %q want %q", got[0].Display, `a \n b`)
|
|
}
|
|
})
|
|
|
|
t.Run("explicit empty display override is distinguished from no pipe", func(t *testing.T) {
|
|
// [[X|]] is distinct from [[X]] in the editor: the former
|
|
// has displayOverride="" (preserved by JS ?? coalescing),
|
|
// the latter has no override and falls back to title. We
|
|
// preserve that distinction via HasDisplay. Codex round-12 P3.
|
|
withPipe := ExtractWikiLinks(`[[TASK-7|]]`)
|
|
if len(withPipe) != 1 {
|
|
t.Fatalf("expected 1 ref, got %d", len(withPipe))
|
|
}
|
|
if !withPipe[0].HasDisplay {
|
|
t.Errorf("[[TASK-7|]] should have HasDisplay=true")
|
|
}
|
|
if withPipe[0].Display != "" {
|
|
t.Errorf("[[TASK-7|]] Display should be \"\", got %q", withPipe[0].Display)
|
|
}
|
|
|
|
noPipe := ExtractWikiLinks(`[[TASK-7]]`)
|
|
if len(noPipe) != 1 {
|
|
t.Fatalf("expected 1 ref, got %d", len(noPipe))
|
|
}
|
|
if noPipe[0].HasDisplay {
|
|
t.Errorf("[[TASK-7]] should have HasDisplay=false")
|
|
}
|
|
if noPipe[0].Display != "" {
|
|
t.Errorf("[[TASK-7]] Display should be \"\", got %q", noPipe[0].Display)
|
|
}
|
|
})
|
|
|
|
t.Run("display text preserved verbatim (no TrimSpace)", func(t *testing.T) {
|
|
// The renderer stores display text verbatim — leading and
|
|
// trailing whitespace are part of the display. Trimming
|
|
// in the extractor would silently differ from client
|
|
// behavior. Codex round-11 P3.
|
|
got := ExtractWikiLinks(`[[TASK-9| padded display ]]`)
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 ref, got %d", len(got))
|
|
}
|
|
if got[0].Display != " padded display " {
|
|
t.Errorf("Display should be verbatim, got %q", got[0].Display)
|
|
}
|
|
})
|
|
|
|
t.Run("position still points at opening [[", func(t *testing.T) {
|
|
// Escapes shouldn't shift Position — it's the byte offset
|
|
// in the ORIGINAL content, not the unescaped form.
|
|
content := "Prefix " + `[[TASK-5|see \] here]]` + " suffix"
|
|
got := ExtractWikiLinks(content)
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 ref, got %d", len(got))
|
|
}
|
|
if content[got[0].Position:got[0].Position+2] != "[[" {
|
|
t.Errorf("Position should point at `[[`, got %q",
|
|
content[got[0].Position:got[0].Position+2])
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSplitOnUnescapedPipe / TestUnescapeWikiBody — direct unit
|
|
// tests for the helpers. Round-trip safety against the editor's
|
|
// serializer (escapeWikiBody/unescapeWikiBody in markdown.ts:652-658)
|
|
// is the property we care about.
|
|
func TestSplitOnUnescapedPipe(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
wantKey string
|
|
wantSuffix string
|
|
wantFound bool
|
|
}{
|
|
{"ref-only", "ref-only", "", false},
|
|
{"key|display", "key", "display", true},
|
|
{`key\|with-pipe`, `key\|with-pipe`, "", false},
|
|
{`first\|second|third`, `first\|second`, "third", true},
|
|
{`\\|trailing`, `\\`, "trailing", true}, // \\ is escaped backslash, then |
|
|
}
|
|
for _, c := range cases {
|
|
k, s, ok := splitOnUnescapedPipe(c.in)
|
|
if k != c.wantKey || s != c.wantSuffix || ok != c.wantFound {
|
|
t.Errorf("splitOnUnescapedPipe(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
|
c.in, k, s, ok, c.wantKey, c.wantSuffix, c.wantFound)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnescapeWikiBody(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"plain", "plain"},
|
|
{`\]`, "]"},
|
|
{`\|`, "|"},
|
|
{`\\`, `\`},
|
|
{`a \] b \| c \\ d`, `a ] b | c \ d`},
|
|
{`\n stays literal`, `\n stays literal`},
|
|
{"", ""},
|
|
}
|
|
for _, c := range cases {
|
|
got := unescapeWikiBody(c.in)
|
|
if got != c.want {
|
|
t.Errorf("unescapeWikiBody(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCanonicalizeRef is the unit-level check on the helper. The
|
|
// integration coverage lives in TestWikiLinks_MixedCaseRefIndexed
|
|
// (store) — but the helper's edge cases (no-hyphen, all-uppercase
|
|
// already, multi-hyphen prefix) are easier to assert directly.
|
|
func TestCanonicalizeRef(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"TASK-5", "TASK-5"},
|
|
{"task-5", "TASK-5"},
|
|
{"Task-5", "TASK-5"},
|
|
{"PLAYBOOK-12345", "PLAYBOOK-12345"},
|
|
{"playbook-12345", "PLAYBOOK-12345"},
|
|
}
|
|
for _, tc := range cases {
|
|
got := canonicalizeRef(tc.in)
|
|
if got != tc.want {
|
|
t.Errorf("canonicalizeRef(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// assertLinks compares two WikiLinkRef slices for the fields Phase 1
|
|
// + Phase 2a care about. WorkspaceSlug is excluded because Phase 2b
|
|
// (TASK-1597) is the first to emit workspace_ref kinds; once that
|
|
// lands the helper grows another comparison line.
|
|
func assertLinks(t *testing.T, got, want []WikiLinkRef) {
|
|
t.Helper()
|
|
if len(got) != len(want) {
|
|
t.Fatalf("got %d links, want %d: got=%+v want=%+v", len(got), len(want), got, want)
|
|
}
|
|
for i := range got {
|
|
if got[i].Kind != want[i].Kind {
|
|
t.Errorf("[%d] Kind: got %q, want %q", i, got[i].Kind, want[i].Kind)
|
|
}
|
|
if got[i].Ref != want[i].Ref {
|
|
t.Errorf("[%d] Ref: got %q, want %q", i, got[i].Ref, want[i].Ref)
|
|
}
|
|
if got[i].Title != want[i].Title {
|
|
t.Errorf("[%d] Title: got %q, want %q", i, got[i].Title, want[i].Title)
|
|
}
|
|
if got[i].Display != want[i].Display {
|
|
t.Errorf("[%d] Display: got %q, want %q", i, got[i].Display, want[i].Display)
|
|
}
|
|
if got[i].Position != want[i].Position {
|
|
t.Errorf("[%d] Position: got %d, want %d", i, got[i].Position, want[i].Position)
|
|
}
|
|
}
|
|
}
|
|
|
|
func refStrings(links []WikiLinkRef) []string {
|
|
out := make([]string, len(links))
|
|
for i, l := range links {
|
|
out[i] = l.Ref
|
|
}
|
|
return out
|
|
}
|
|
|
|
func equalStringSlices(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Sanity check: position is BYTE offset, not rune offset. A leading
|
|
// multi-byte rune shifts the [[ to a position > its rune index.
|
|
func TestExtractWikiLinks_PositionByteVsRune(t *testing.T) {
|
|
// "héllo " has é = 2 bytes (UTF-8). The [[ that follows should
|
|
// be at byte offset 7 ("h"=1 + "é"=2 + "llo "=4 = 7), even
|
|
// though its rune offset is only 6.
|
|
content := "héllo [[TASK-1]]"
|
|
got := ExtractWikiLinks(content)
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(got))
|
|
}
|
|
if got[0].Position != strings.Index(content, "[[") {
|
|
t.Errorf("Position should be byte offset matching strings.Index, got %d want %d",
|
|
got[0].Position, strings.Index(content, "[["))
|
|
}
|
|
}
|