Creating a folder/file at a project-level folder-nav aggregator root (working/staging/reviewing) used to error or silently shadow — the slots are virtual and content is party-scoped. Now browse opens a party picker that targets archive/<party>/<slot>/<name>, with a "+ New party…" option (server-gated to the document_controller via the existing archive/ ACL). - events.js: aggregatorRoot detection + openPartyPicker modal (mirrors the stage.js modal), createInAggregator routes the create to the canonical archive path; rewriteAggregatorPath handles right-clicking a party row shown in an aggregator listing so it never re-prompts. - server: serveFileMkdir now 409s a mkdir inside an aggregator (rejectProjectAggregatorMkdir) with a pointer at archive/<party>/<slot>/, instead of letting the write fall through to an unreachable shadow dir. Reverts the prior session's project-level creator-owned working/ folders (per the design decision to make all three folder-nav slots uniformly party-scoped): working/ is a pure virtual aggregator again like staging/reviewing — drops the working/ history+auto_own+acl defaults, the EnsureCanonicalAncestors working exception, the working-root document- controller file gate (serveFilePut/Move) and zddc.IsRoleMemberAt. Per-party archive/<party>/working/ keeps its own history + auto-own. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
356 lines
14 KiB
Go
356 lines
14 KiB
Go
package zddc
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// TestDefaultToolAt_FromEmbeddedConvention — the canonical default-
|
|
// tool rules in defaults.zddc.yaml should resolve correctly for the
|
|
// well-known paths without any on-disk .zddc.
|
|
//
|
|
// Layout reshape: lifecycle slots (working/staging/reviewing) now
|
|
// live under archive/<party>/. The project-level
|
|
// <project>/{working,staging,reviewing} URLs are virtual folder-nav
|
|
// aggregators (default_tool=browse).
|
|
func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
cases := []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{filepath.Join(root, "Project-X", "archive"), "archive"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "tables"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), "tables"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), "browse"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), "browse"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), "transmittal"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), "browse"},
|
|
// Project-level virtual aggregators.
|
|
{filepath.Join(root, "Project-X", "ssr"), "tables"},
|
|
{filepath.Join(root, "Project-X", "mdl"), "tables"},
|
|
{filepath.Join(root, "Project-X", "rsk"), "tables"},
|
|
{filepath.Join(root, "Project-X", "working"), "browse"},
|
|
{filepath.Join(root, "Project-X", "staging"), "browse"},
|
|
{filepath.Join(root, "Project-X", "reviewing"), "browse"},
|
|
}
|
|
for _, tc := range cases {
|
|
got := DefaultToolAt(root, tc.path)
|
|
if got != tc.want {
|
|
t.Errorf("DefaultToolAt(%q) = %q, want %q",
|
|
tc.path[len(root):], got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHistoryAt_Defaults — the embedded convention enables edit-history
|
|
// versioning on the per-party archive/<party>/working/ subtree (+ its
|
|
// homes). History is subtree-inheriting and ignores the homes'
|
|
// inherit:false fences. The project-level working/ aggregator is virtual
|
|
// (no direct content → no history), and sibling slots (staging,
|
|
// reviewing, mdl, incoming, received) do NOT get history.
|
|
func TestHistoryAt_Defaults(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
cases := []struct {
|
|
path string
|
|
want bool
|
|
}{
|
|
// Project-level working/ is a pure virtual aggregator — no
|
|
// direct content, so no history there.
|
|
{filepath.Join(root, "Project-X", "working"), false},
|
|
// Per-party working carries history (edit-history versioning).
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes"), true},
|
|
// Sibling slots get no history.
|
|
{filepath.Join(root, "Project-X", "staging"), false},
|
|
{filepath.Join(root, "Project-X", "reviewing"), false},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
|
{filepath.Join(root, "Project-X", "archive"), false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := HistoryAt(root, tc.path); got != tc.want {
|
|
t.Errorf("HistoryAt(%q) = %v, want %v", tc.path[len(root):], got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDirToolAt — the trailing-slash form floors at "browse" for
|
|
// every path (the embedded convention sets dir_tool nowhere), and an
|
|
// on-disk .zddc can override it for a subtree.
|
|
func TestDirToolAt(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
// Nothing declares dir_tool → browse everywhere, including paths
|
|
// whose default_tool (no-slash form) is something else.
|
|
for _, p := range []string{
|
|
filepath.Join(root, "Project-X"),
|
|
filepath.Join(root, "Project-X", "archive", "Acme", "working"),
|
|
filepath.Join(root, "Project-X", "archive", "Acme", "mdl"),
|
|
filepath.Join(root, "Project-X", "random", "deep", "folder"),
|
|
} {
|
|
if got := DirToolAt(root, p); got != "browse" {
|
|
t.Errorf("DirToolAt(%q) = %q, want browse", p[len(root):], got)
|
|
}
|
|
}
|
|
// Operator override on a subtree; cascades leaf→root.
|
|
specialDir := filepath.Join(root, "Special")
|
|
if err := os.MkdirAll(specialDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeZddc(t, specialDir, "dir_tool: tables\n")
|
|
resetCache()
|
|
if got := DirToolAt(root, filepath.Join(root, "Special")); got != "tables" {
|
|
t.Errorf("DirToolAt(Special) = %q, want tables", got)
|
|
}
|
|
if got := DirToolAt(root, filepath.Join(root, "Special", "deep")); got != "tables" {
|
|
t.Errorf("DirToolAt(Special/deep) = %q, want tables (cascade)", got)
|
|
}
|
|
if got := DirToolAt(root, filepath.Join(root, "Other")); got != "browse" {
|
|
t.Errorf("DirToolAt(Other) = %q, want browse (override scoped to Special)", got)
|
|
}
|
|
}
|
|
|
|
// TestCanonicalFolderAt — structural detection of the canonical
|
|
// project-layout slots that the browse SPA scope-gates context-menu
|
|
// actions against.
|
|
//
|
|
// After the layout reshape:
|
|
// - <project>/archive is the only depth-2 canonical
|
|
// - <project>/archive/<party>/<slot> covers the eight per-party
|
|
// physical slots (incoming, received, issued, mdl, rsk, working,
|
|
// staging, reviewing)
|
|
// - everything else returns ""
|
|
func TestCanonicalFolderAt(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
cases := []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{filepath.Join(root, "Project-X", "archive"), "archive"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "incoming"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "received"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "issued"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "mdl"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), "rsk"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), "working"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), "staging"},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), "reviewing"},
|
|
// Project-root virtuals are NOT canonical-folder slots.
|
|
{filepath.Join(root, "Project-X", "working"), ""},
|
|
{filepath.Join(root, "Project-X", "staging"), ""},
|
|
{filepath.Join(root, "Project-X", "reviewing"), ""},
|
|
{root, ""},
|
|
{filepath.Join(root, "Project-X"), ""},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), ""},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme"), ""},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming", "2026-05-15_Acme-0042 (RFI) - Foundation"), ""},
|
|
{filepath.Join(root, "Project-X", "random", "dir"), ""},
|
|
}
|
|
for _, tc := range cases {
|
|
got := CanonicalFolderAt(root, tc.path)
|
|
if got != tc.want {
|
|
t.Errorf("CanonicalFolderAt(%q) = %q, want %q",
|
|
tc.path[len(root):], got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
|
|
// the per-party lifecycle slots (working/staging/reviewing/incoming)
|
|
// and false for received/issued/mdl/rsk.
|
|
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
cases := []struct {
|
|
path string
|
|
want bool
|
|
}{
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), true},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), true},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), false},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), false},
|
|
}
|
|
for _, tc := range cases {
|
|
got := AutoOwnAt(root, tc.path)
|
|
if got != tc.want {
|
|
t.Errorf("AutoOwnAt(%q) = %v, want %v",
|
|
tc.path[len(root):], got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestVirtualAt_FromEmbeddedConvention — mdl/rsk under a party are
|
|
// declared virtual, and the six project-level aggregators are virtual.
|
|
// Other canonical slots materialise on disk.
|
|
func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
cases := []struct {
|
|
path string
|
|
want bool
|
|
}{
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), true},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), false},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), false},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
|
// Project-level aggregators.
|
|
{filepath.Join(root, "Project-X", "ssr"), true},
|
|
{filepath.Join(root, "Project-X", "mdl"), true},
|
|
{filepath.Join(root, "Project-X", "rsk"), true},
|
|
{filepath.Join(root, "Project-X", "working"), true},
|
|
{filepath.Join(root, "Project-X", "staging"), true},
|
|
{filepath.Join(root, "Project-X", "reviewing"), true},
|
|
}
|
|
for _, tc := range cases {
|
|
got := VirtualAt(root, tc.path)
|
|
if got != tc.want {
|
|
t.Errorf("VirtualAt(%q) = %v, want %v",
|
|
tc.path[len(root):], got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIsDeclaredPath_FromEmbeddedConvention — canonical paths under
|
|
// the convention are declared even on a fresh root; arbitrary paths
|
|
// are not.
|
|
func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
cases := []struct {
|
|
path string
|
|
want bool
|
|
}{
|
|
{filepath.Join(root, "Project-X", "archive"), true},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
|
|
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
|
|
// Project-root aggregators are also declared.
|
|
{filepath.Join(root, "Project-X", "working"), true},
|
|
{filepath.Join(root, "Project-X", "reviewing"), true},
|
|
{filepath.Join(root, "Project-X", "ssr"), true},
|
|
{filepath.Join(root, "Project-X", "junk"), false}, // not in convention
|
|
}
|
|
for _, tc := range cases {
|
|
got := IsDeclaredPath(root, tc.path)
|
|
if got != tc.want {
|
|
t.Errorf("IsDeclaredPath(%q) = %v, want %v",
|
|
tc.path[len(root):], got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project root
|
|
// the cascade declares archive/ plus the six top-level virtual
|
|
// aggregator slots (ssr, mdl, rsk, working, staging, reviewing).
|
|
func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X"))
|
|
want := map[string]bool{
|
|
"archive": true,
|
|
"ssr": true, "mdl": true, "rsk": true,
|
|
"working": true, "staging": true, "reviewing": true,
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)
|
|
}
|
|
for _, n := range got {
|
|
if !want[n] {
|
|
t.Errorf("unexpected child %q", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOperatorOverride_DefaultsAreSurfaceable — operator can override
|
|
// any of the canonical tool defaults by mirroring the structure in an
|
|
// on-disk .zddc. The override wins.
|
|
func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(root, "Special", "archive", "Acme", "working"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Operator declares that Special/archive/Acme/working uses
|
|
// classifier instead of the embedded-default browse.
|
|
writeZddc(t, filepath.Join(root, "Special", "archive", "Acme", "working"),
|
|
"default_tool: classifier\n")
|
|
|
|
if got := DefaultToolAt(root, filepath.Join(root, "Special", "archive", "Acme", "working")); got != "classifier" {
|
|
t.Errorf("operator override should set default_tool=classifier, got %q", got)
|
|
}
|
|
// Default still applies at other projects.
|
|
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "archive", "Acme", "working")); got != "browse" {
|
|
t.Errorf("default convention should hold at unchanged paths, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestDefaultToolAt_PropagatesToDescendants — once an ancestor sets
|
|
// default_tool, descendants inherit it unless they override. So a
|
|
// path under working/ that isn't explicitly declared in paths: still
|
|
// gets browse as its default tool.
|
|
func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
// Deep path under archive/<party>/working/ — not explicitly
|
|
// mentioned in paths:.
|
|
deep := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes", "sub", "deep")
|
|
if got := DefaultToolAt(root, deep); got != "browse" {
|
|
t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)",
|
|
deep[len(root):], got)
|
|
}
|
|
}
|
|
|
|
// TestAutoOwnAt_DescendantCanDisable — explicit auto_own:false at a
|
|
// descendant overrides an ancestor's auto_own:true.
|
|
func TestAutoOwnAt_DescendantCanDisable(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
deepDir := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com")
|
|
if err := os.MkdirAll(deepDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeZddc(t, deepDir, "auto_own: false\n")
|
|
if got := AutoOwnAt(root, deepDir); got != false {
|
|
t.Errorf("AutoOwnAt(%q) = %v, want false (descendant override)", deepDir, got)
|
|
}
|
|
// Ancestor still has it true.
|
|
ancestor := filepath.Join(root, "Project-X", "archive", "Acme", "working")
|
|
if got := AutoOwnAt(root, ancestor); got != true {
|
|
t.Errorf("AutoOwnAt(%q) = %v, want true (ancestor untouched)", ancestor, got)
|
|
}
|
|
}
|
|
|
|
// TestInheritFalse_BlocksEmbeddedDefaults — at the on-disk root,
|
|
// inherit:false stops the embedded layer from contributing. The
|
|
// canonical paths are then no longer declared.
|
|
func TestInheritFalse_BlocksEmbeddedDefaults(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
writeZddc(t, root, "inherit: false\n")
|
|
// Without the embedded defaults' paths: tree, IsDeclaredPath
|
|
// returns false for previously-canonical paths.
|
|
if IsDeclaredPath(root, filepath.Join(root, "Project-X", "archive")) {
|
|
t.Errorf("with inherit:false at root, archive should not be a declared path")
|
|
}
|
|
if DefaultToolAt(root, filepath.Join(root, "Project-X", "archive", "Acme", "working")) != "" {
|
|
t.Errorf("with inherit:false at root, default_tool should be empty for working")
|
|
}
|
|
}
|