diff --git a/browse/js/accept-transmittal.js b/browse/js/accept-transmittal.js index ad6718c..3bd86c6 100644 --- a/browse/js/accept-transmittal.js +++ b/browse/js/accept-transmittal.js @@ -255,10 +255,10 @@ return; } - // Derive the party from the path: archive//incoming//. + // Derive the party from the path: incoming///. var parts = url.replace(/^\/+|\/+$/g, '').split('/'); - var partyIdx = parts.indexOf('archive'); - var party = (partyIdx >= 0 && parts[partyIdx + 1]) ? parts[partyIdx + 1] : ''; + var incIdx = parts.indexOf('incoming'); + var party = (incIdx >= 0 && parts[incIdx + 1]) ? parts[incIdx + 1] : ''; var classification = classifyChildren(node, parsedFolder.trackingNumber); diff --git a/browse/js/events.js b/browse/js/events.js index 10952c2..905b811 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -670,49 +670,36 @@ // (^[A-Za-z0-9][A-Za-z0-9.-]*$). function validPartyName(s) { return /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(s || ''); } - // The project-level folder-nav aggregators. These have no physical - // presence: // lists the parties whose - // archive/// has content. Creating something here means - // creating it under a party — see createInAggregator. - var FOLDER_NAV_SLOTS = { working: 1, staging: 1, reviewing: 1 }; + // The party-partitioned workspace peers. Each is a physical top-level + // directory // whose children are / folders. + // Creating something at a peer root means choosing a party — see + // createInAggregator. (mdl/rsk rows are created via the tables tool; + // archive is the WORM record; ssr is the flat registry — none of those + // use this picker.) + var PARTY_PEERS = { incoming: 1, working: 1, staging: 1, reviewing: 1 }; - // aggregatorRoot returns { project, slot } when parentDir is a - // project-level folder-nav aggregator root (server mode only), else - // null. parentDir is a "///" URL. + // aggregatorRoot returns { project, slot } when parentDir is a party- + // partitioned peer root (server mode only), else null. parentDir is a + // "///" URL. function aggregatorRoot(parentDir) { if (state.source !== 'server') return null; var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/'); if (segs.length !== 2 || !segs[0]) return null; - var slot = segs[1].toLowerCase(); - return FOLDER_NAV_SLOTS[slot] ? { project: segs[0], slot: slot } : null; + var peer = segs[1].toLowerCase(); + return PARTY_PEERS[peer] ? { project: segs[0], slot: peer } : null; } - // rewriteAggregatorPath maps a path UNDER a folder-nav aggregator - // (a party already chosen — ///[/]) to its - // canonical archive path //archive//[/], - // mirroring the server's folder-nav redirect. Returns null when - // parentDir isn't under such an aggregator (root case is handled by - // aggregatorRoot + the picker). Covers right-clicking a party row - // shown in an aggregator listing so "New folder" doesn't 409. - function rewriteAggregatorPath(parentDir) { - if (state.source !== 'server') return null; - var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/'); - if (segs.length < 3 || !segs[0]) return null; - var slot = segs[1].toLowerCase(); - if (!FOLDER_NAV_SLOTS[slot]) return null; - var p = '/' + segs[0] + '/archive/' + segs[2] + '/' + slot + '/'; - var rest = segs.slice(3); - if (rest.length) p += rest.join('/') + '/'; - return p; - } - - // List the parties under a project's archive/ (folder names), sorted. + // List the registered parties for a project — one ssr/.yaml per + // party (the authoritative registry). A party "exists" iff its ssr row + // exists, so this is the canonical source for the picker. Returns [] + // on error. async function fetchParties(project) { try { - var entries = await loader.fetchServerChildren('/' + project + '/archive/'); + var entries = await loader.fetchServerChildren('/' + project + '/ssr/'); return entries - .filter(function (e) { return e.isDir; }) - .map(function (e) { return e.name; }) + .filter(function (e) { return !e.isDir && /\.yaml$/i.test(e.name); }) + .map(function (e) { return e.name.replace(/\.yaml$/i, ''); }) + .filter(function (n) { return n !== 'table' && n !== 'form'; }) .sort(function (a, b) { return a.localeCompare(b); }); } catch (_e) { return []; } } @@ -738,8 +725,8 @@ box.innerHTML = '

New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/

' + '

' + - escapeHtml(opts.slot) + '/ aggregates each party’s work, so it has no folder of its own. ' + - 'Pick the party this ' + kindWord + ' belongs to — it lands under archive/<party>/' + escapeHtml(opts.slot) + '/.' + + escapeHtml(opts.slot) + '/ is partitioned by party. ' + + 'Pick the party this ' + kindWord + ' belongs to — it lands under ' + escapeHtml(opts.slot) + '/<party>/.' + '

' + '
' + (partyList || 'No parties yet — create one below.') + @@ -790,13 +777,15 @@ var nv = validateName(box.querySelector('#pp-name').value); if (!nv.ok) { statusError(nv.msg); return; } close(); - resolve({ party: party, name: nv.name }); + resolve({ party: party, name: nv.name, isNew: sel.value === '__new__' }); }); }); } - // createInAggregator routes a New folder/file in a virtual aggregator - // root to archive/// after prompting for the party. + // createInAggregator routes a New folder/file at a party-peer root to + // the physical /// after prompting for the + // party. A brand-new party is registered first by creating its + // ssr/.yaml row (the authoritative registry; party_source: ssr). async function createInAggregator(agg, kind) { var up = window.app.modules.upload; if (!up) return; @@ -805,42 +794,45 @@ if (!choice) return; // Party names are validated to a URL-safe charset, so no encoding // needed for the party segment; makeDir/makeFile encode the leaf. - var targetDir = '/' + agg.project + '/archive/' + choice.party + '/' + agg.slot + '/'; + var targetDir = '/' + agg.project + '/' + agg.slot + '/' + choice.party + '/'; try { + if (choice.isNew) { + // Register the party: its existence is ssr/.yaml. + await up.makeFile('/' + agg.project + '/ssr/', choice.party + '.yaml', + 'kind: SSR\n', 'application/yaml; charset=utf-8'); + } if (kind === 'folder') { await up.makeDir(targetDir, choice.name); - statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + choice.name); + statusInfo('Created ' + choice.party + '/' + choice.name + ' in ' + agg.slot + '/'); } else { var name = /\.(md|markdown)$/i.test(choice.name) ? choice.name : choice.name + '.md'; var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n'; await up.makeFile(targetDir, name, template, 'text/markdown; charset=utf-8'); - statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + name); + statusInfo('Created ' + choice.party + '/' + name + ' in ' + agg.slot + '/'); } } catch (e) { var msg = (e && e.message) || String(e); if (/\b403\b/.test(msg)) { - statusError('Not allowed — creating a new party requires the document-controller role.'); + statusError('Not allowed — registering a new party requires the document-controller role.'); + } else if (/\b409\b/.test(msg)) { + statusError('Unknown party — register it first (document controller).'); } else { statusError('Create failed: ' + msg); } return; } - // Refresh the aggregator view — the party now appears if it had no - // content before. await reloadDir('/' + agg.project + '/' + agg.slot + '/'); } async function createInDir(parentDir, kind) { var up = window.app.modules.upload; if (!up) return; - // A project-level folder-nav aggregator (working/staging/reviewing) - // has no physical home — route through the party picker instead of - // erroring on an unplaceable mkdir/PUT. + // At a party-peer root (incoming/working/staging/reviewing) the + // create needs a party — route through the picker. Deeper paths + // (a party already chosen, e.g. working//…) are physical and + // created directly. var agg = aggregatorRoot(parentDir); if (agg) return createInAggregator(agg, kind); - // A party already chosen inside an aggregator view → canonical path. - var rewritten = rewriteAggregatorPath(parentDir); - if (rewritten) parentDir = rewritten; var promptMsg = kind === 'folder' ? 'New folder name (under ' + parentDir + '):' : 'New markdown filename (under ' + parentDir + '):'; diff --git a/browse/js/stage.js b/browse/js/stage.js index 3ff90ae..83c3302 100644 --- a/browse/js/stage.js +++ b/browse/js/stage.js @@ -1,21 +1,18 @@ // stage.js — Stage and Unstage workflow modals. // -// After the layout reshape, working/ and staging/ live INSIDE each -// party folder: archive//working// and -// archive//staging//. Stage and Unstage are now -// per-party — the destination batch is always inside the SAME -// party's staging slot. The party context is read from the source -// file's path. +// In the flat-peer layout working/ and staging/ are top-level peers, +// each partitioned by party: working// and +// staging///. Stage and Unstage are per-party — the +// destination batch is always inside the SAME party's staging peer. The +// party context is read from the source file's path. // -// Stage: move a file from archive//working/<…> into a -// transmittal folder under archive//staging/<…>. Modal lists -// existing transmittal folders in the party's staging/ plus a "New -// transmittal folder…" option that prompts for a ZDDC-conforming -// name and mkdirs it before the move. +// Stage: move a file from working//<…> into a transmittal folder +// under staging//<…>. Modal lists existing transmittal folders in +// the party's staging/ plus a "New transmittal folder…" option that +// prompts for a ZDDC-conforming name and mkdirs it before the move. // -// Unstage: move a file from archive//staging// -// back to the user's archive//working// home -// (overridable). +// Unstage: move a file from staging/// back to +// working// (overridable). // // Both reuse the existing X-ZDDC-Op: move primitive — no new composite // endpoint is needed; the client just orchestrates one POST per file @@ -36,19 +33,21 @@ // ── Scope detection: path-shape, not cascade-content ────────────── // A file is stageable if its path matches - // //archive//working/<…>. Unstageable if it - // matches //archive//staging//<…>. - // Both are path-shape queries — content/ACL is enforced server- - // side. + // //working//<…>. Unstageable if it matches + // //staging///<…>. Both are path-shape + // queries — content/ACL is enforced server-side. - // projectPartySlot returns { project, party, slot, rest } when - // path matches //archive///, or + var WORKSPACE_PEERS = { working: 1, staging: 1, reviewing: 1, incoming: 1 }; + + // projectPartySlot returns { project, party, slot, rest } when path + // matches //// for a workspace peer, or // null on non-match. function projectPartySlot(path) { var rel = path.replace(/^\/+|\/+$/g, '').split('/'); - if (rel.length < 4) return null; - if (rel[1].toLowerCase() !== 'archive') return null; - return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) }; + if (rel.length < 3) return null; + var slot = rel[1].toLowerCase(); + if (!WORKSPACE_PEERS[slot]) return null; + return { project: rel[0], slot: slot, party: rel[2], rest: rel.slice(3) }; } function isStageableFile(node) { @@ -85,7 +84,7 @@ async function fetchStagingFolders(project, party) { var entries = await listDir( - '/' + project + '/archive/' + encodeURIComponent(party) + '/staging/'); + '/' + project + '/staging/' + encodeURIComponent(party) + '/'); return entries .filter(function (e) { return e && e.isDir; }) .map(function (e) { return e.name; }); @@ -273,11 +272,11 @@ var srcUrl = tree.pathFor(node); var info = projectPartySlot(srcUrl); if (!info || info.slot !== 'working') { - status('Stage applies only to files under archive//working/.', 'error'); + status('Stage applies only to files under working//.', 'error'); return; } - var stagingBase = '/' + info.project + '/archive/' + - encodeURIComponent(info.party) + '/staging/'; + var stagingBase = '/' + info.project + '/staging/' + + encodeURIComponent(info.party) + '/'; var folders; try { folders = await fetchStagingFolders(info.project, info.party); } catch (e) { @@ -315,12 +314,11 @@ var srcUrl = tree.pathFor(node); var info = projectPartySlot(srcUrl); if (!info || info.slot !== 'staging') { - status('Unstage applies only to files under archive//staging/.', 'error'); + status('Unstage applies only to files under staging//.', 'error'); return; } - var email = await fetchSelfEmail(); - var defaultTarget = '/' + info.project + '/archive/' + - encodeURIComponent(info.party) + '/working/' + (email || '') + '/'; + var defaultTarget = '/' + info.project + '/working/' + + encodeURIComponent(info.party) + '/'; var choice; try { choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget }); diff --git a/tables/js/context.js b/tables/js/context.js index 8a7b88d..ea0a594 100644 --- a/tables/js/context.js +++ b/tables/js/context.js @@ -180,34 +180,58 @@ return cur; } + function isRowFile(name) { + return name.endsWith('.yaml') && name !== 'table.yaml' && name !== 'form.yaml'; + } + + // readRows reads a table's rows from rowsDir. A flat directory + // (a per-party register like mdl// or the ssr/ registry) + // yields one row per *.yaml file. An aggregate peer root (mdl/ , + // rsk/) instead contains party SUBDIRS — we recurse ONE level so the + // peer root renders the cross-party table. relName carries the + // / prefix for those rows so reads + edit URLs hit the real + // per-party path; $party is derived from that prefix (and matches the + // server-injected value online). Works in both online + offline modes. async function readRows(rowsDir, _rowsRel, _tableName) { const rows = []; - for await (const entry of rowsDir.values()) { - if (entry.kind !== 'file') continue; - if (!entry.name.endsWith('.yaml')) continue; - // Skip the spec and the row-edit form — they live alongside - // the rows but aren't rows themselves. - if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue; + + async function pushRow(handle, relName) { try { - const handle = await rowsDir.getFileHandle(entry.name); const file = await handle.getFile(); - const data = window.jsyaml.load(await file.text()); + const data = window.jsyaml.load(await file.text()) || {}; + const slash = relName.indexOf('/'); + if (slash > 0 && typeof data === 'object' && data.$party === undefined) { + data['$party'] = relName.slice(0, slash); + } rows.push({ - url: rowEditUrl(entry.name), - // Underlying YAML URL — strip the trailing .html - // from the form-mode re-edit URL. Phase 3 PUTs to - // this URL with If-Match: for optimistic - // concurrency. - yamlUrl: rowEditUrl(entry.name).replace(/\.html$/, ''), - data: data || {}, - // ETag captured by HttpFileHandle.getFile from the - // server's response header. null in offline / file:// - // mode (no HTTP roundtrip happened). + url: rowEditUrl(relName), + // Underlying YAML URL — strip the trailing .html from + // the form-mode re-edit URL. PUTs go here with + // If-Match: for optimistic concurrency. + yamlUrl: rowEditUrl(relName).replace(/\.html$/, ''), + data: data, etag: handle._etag || null, editable: true }); } catch (err) { - console.warn('[tables] skipping unparseable row', entry.name, err); + console.warn('[tables] skipping unparseable row', relName, err); + } + } + + for await (const entry of rowsDir.values()) { + if (entry.kind === 'file') { + if (!isRowFile(entry.name)) continue; + await pushRow(await rowsDir.getFileHandle(entry.name), entry.name); + continue; + } + if (entry.kind === 'directory') { + let sub; + try { sub = await rowsDir.getDirectoryHandle(entry.name); } + catch (_e) { continue; } + for await (const child of sub.values()) { + if (child.kind !== 'file' || !isRowFile(child.name)) continue; + await pushRow(await sub.getFileHandle(child.name), entry.name + '/' + child.name); + } } } return rows; diff --git a/tables/js/row-ops.js b/tables/js/row-ops.js index 2b55660..5939383 100644 --- a/tables/js/row-ops.js +++ b/tables/js/row-ops.js @@ -156,12 +156,11 @@ const targets = inRange ? rangeRows : [ctx.rowId]; const items = []; - // Edit row — opens the schema-driven form-mode editor for - // this row. row.url is already the .yaml.html form URL - // (the form handler unwraps virtual-view URLs server-side, so - // SSR + rollup rows route to their per-party canonical paths - // automatically). Disabled on multi-row range and unsaved - // draft rows (no backing file yet). + // Edit row — opens the schema-driven form-mode editor for this + // row. row.url is the real <…>/.yaml.html form URL (it carries + // the / prefix for aggregate rows, so it hits the real + // per-party path). Disabled on multi-row range and unsaved draft + // rows (no backing file yet). const singleRow = targets.length === 1 ? ctx.row : null; const editUrl = singleRow && !singleRow.isNew && singleRow.url ? singleRow.url : null; items.push({ diff --git a/tables/js/save.js b/tables/js/save.js index 7797636..9374877 100644 --- a/tables/js/save.js +++ b/tables/js/save.js @@ -58,11 +58,11 @@ // contain primitive / string-array values that are safe to // overwrite the corresponding top-level field. // - // $-prefixed keys are system-synthesised on read (e.g. `$party` - // injected by the server's virtual-view handler on project- - // rollup MDL/RSK rows). They are not part of the row's stored - // YAML and would be rejected by the schema's additionalProperties - // rule. Strip them before sending the write. + // $-prefixed keys are system-synthesised on read (e.g. `$party`, + // injected by the server on mdl/rsk rows or derived from the + // party subdir in the aggregate view). They are not part of the + // row's stored YAML and would be rejected by the schema's + // additionalProperties rule. Strip them before sending the write. const merged = Object.assign({}, data || {}, drafts || {}); for (const k of Object.keys(merged)) { if (k.charAt(0) === '$') delete merged[k]; diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 596cd5d..0792f0e 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -207,48 +207,16 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // Tables register peers (ssr/mdl/rsk) at the project root: // - // mdl/ and rsk/ render an AGGREGATE table — one row per physical - // ///*.yaml across all parties. This REPLACES - // the physical party-subdir listing built above so the peer root - // shows the combined table (not a folder-nav of parties). Each - // row's URL is its real path; ACL is evaluated against the row's - // own chain; the $party column is injected on read (see the rollup - // row serve). // lists its rows normally. - // - // ssr/ keeps its flat real-file listing (one ssr/.yaml per - // party = one row); only the spec entries are added. - // - // All three advertise synthetic table.yaml/form.yaml entries so the - // tables tool's client-side walkServer finds the spec without a 404 - // (spec bytes come from main.go's IsDefaultSpec fallback). + // The peer root lists normally — mdl/ and rsk/ show their party + // subdirs (folder-nav), ssr/ shows its flat ssr/.yaml rows. + // For mdl/rsk the tables tool aggregates the cross-party table by + // recursing one level into the party subdirs CLIENT-side (works in + // both online + offline modes); the $party column comes from the + // server-injected row content (see ServeInjectedRow). All three just + // advertise the synthetic table.yaml/form.yaml entries here so the + // tables tool's walkServer finds the spec without a 404 (spec bytes + // come from main.go's IsDefaultSpec fallback). if segsURL := strings.Split(strings.Trim(baseURL, "/"), "/"); len(segsURL) == 2 && zddc.IsRowSlot(segsURL[1]) { - peer := segsURL[1] - projectAbs := filepath.Join(fsRoot, segsURL[0]) - if peer == "mdl" || peer == "rsk" { - rows, _ := zddc.ListRollupRows(projectAbs, peer) - agg := make([]listing.FileInfo, 0, len(rows)+2) - rowChains := make(map[string]zddc.PolicyChain) - for _, row := range rows { - rowDir := filepath.Dir(row.Abs) - chain, ok := rowChains[rowDir] - if !ok { - chain, _ = zddc.EffectivePolicy(fsRoot, rowDir) - rowChains[rowDir] = chain - } - verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, row.RelURL) - if !verbs.Has(zddc.VerbR) { - continue - } - agg = append(agg, listing.FileInfo{ - Name: row.Filename, - URL: row.RelURL, - IsDir: false, - Writable: verbs.Has(zddc.VerbW), - Verbs: verbs.String(), - }) - } - result = agg - } // Advertise the tables specs (skip any already present on disk). have := make(map[string]bool, len(result)) for _, fi := range result { diff --git a/zddc/internal/fs/tree_test.go b/zddc/internal/fs/tree_test.go index 3900363..6eaa4d0 100644 --- a/zddc/internal/fs/tree_test.go +++ b/zddc/internal/fs/tree_test.go @@ -71,9 +71,10 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) { } } -// The mdl/ peer root renders the cross-party AGGREGATE: one entry per -// physical mdl//*.yaml (real URLs), not a folder-nav of party -// dirs. Spec entries (table.yaml/form.yaml) are advertised too. +// The mdl/ peer root lists its party subdirs (folder-nav) plus the +// synthetic table.yaml/form.yaml spec entries. The tables tool builds +// the cross-party aggregate by recursing into the party subdirs +// client-side; the server just lists normally + advertises the spec. func TestListDirectory_MdlAggregate(t *testing.T) { root := setupTreeRoot(t) mk := func(p string) { @@ -93,21 +94,13 @@ func TestListDirectory_MdlAggregate(t *testing.T) { if err != nil { t.Fatalf("list: %v", err) } - rowURLs := map[string]bool{} + names := map[string]bool{} for _, fi := range got { - if !fi.IsDir { - rowURLs[fi.URL] = true - } + names[fi.Name] = true } - for _, want := range []string{"/Proj/mdl/Acme/D-001.yaml", "/Proj/mdl/Beta/D-009.yaml"} { - if !rowURLs[want] { - t.Errorf("aggregate listing missing row %q; got %+v", want, got) - } - } - // No party SUBDIR entries in the aggregate (rows, not folders). - for _, fi := range got { - if fi.IsDir { - t.Errorf("aggregate mdl/ should not list party dirs; got dir %q", fi.Name) + for _, want := range []string{"Acme/", "Beta/", "table.yaml", "form.yaml"} { + if !names[want] { + t.Errorf("mdl/ listing missing %q; got %+v", want, got) } } } diff --git a/zddc/internal/zddc/virtualviews.go b/zddc/internal/zddc/virtualviews.go index 965ced5..290381a 100644 --- a/zddc/internal/zddc/virtualviews.go +++ b/zddc/internal/zddc/virtualviews.go @@ -122,68 +122,3 @@ func ListParties(projectAbs string) ([]string, error) { sort.Strings(out) return out, nil } - -// RollupRow describes one row in an mdl/ or rsk/ aggregate table, -// gathered from the physical ///.yaml. -type RollupRow struct { - Party string // source party (real subdir name → the $party column) - Filename string // e.g. "ACME-PRJ-EL-SPC-0001.yaml" - Abs string // underlying file on disk - RelURL string // ////.yaml -} - -// ListRollupRows walks //*/*.yaml (peer = mdl|rsk) and -// returns one row per *.yaml file, sorted by (party, filename). The -// $party column is the real subdir name. Skipped: table.yaml / form.yaml -// specs, non-*.yaml files, and party dirs with invalid names. Returns -// nil + nil when the peer dir doesn't exist on disk. -func ListRollupRows(projectAbs, peer string) ([]RollupRow, error) { - if peer != "mdl" && peer != "rsk" { - return nil, errors.New("ListRollupRows: peer must be mdl or rsk") - } - peerAbs := filepath.Join(projectAbs, peer) - partyEntries, err := os.ReadDir(peerAbs) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - return nil, err - } - project := filepath.Base(projectAbs) - var out []RollupRow - for _, pe := range partyEntries { - if !pe.IsDir() { - continue - } - party := pe.Name() - if !ValidPartyName(party) { - continue - } - partyDir := filepath.Join(peerAbs, party) - rows, err := os.ReadDir(partyDir) - if err != nil { - return nil, err - } - for _, e := range rows { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { - continue - } - if e.Name() == "table.yaml" || e.Name() == "form.yaml" { - continue - } - out = append(out, RollupRow{ - Party: party, - Filename: e.Name(), - Abs: filepath.Join(partyDir, e.Name()), - RelURL: "/" + project + "/" + peer + "/" + party + "/" + e.Name(), - }) - } - } - sort.Slice(out, func(i, j int) bool { - if out[i].Party != out[j].Party { - return out[i].Party < out[j].Party - } - return out[i].Filename < out[j].Filename - }) - return out, nil -} diff --git a/zddc/internal/zddc/virtualviews_test.go b/zddc/internal/zddc/virtualviews_test.go index 19c762f..880fe86 100644 --- a/zddc/internal/zddc/virtualviews_test.go +++ b/zddc/internal/zddc/virtualviews_test.go @@ -140,52 +140,3 @@ func TestListParties_NoRegistry(t *testing.T) { t.Errorf("got %v, want empty", parties) } } - -// ListRollupRows aggregates physical ///*.yaml. -func TestListRollupRows(t *testing.T) { - root := t.TempDir() - projectAbs := filepath.Join(root, "Project") - - for _, party := range []string{"0330C1", "0440P2"} { - if err := os.MkdirAll(filepath.Join(projectAbs, "mdl", party), 0o755); err != nil { - t.Fatal(err) - } - } - mustWrite := func(p string) { - if err := os.WriteFile(p, []byte("id: x\n"), 0o644); err != nil { - t.Fatal(err) - } - } - mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "D-001.yaml")) - mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "D-002.yaml")) - mustWrite(filepath.Join(projectAbs, "mdl", "0440P2", "D-010.yaml")) - // Skipped: table.yaml / form.yaml specs. - mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "table.yaml")) - mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "form.yaml")) - - rows, err := ListRollupRows(projectAbs, "mdl") - if err != nil { - t.Fatal(err) - } - if len(rows) != 3 { - t.Fatalf("got %d rows, want 3; rows=%+v", len(rows), rows) - } - // Sorted by (party, filename); $party is the real subdir. - want := []struct{ party, file, relURL string }{ - {"0330C1", "D-001.yaml", "/Project/mdl/0330C1/D-001.yaml"}, - {"0330C1", "D-002.yaml", "/Project/mdl/0330C1/D-002.yaml"}, - {"0440P2", "D-010.yaml", "/Project/mdl/0440P2/D-010.yaml"}, - } - for i, w := range want { - if rows[i].Party != w.party || rows[i].Filename != w.file || rows[i].RelURL != w.relURL { - t.Errorf("row[%d] = %+v want party=%q file=%q rel=%q", i, rows[i], w.party, w.file, w.relURL) - } - } -} - -func TestListRollupRows_BadPeer(t *testing.T) { - root := t.TempDir() - if _, err := ListRollupRows(filepath.Join(root, "Project"), "ssr"); err == nil { - t.Error("expected error for peer=ssr (only mdl/rsk valid)") - } -}