1126 lines
41 KiB
Markdown
1126 lines
41 KiB
Markdown
# Multi-Branch Picker Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Stop emitting all branches of a multi-mod Workshop item (e.g. AuthenticZ) into `Mods=`, which silently bricks PZ servers. Add a per-wsid picker UI plus a `/api/resort` endpoint that recomputes load order and warnings against the user's selection.
|
||
|
||
**Architecture:** Spec at `/opt/sortof/docs/specs/2026-04-30-multi-branch-picker.md` (v2, 155 lines, all decisions locked in §10). Backend grows one new POST endpoint and one new WARNINGS tag. Frontend adds picker state, localStorage persistence, a `BranchPicker` sub-component, and resort fetch with sequence-number race protection. No schema migration. No new venv.
|
||
|
||
**Tech Stack:** FastAPI + asyncpg + Pydantic on the backend; React 18 + Babel-standalone (in-browser JSX, no build) on the frontend; CSS in `index.html`. Persistence via `localStorage` only.
|
||
|
||
---
|
||
|
||
## File structure
|
||
|
||
| Path | Action | Responsibility |
|
||
|---|---|---|
|
||
| `/opt/sortof/api/app.py` | Modify | Add `ResortRequest` model + `POST /api/resort` route. No other change. |
|
||
| `/opt/sortof/api/adapters.py` | Modify | Add `_compute_ambiguous_warnings()`. Inject result into `build_response`. |
|
||
| `/opt/sortof/frontend/sortof-app.jsx` | Modify | Add `COLUMN_COUNT`, `branchSelections` state + hydration + storage listener, `BranchPicker` component, refactor `ModTable` to group multi-branch wsids, `resort()` with sequence number, 5xx handling. |
|
||
| `/opt/sortof/frontend/index.html` | Modify | CSS for `.branch-affordance`, `.branch-panel`, `.branch-row`. |
|
||
|
||
No new files. The two `mlos_sort.py` copies are unchanged - dangling-deps detection already exists in `mlos_sort.sort_mods`.
|
||
|
||
**Verification fixtures (already cached, no /api/sort cold-start required):**
|
||
- 3-mod canonical (single-mod wsids, regression baseline): `2169435993;2392709985;2487022075` → `MODS_LINE="modoptions;tsarslib;TMC_TrueActions"`.
|
||
- AuthenticZ multi-branch (3 mod_ids on one wsid, all `incompatible_mods=[]`): wsid `2335368829` → mod_ids `Authentic Z - Current`, `AuthenticZBackpacks+`, `AuthenticZLite`.
|
||
|
||
---
|
||
|
||
## Task 1: Backend - `POST /api/resort` endpoint
|
||
|
||
**Files:**
|
||
- Modify: `/opt/sortof/api/app.py` (add Pydantic model + route)
|
||
|
||
- [ ] **Step 1: Backup app.py**
|
||
|
||
```bash
|
||
cp /opt/sortof/api/app.py /opt/sortof/api/app.py.bak-$(date +%Y%m%d-%H%M)
|
||
```
|
||
|
||
- [ ] **Step 2: Add `ResortRequest` Pydantic model**
|
||
|
||
Find the existing `class SortRequest(BaseModel):` block (around line 76). Replace it with both models:
|
||
|
||
```python
|
||
class SortRequest(BaseModel):
|
||
input: str = Field(default="")
|
||
rules: Optional[str] = Field(default=None)
|
||
|
||
|
||
class ResortRequest(BaseModel):
|
||
selected_mod_ids: List[str] = Field(default_factory=list)
|
||
```
|
||
|
||
- [ ] **Step 3: Add `/api/resort` route**
|
||
|
||
Find the line right after the existing `sort_endpoint` function returns (the line `return payload` near the bottom of the function - currently line ~236). The static-files mount block follows. Insert the new route **between** them:
|
||
|
||
```python
|
||
@app.post("/api/resort")
|
||
async def resort_endpoint(req: ResortRequest, request: Request) -> Dict[str, Any]:
|
||
"""Re-run mlos_sort against a user-supplied subset of mod_ids.
|
||
|
||
Stateless. No Steam round-trip. Filters mod_parsed by the requested
|
||
mod_ids, runs sort_mods, returns the same shape as /api/sort.
|
||
"""
|
||
t0 = time.monotonic()
|
||
ids = list(req.selected_mod_ids or [])
|
||
|
||
if not ids:
|
||
raise HTTPException(status_code=400, detail="selected_mod_ids must be non-empty")
|
||
if len(ids) > MAX_IDS:
|
||
raise HTTPException(status_code=413, detail=f"too many mod_ids ({len(ids)} > {MAX_IDS})")
|
||
for mod_id in ids:
|
||
if not isinstance(mod_id, str) or not (1 <= len(mod_id) <= 256):
|
||
raise HTTPException(status_code=400, detail="invalid mod_id")
|
||
|
||
pool = request.app.state.db
|
||
async with pool.acquire() as conn:
|
||
rows = await conn.fetch(
|
||
"""
|
||
SELECT mp.workshop_id, mp.mod_id, mp.name, mp.category,
|
||
mp.requirements, mp.load_after, mp.load_before,
|
||
mp.incompatible_mods, mp.load_first, mp.load_last,
|
||
mp.tags, mp.maps
|
||
FROM mod_parsed mp
|
||
JOIN workshop_meta wm ON wm.workshop_id = mp.workshop_id
|
||
WHERE mp.mod_id = ANY($1::text[])
|
||
AND mp.parsed_at_time_updated = wm.time_updated
|
||
ORDER BY mp.workshop_id, mp.mod_id
|
||
""",
|
||
ids,
|
||
)
|
||
|
||
found_ids = {r["mod_id"] for r in rows}
|
||
missing = [mid for mid in ids if mid not in found_ids]
|
||
if missing:
|
||
log.info("resort dropped unknown mod_ids count=%d sample=%s",
|
||
len(missing), missing[:3])
|
||
if not rows:
|
||
raise HTTPException(status_code=400, detail="no known mod_ids in selection")
|
||
|
||
mods: List[ModInfo] = [
|
||
ModInfo(
|
||
id=r["mod_id"],
|
||
name=r["name"] or r["mod_id"],
|
||
workshop_id=r["workshop_id"],
|
||
category=r["category"] or "undefined",
|
||
requirements=list(r["requirements"] or []),
|
||
loadAfter=list(r["load_after"] or []),
|
||
loadBefore=list(r["load_before"] or []),
|
||
incompatibleMods=list(r["incompatible_mods"] or []),
|
||
loadFirst=r["load_first"] or "off",
|
||
loadLast=r["load_last"] or "off",
|
||
tags=list(r["tags"] or []),
|
||
maps=list(r["maps"] or []),
|
||
)
|
||
for r in rows
|
||
]
|
||
sort_result = sort_mods(mods, {})
|
||
payload = adapters.build_response(
|
||
input_ids=[],
|
||
hit_ids=ids,
|
||
mods=mods,
|
||
sort_result=sort_result,
|
||
status="success",
|
||
)
|
||
payload["pending"] = []
|
||
|
||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||
log.info("resort done count=%d ms=%d", len(rows), elapsed_ms)
|
||
return payload
|
||
```
|
||
|
||
- [ ] **Step 4: py_compile + smoke import**
|
||
|
||
```bash
|
||
/opt/sortof/api/.venv/bin/python -m py_compile /opt/sortof/api/app.py && echo PY_OK
|
||
cd /opt/sortof/api && .venv/bin/python -c "import app" && echo IMPORT_OK
|
||
```
|
||
|
||
Expected: both lines print `PY_OK` and `IMPORT_OK` respectively. If either fails, fix the syntax/import before restarting.
|
||
|
||
- [ ] **Step 5: Restart API**
|
||
|
||
```bash
|
||
sudo systemctl restart sortof-api && sleep 2 && sudo systemctl is-active sortof-api
|
||
```
|
||
|
||
Expected: `active`.
|
||
|
||
- [ ] **Step 6: Verify happy path**
|
||
|
||
```bash
|
||
curl -sS -X POST http://100.114.205.53:8801/api/resort \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"selected_mod_ids":["modoptions","tsarslib","TMC_TrueActions"]}' \
|
||
| jq '{status, MODS_LINE, mod_db_count: (.MOD_DB|length), pending}'
|
||
```
|
||
|
||
Expected:
|
||
```json
|
||
{"status":"success","MODS_LINE":"modoptions;tsarslib;TMC_TrueActions","mod_db_count":3,"pending":[]}
|
||
```
|
||
|
||
- [ ] **Step 7: Verify validation 400s**
|
||
|
||
```bash
|
||
# Empty selection → 400
|
||
curl -sS -o /dev/null -w 'empty=%{http_code}\n' -X POST http://100.114.205.53:8801/api/resort \
|
||
-H 'Content-Type: application/json' -d '{"selected_mod_ids":[]}'
|
||
|
||
# All-unknown selection → 400
|
||
curl -sS -o /dev/null -w 'unknown=%{http_code}\n' -X POST http://100.114.205.53:8801/api/resort \
|
||
-H 'Content-Type: application/json' -d '{"selected_mod_ids":["ghostA","ghostB"]}'
|
||
|
||
# Mixed (one valid + one unknown) → 200, ghost dropped
|
||
curl -sS -X POST http://100.114.205.53:8801/api/resort \
|
||
-H 'Content-Type: application/json' -d '{"selected_mod_ids":["modoptions","ghostMod"]}' \
|
||
| jq '{status, mod_db_count: (.MOD_DB|length)}'
|
||
```
|
||
|
||
Expected:
|
||
```
|
||
empty=400
|
||
unknown=400
|
||
{"status":"success","mod_db_count":1}
|
||
```
|
||
|
||
- [ ] **Step 8: Checkpoint** - task complete; backup file remains as rollback.
|
||
|
||
---
|
||
|
||
## Task 2: Backend - `ambiguous-multi-branch` warning
|
||
|
||
**Files:**
|
||
- Modify: `/opt/sortof/api/adapters.py`
|
||
|
||
- [ ] **Step 1: Backup adapters.py**
|
||
|
||
```bash
|
||
cp /opt/sortof/api/adapters.py /opt/sortof/api/adapters.py.bak-$(date +%Y%m%d-%H%M)
|
||
```
|
||
|
||
- [ ] **Step 2: Add helper for ambiguous-multi-branch detection**
|
||
|
||
Open `/opt/sortof/api/adapters.py`. Just below the existing `build_warnings()` function (around line 56), insert:
|
||
|
||
```python
|
||
def _compute_ambiguous_warnings(mods: List[ModInfo]) -> List[Dict[str, str]]:
|
||
"""Emit one amber WARNINGS entry per wsid that has >=2 selected mod_ids,
|
||
none of them flagging others in `incompatibleMods`. The user is at risk
|
||
of shipping conflicting alternates (e.g. AuthenticZ Lite + Current);
|
||
show this even when they don't expand the picker.
|
||
|
||
Spec: 2026-04-30-multi-branch-picker.md §4 "default-selection safety net"
|
||
(review #1 fix).
|
||
"""
|
||
by_wsid: Dict[str, List[ModInfo]] = {}
|
||
for m in mods:
|
||
wsid = m.workshop_id or ""
|
||
if not wsid:
|
||
continue
|
||
by_wsid.setdefault(wsid, []).append(m)
|
||
|
||
out: List[Dict[str, str]] = []
|
||
for wsid, group in by_wsid.items():
|
||
if len(group) < 2:
|
||
continue
|
||
ids_in_group = {m.id for m in group}
|
||
any_flags_sibling = any(
|
||
any(other in ids_in_group for other in m.incompatibleMods)
|
||
for m in group
|
||
)
|
||
if any_flags_sibling:
|
||
continue # author marked alternates; picker will run in radio mode
|
||
title = group[0].name or wsid
|
||
out.append({
|
||
"tag": "ambiguous-multi-branch",
|
||
"level": "amber",
|
||
"msg": (
|
||
f"{len(group)} branches selected from {title} ({wsid}) - "
|
||
f"author didn't declare alternates; verify these aren't "
|
||
f"mutually exclusive (e.g., AuthenticZ Lite vs Current). "
|
||
f"Expand the row to pick one."
|
||
),
|
||
})
|
||
return out
|
||
```
|
||
|
||
- [ ] **Step 3: Wire helper into `build_response`**
|
||
|
||
Find the existing `return { ... }` at the bottom of `build_response()` (around line 103-112). Replace the `"WARNINGS": build_warnings(...)` line with:
|
||
|
||
```python
|
||
"WARNINGS": (
|
||
build_warnings(sort_result.get("warnings", {}) or {})
|
||
+ _compute_ambiguous_warnings(mods)
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 4: py_compile + smoke import**
|
||
|
||
```bash
|
||
/opt/sortof/api/.venv/bin/python -m py_compile /opt/sortof/api/adapters.py && echo PY_OK
|
||
cd /opt/sortof/api && .venv/bin/python -c "import app" && echo IMPORT_OK
|
||
```
|
||
|
||
- [ ] **Step 5: Restart API**
|
||
|
||
```bash
|
||
sudo systemctl restart sortof-api && sleep 2 && sudo systemctl is-active sortof-api
|
||
```
|
||
|
||
- [ ] **Step 6: Verify warning fires for AuthenticZ**
|
||
|
||
```bash
|
||
curl -sS -X POST http://100.114.205.53:8801/api/sort \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"input":"2335368829"}' \
|
||
| jq '.WARNINGS[] | select(.tag == "ambiguous-multi-branch")'
|
||
```
|
||
|
||
Expected: one object with `tag:"ambiguous-multi-branch"`, `level:"amber"`, and a `msg` mentioning "3 branches" and `2335368829`.
|
||
|
||
- [ ] **Step 7: Verify warning DOES NOT fire for the canonical 3-mod input**
|
||
|
||
```bash
|
||
curl -sS -X POST http://100.114.205.53:8801/api/sort \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"input":"2169435993;2392709985;2487022075"}' \
|
||
| jq '[.WARNINGS[] | select(.tag == "ambiguous-multi-branch")] | length'
|
||
```
|
||
|
||
Expected: `0` (each wsid has only 1 mod row → no ambiguity).
|
||
|
||
- [ ] **Step 8: Verify warning DOES NOT fire when subset is selected via /api/resort**
|
||
|
||
```bash
|
||
curl -sS -X POST http://100.114.205.53:8801/api/resort \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"selected_mod_ids":["Authentic Z - Current"]}' \
|
||
| jq '[.WARNINGS[] | select(.tag == "ambiguous-multi-branch")] | length'
|
||
```
|
||
|
||
Expected: `0` (one mod from the wsid, not a multi-branch ambiguity).
|
||
|
||
---
|
||
|
||
## Task 3: Frontend - `COLUMN_COUNT` + `branchSelections` state + hydration + storage listener
|
||
|
||
**Files:**
|
||
- Modify: `/opt/sortof/frontend/sortof-app.jsx`
|
||
|
||
- [ ] **Step 1: Backup**
|
||
|
||
```bash
|
||
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
|
||
```
|
||
|
||
- [ ] **Step 2: Add `COLUMN_COUNT` constant**
|
||
|
||
Find the helper functions near the top of the file (after `buildModsLine`, around line 35). Add:
|
||
|
||
```jsx
|
||
// Shared column count for the Mod Details table. Keep in sync with the
|
||
// <thead> in ModTable; expansion-panel <tr colSpan> reads this constant.
|
||
// Spec C will add a 7th column; bump here, do not hardcode the integer.
|
||
const COLUMN_COUNT = 6;
|
||
```
|
||
|
||
- [ ] **Step 3: Add `branchSelections` App-level state with localStorage hydration**
|
||
|
||
Find the App function's state declarations (around line 456). Below the existing `pzBuild` block, add:
|
||
|
||
```jsx
|
||
const [branchSelections, setBranchSelections] = useState(() => {
|
||
try {
|
||
const raw = localStorage.getItem('sortof.branch.selections');
|
||
if (!raw) return {};
|
||
const parsed = JSON.parse(raw);
|
||
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
|
||
} catch { return {}; }
|
||
});
|
||
const [resortSeq, setResortSeq] = useState(0); // monotonic; for stale-response drop
|
||
```
|
||
|
||
- [ ] **Step 4: Add useEffect for persist + cross-tab `storage` listener**
|
||
|
||
Below the `pzBuild` localStorage useEffect (around line 467), add:
|
||
|
||
```jsx
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem('sortof.branch.selections', JSON.stringify(branchSelections));
|
||
} catch {}
|
||
}, [branchSelections]);
|
||
|
||
useEffect(() => {
|
||
function onStorage(e) {
|
||
if (e.key !== 'sortof.branch.selections' || e.newValue === null) return;
|
||
try {
|
||
const parsed = JSON.parse(e.newValue);
|
||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||
setBranchSelections(parsed);
|
||
}
|
||
} catch {}
|
||
}
|
||
window.addEventListener('storage', onStorage);
|
||
return () => window.removeEventListener('storage', onStorage);
|
||
}, []);
|
||
```
|
||
|
||
- [ ] **Step 5: Verify served file picks up the new constants**
|
||
|
||
```bash
|
||
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE 'COLUMN_COUNT|branchSelections|sortof.branch.selections'
|
||
```
|
||
|
||
Expected: `>= 6` (constant declaration + state name uses + localStorage key uses).
|
||
|
||
- [ ] **Step 6: Manual browser smoke test**
|
||
|
||
In a browser tab on `http://100.114.205.53:8801/`:
|
||
|
||
1. Open DevTools console.
|
||
2. Run `localStorage.setItem('sortof.branch.selections', JSON.stringify({"2335368829": ["Authentic Z - Current"]}))`.
|
||
3. Hard-refresh the page.
|
||
4. In console: `localStorage.getItem('sortof.branch.selections')` - should return the same JSON.
|
||
5. App should render without errors (no behavior change yet - picker UI is Task 4-5).
|
||
|
||
If the console shows JSON parse errors or "branchSelections is not defined", revert to backup and re-do.
|
||
|
||
---
|
||
|
||
## Task 4: Frontend - `ModTable` refactor to group multi-branch wsids
|
||
|
||
**Files:**
|
||
- Modify: `/opt/sortof/frontend/sortof-app.jsx` (replace `ModTable` body around line 306; pass `branchSelections` from App)
|
||
|
||
- [ ] **Step 1: Backup** (if more than ~10 minutes since last)
|
||
|
||
```bash
|
||
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
|
||
```
|
||
|
||
- [ ] **Step 2: Replace `ModTable` with the wsid-grouping version**
|
||
|
||
Find the existing `function ModTable({ defaultOpen = false }) {` block (around line 306) and the closing `}` of that function (around line 351). Replace the entire function with:
|
||
|
||
```jsx
|
||
function ModTable({ defaultOpen = false, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion }) {
|
||
const [open, setOpen] = useState(defaultOpen);
|
||
|
||
// Group MOD_DB rows by wsid. For wsids with >1 mod, render a single parent
|
||
// row and an expansion <tr colSpan={COLUMN_COUNT}> with the picker.
|
||
const groupedByWsid = useMemo(() => {
|
||
const g = {};
|
||
for (const m of (D.MOD_DB || [])) {
|
||
const w = m.wsid || '';
|
||
if (!w) continue;
|
||
(g[w] = g[w] || []).push(m);
|
||
}
|
||
return g;
|
||
}, [D.MOD_DB]);
|
||
|
||
// Walk SORTED_ORDER and emit one row-spec per wsid (deduped).
|
||
const rowSpecs = useMemo(() => {
|
||
const specs = [];
|
||
const seenWsid = new Set();
|
||
for (const modId of (D.SORTED_ORDER || [])) {
|
||
const m = (D.MOD_DB || []).find(x => x.modId === modId);
|
||
if (!m || !m.wsid) continue;
|
||
if (seenWsid.has(m.wsid)) continue;
|
||
seenWsid.add(m.wsid);
|
||
const branches = groupedByWsid[m.wsid] || [m];
|
||
const isMulti = branches.length >= 2;
|
||
specs.push({ wsid: m.wsid, primary: m, branches, isMulti });
|
||
}
|
||
// Wsids whose parent had zero selected mods: not in SORTED_ORDER. Append.
|
||
for (const w of Object.keys(groupedByWsid)) {
|
||
if (!seenWsid.has(w)) {
|
||
const branches = groupedByWsid[w];
|
||
specs.push({ wsid: w, primary: branches[0], branches, isMulti: branches.length >= 2 });
|
||
}
|
||
}
|
||
return specs;
|
||
}, [D.SORTED_ORDER, D.MOD_DB, groupedByWsid]);
|
||
|
||
return (
|
||
<div className="panel table-section">
|
||
<div className="tbl-head" onClick={() => setOpen(!open)}>
|
||
<span>mod details</span>
|
||
<span className="count">· {(D.SORTED_ORDER || []).length} mods</span>
|
||
<span style={{ color: 'var(--fg-3)', fontSize: 10.5, marginLeft: 8 }}>
|
||
why everything ended up where it did
|
||
</span>
|
||
<span className="chev">{open ? '▾' : '▸'}</span>
|
||
</div>
|
||
{open && (
|
||
<table className="mods-table">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>mod id</th>
|
||
<th>workshop id</th>
|
||
<th>category</th>
|
||
<th>dependencies</th>
|
||
<th>load</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rowSpecs.map((spec, i) => {
|
||
const idx = String(i + 1).padStart(2, '0');
|
||
if (!spec.isMulti) {
|
||
const m = spec.primary;
|
||
return (
|
||
<tr key={spec.wsid}>
|
||
<td className="idx">{idx}</td>
|
||
<td><span className="modid">{m.modId}</span></td>
|
||
<td><span className="wsid">{spec.wsid}</span></td>
|
||
<td><span className={'cat ' + m.cat}>{m.cat}</span></td>
|
||
<td><span className="deps">{m.deps && m.deps.length ? m.deps.join(', ') : '-'}</span></td>
|
||
<td>
|
||
{m.pos === 'first' && <span className="pos first">first</span>}
|
||
{m.pos === 'last' && <span className="pos last">last</span>}
|
||
{!m.pos && <span className="pos">-</span>}
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
// Multi-branch wsid: parent row + (optional) expansion panel.
|
||
const selected = branchSelections[spec.wsid] || [];
|
||
const N = spec.branches.length;
|
||
const X = selected.length;
|
||
const userTouched = (spec.wsid in branchSelections);
|
||
const affordance = userTouched ? `✓ ${X} of ${N}` : `▾ ${N} branches`;
|
||
const firstSelected = spec.branches.find(b => selected.includes(b.modId)) || spec.branches[0];
|
||
const showAsZero = userTouched && X === 0;
|
||
const display = showAsZero ? null : firstSelected;
|
||
const expanded = expandedWsids.has(spec.wsid);
|
||
return (
|
||
<React.Fragment key={spec.wsid}>
|
||
<tr>
|
||
<td className="idx">{idx}</td>
|
||
<td>
|
||
<button className="branch-affordance"
|
||
onClick={() => onToggleExpansion(spec.wsid)}>
|
||
{affordance}
|
||
</button>
|
||
</td>
|
||
<td><span className="wsid">{spec.wsid}</span></td>
|
||
<td>{display ? <span className={'cat ' + display.cat}>{display.cat}</span> : '-'}</td>
|
||
<td>{display && display.deps && display.deps.length ? display.deps.join(', ') : '-'}</td>
|
||
<td>
|
||
{display && display.pos === 'first' && <span className="pos first">first</span>}
|
||
{display && display.pos === 'last' && <span className="pos last">last</span>}
|
||
{(!display || !display.pos) && <span className="pos">-</span>}
|
||
</td>
|
||
</tr>
|
||
{expanded && (
|
||
<BranchPicker
|
||
wsid={spec.wsid}
|
||
branches={spec.branches}
|
||
selected={selected}
|
||
userTouched={userTouched}
|
||
onToggle={onToggleBranch}
|
||
/>
|
||
)}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update App to maintain `expandedWsids` state and pass everything down**
|
||
|
||
Find App's other useState calls. Add:
|
||
|
||
```jsx
|
||
const [expandedWsids, setExpandedWsids] = useState(() => new Set());
|
||
|
||
const onToggleExpansion = (wsid) => {
|
||
setExpandedWsids(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(wsid)) next.delete(wsid); else next.add(wsid);
|
||
return next;
|
||
});
|
||
};
|
||
```
|
||
|
||
(`onToggleBranch` is added in Task 5 - leave it for now; the existing `<ModTable defaultOpen={modTableDefault} />` invocation in `RightColumn` needs updating in Step 4 below.)
|
||
|
||
- [ ] **Step 4: Pass props through `RightColumn` to `ModTable`**
|
||
|
||
Find the `RightColumn` function signature (around line 372). Add `branchSelections, onToggleBranch, expandedWsids, onToggleExpansion` to the destructured props:
|
||
|
||
```jsx
|
||
function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion }) {
|
||
```
|
||
|
||
Then find the `<ModTable defaultOpen={modTableDefault} />` invocation inside `RightColumn` (around line 437). Replace with:
|
||
|
||
```jsx
|
||
<ModTable
|
||
defaultOpen={modTableDefault}
|
||
branchSelections={branchSelections}
|
||
onToggleBranch={onToggleBranch}
|
||
expandedWsids={expandedWsids}
|
||
onToggleExpansion={onToggleExpansion}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 5: Pass props from App to `RightColumn`**
|
||
|
||
Find the `<RightColumn …/>` invocation in App (around line 613). Append:
|
||
|
||
```jsx
|
||
branchSelections={branchSelections}
|
||
onToggleBranch={() => {}}
|
||
expandedWsids={expandedWsids}
|
||
onToggleExpansion={onToggleExpansion}
|
||
```
|
||
|
||
(`onToggleBranch` is a placeholder no-op for now; replaced in Task 5.)
|
||
|
||
- [ ] **Step 6: Verify served file**
|
||
|
||
```bash
|
||
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE 'BranchPicker|expandedWsids|branch-affordance|onToggleExpansion'
|
||
```
|
||
|
||
Expected: `>= 6`.
|
||
|
||
- [ ] **Step 7: Manual browser smoke test**
|
||
|
||
Hard-refresh the page. Submit a sort with `2335368829` (AuthenticZ alone) and any cached single-mod input. Open the Mod Details table. Expectation:
|
||
- AuthenticZ appears as **one row** with `▾ 3 branches` (or `✓ X of N` if hydrated from earlier Task 3 testing).
|
||
- Single-mod wsids appear as normal rows.
|
||
- Clicking the `▾` affordance toggles `expandedWsids` (panel visibility - content is empty until Task 5 adds `BranchPicker`).
|
||
- Console: no errors. (`BranchPicker is not defined` is expected at this point if you click - Task 5 fixes.)
|
||
|
||
---
|
||
|
||
## Task 5: Frontend - `BranchPicker` expansion panel + default selection + radio/checkbox modes
|
||
|
||
**Files:**
|
||
- Modify: `/opt/sortof/frontend/sortof-app.jsx`
|
||
|
||
- [ ] **Step 1: Backup**
|
||
|
||
```bash
|
||
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
|
||
```
|
||
|
||
- [ ] **Step 2: Add `defaultSelectionForBranches()` helper near top of file**
|
||
|
||
Right after `buildModsLine` (around line 35), insert:
|
||
|
||
```jsx
|
||
// Default selection for a multi-branch wsid (spec §4):
|
||
// - if any branch lists another in `incompatible_mods`, default = [first only]
|
||
// - else default = [all branches]
|
||
// "First" tiebreaker: branches arrive sorted by parsed_at ASC, mod_id ASC from
|
||
// the API; we trust that order and pick branches[0].
|
||
function defaultSelectionForBranches(branches) {
|
||
if (!branches || !branches.length) return [];
|
||
const ids = new Set(branches.map(b => b.modId));
|
||
const isRadio = branches.some(b => (b.conflicts || []).some(c => ids.has(c)));
|
||
if (isRadio) return [branches[0].modId];
|
||
return branches.map(b => b.modId);
|
||
}
|
||
|
||
function isRadioMode(branches) {
|
||
if (!branches || branches.length < 2) return false;
|
||
const ids = new Set(branches.map(b => b.modId));
|
||
return branches.some(b => (b.conflicts || []).some(c => ids.has(c)));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add `BranchPicker` component**
|
||
|
||
Just above `function ModTable(` (around line 306, **before** the table), insert:
|
||
|
||
```jsx
|
||
function BranchPicker({ wsid, branches, selected, userTouched, onToggle }) {
|
||
const radio = isRadioMode(branches);
|
||
const inputType = radio ? 'radio' : 'checkbox';
|
||
// If user hasn't touched yet, render the default selection (so checkboxes
|
||
// reflect the implicit state). The actual default is also assumed by the
|
||
// rest of the system whenever branchSelections[wsid] is missing.
|
||
const effective = userTouched ? selected : defaultSelectionForBranches(branches);
|
||
return (
|
||
<tr>
|
||
<td colSpan={COLUMN_COUNT} className="branch-panel">
|
||
<div className="branch-panel-inner">
|
||
<div className="branch-panel-meta">
|
||
workshop {wsid} · {branches.length} mod_ids · {radio ? 'pick one' : 'multi-select'}
|
||
</div>
|
||
{branches.map(b => {
|
||
const checked = effective.includes(b.modId);
|
||
return (
|
||
<label key={b.modId} className={'branch-row' + (checked ? ' is-checked' : '')}>
|
||
<input
|
||
type={inputType}
|
||
name={radio ? `branch-${wsid}` : undefined}
|
||
className="branch-input"
|
||
checked={checked}
|
||
onChange={() => onToggle(wsid, b.modId, branches)}
|
||
/>
|
||
<span className="branch-modid">{b.modId}</span>
|
||
<span className="branch-name">{b.name}</span>
|
||
<span className={'cat ' + b.cat}>{b.cat}</span>
|
||
<span className="branch-deps">
|
||
{b.deps && b.deps.length ? b.deps.join(', ') : '-'}
|
||
</span>
|
||
<span className="branch-pos">
|
||
{b.pos === 'first' ? 'first' : (b.pos === 'last' ? 'last' : '-')}
|
||
</span>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement `onToggleBranch` in App**
|
||
|
||
Find the App function (around line 454). Below the `expandedWsids` state, add:
|
||
|
||
```jsx
|
||
const onToggleBranch = (wsid, modId, branches) => {
|
||
setBranchSelections(prev => {
|
||
const radio = isRadioMode(branches);
|
||
const cur = prev[wsid] !== undefined
|
||
? prev[wsid]
|
||
: defaultSelectionForBranches(branches);
|
||
let next;
|
||
if (radio) {
|
||
next = [modId]; // exactly one
|
||
} else {
|
||
next = cur.includes(modId)
|
||
? cur.filter(x => x !== modId)
|
||
: [...cur, modId];
|
||
}
|
||
return { ...prev, [wsid]: next };
|
||
});
|
||
// Resort fetch is wired in Task 6; for now this just updates state.
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 5: Replace the no-op `onToggleBranch` placeholder in `<RightColumn>`**
|
||
|
||
Find the `<RightColumn …/>` invocation in App (Task 4 added `onToggleBranch={() => {}}`). Replace with:
|
||
|
||
```jsx
|
||
onToggleBranch={onToggleBranch}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify served file**
|
||
|
||
```bash
|
||
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE 'BranchPicker|defaultSelectionForBranches|isRadioMode|branch-row'
|
||
```
|
||
|
||
Expected: `>= 8`.
|
||
|
||
- [ ] **Step 7: Manual browser smoke**
|
||
|
||
Hard-refresh. Submit AuthenticZ (`2335368829`). Click the `▾ 3 branches` affordance.
|
||
|
||
Expected:
|
||
- Panel expands with 3 rows.
|
||
- All 3 boxes are checked (default-all per §4 since `incompatible_mods=[]` on all).
|
||
- Click one box to uncheck. Affordance changes to `✓ 2 of 3`. Storage event listener writes to localStorage (`localStorage.getItem('sortof.branch.selections')`).
|
||
- The Mods= line in the output panel currently still includes all 3 mod_ids (resort hookup is Task 6).
|
||
|
||
Console: no errors.
|
||
|
||
---
|
||
|
||
## Task 6: Frontend - `/api/resort` integration with sequence number + 5xx handling
|
||
|
||
**Files:**
|
||
- Modify: `/opt/sortof/frontend/sortof-app.jsx`
|
||
|
||
- [ ] **Step 1: Backup**
|
||
|
||
```bash
|
||
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
|
||
```
|
||
|
||
- [ ] **Step 2: Add `runResort()` async function in App**
|
||
|
||
Find the App function. Below `onToggleBranch` (added in Task 5), add:
|
||
|
||
```jsx
|
||
async function runResort(nextSelections) {
|
||
// Compose the flat list of selected mod_ids from MOD_DB + nextSelections.
|
||
// For wsids not in nextSelections, use the §4 default (all-ticked or
|
||
// first-only depending on radio mode). For wsids with N=1, include the
|
||
// sole mod_id unconditionally.
|
||
const byWsid = {};
|
||
for (const m of (_liveSortData?.MOD_DB || window.SORTOF_DATA?.MOD_DB || [])) {
|
||
const w = m.wsid || '';
|
||
if (!w) continue;
|
||
(byWsid[w] = byWsid[w] || []).push(m);
|
||
}
|
||
const ids = [];
|
||
for (const w of Object.keys(byWsid)) {
|
||
const branches = byWsid[w];
|
||
if (branches.length === 1) {
|
||
ids.push(branches[0].modId);
|
||
continue;
|
||
}
|
||
const stored = nextSelections[w];
|
||
const eff = stored !== undefined ? stored : defaultSelectionForBranches(branches);
|
||
for (const id of eff) ids.push(id);
|
||
}
|
||
|
||
if (!ids.length) {
|
||
// Nothing selected; no point hitting the API. Synthesize an empty payload
|
||
// so the UI shows "0 of N" without dispatching a 400.
|
||
_liveSortData = { ..._liveSortData, MOD_DB: [], SORTED_ORDER: [],
|
||
MODS_LINE: '', WORKSHOP_ITEMS_LINE: '', MAP_LINE: 'Muldraugh, KY',
|
||
WARNINGS: [{ tag: 'cycle', level: 'amber', msg: 'no mods selected - pick at least one branch' }],
|
||
pending: [], status: 'success' };
|
||
setCounts(c => ({ ...c, cached: 0, warnings: 1 }));
|
||
return;
|
||
}
|
||
|
||
const seq = resortSeq + 1;
|
||
setResortSeq(seq);
|
||
try {
|
||
const res = await fetch('/api/resort', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Sortof-Seq': String(seq) },
|
||
body: JSON.stringify({ selected_mod_ids: ids }),
|
||
});
|
||
// Drop stale: another resort has issued since this one started.
|
||
if (seq < resortSeq) return;
|
||
if (!res.ok) {
|
||
console.error('resort failed', res.status);
|
||
_liveSortData = {
|
||
..._liveSortData,
|
||
WARNINGS: [
|
||
...(_liveSortData?.WARNINGS || []),
|
||
{ tag: 'cycle', level: 'red', msg: "couldn't recompute sort - try again" },
|
||
],
|
||
};
|
||
setCounts(c => ({ ...c, warnings: (c.warnings || 0) + 1 }));
|
||
return;
|
||
}
|
||
const json = await res.json();
|
||
_liveSortData = json;
|
||
const cached = (json.MOD_DB || []).length;
|
||
const warns = (json.WARNINGS || []).length;
|
||
setCounts({ cached, queued: 0, parsing: 0, warnings: warns });
|
||
setState(json.status || 'success');
|
||
} catch (e) {
|
||
console.error('resort threw', e);
|
||
_liveSortData = {
|
||
..._liveSortData,
|
||
WARNINGS: [
|
||
...(_liveSortData?.WARNINGS || []),
|
||
{ tag: 'cycle', level: 'red', msg: "couldn't recompute sort - try again" },
|
||
],
|
||
};
|
||
setCounts(c => ({ ...c, warnings: (c.warnings || 0) + 1 }));
|
||
}
|
||
}
|
||
```
|
||
|
||
(Note: `_liveSortData` is the module-level let-binding from Task 0 of the original frontend - already present at the top of `sortof-app.jsx`.)
|
||
|
||
- [ ] **Step 3: Update `onToggleBranch` to trigger resort**
|
||
|
||
Find the existing `onToggleBranch` (Task 5). Replace its body with:
|
||
|
||
```jsx
|
||
const onToggleBranch = (wsid, modId, branches) => {
|
||
setBranchSelections(prev => {
|
||
const radio = isRadioMode(branches);
|
||
const cur = prev[wsid] !== undefined
|
||
? prev[wsid]
|
||
: defaultSelectionForBranches(branches);
|
||
let next;
|
||
if (radio) {
|
||
next = [modId];
|
||
} else {
|
||
next = cur.includes(modId)
|
||
? cur.filter(x => x !== modId)
|
||
: [...cur, modId];
|
||
}
|
||
const updated = { ...prev, [wsid]: next };
|
||
// Fire-and-forget: state set + resort dispatch.
|
||
runResort(updated);
|
||
return updated;
|
||
});
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Cross-tab `storage` listener also triggers resort**
|
||
|
||
Find the storage useEffect from Task 3. Replace with:
|
||
|
||
```jsx
|
||
useEffect(() => {
|
||
function onStorage(e) {
|
||
if (e.key !== 'sortof.branch.selections' || e.newValue === null) return;
|
||
try {
|
||
const parsed = JSON.parse(e.newValue);
|
||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||
setBranchSelections(parsed);
|
||
runResort(parsed);
|
||
}
|
||
} catch {}
|
||
}
|
||
window.addEventListener('storage', onStorage);
|
||
return () => window.removeEventListener('storage', onStorage);
|
||
}, [resortSeq]);
|
||
```
|
||
|
||
- [ ] **Step 5: Trigger an initial resort if hydrated `branchSelections` differs from defaults**
|
||
|
||
Add another useEffect just below the previous, fired once on mount when `_liveSortData` first becomes truthy (i.e. after the first /api/sort response):
|
||
|
||
```jsx
|
||
useEffect(() => {
|
||
// Hydrate-driven resort: after the user submits and gets MOD_DB,
|
||
// if branchSelections has entries, re-run sort against them.
|
||
if (state === 'success' || state === 'partial') {
|
||
if (Object.keys(branchSelections).length > 0) {
|
||
runResort(branchSelections);
|
||
}
|
||
}
|
||
// eslint-disable-next-line
|
||
}, [state]);
|
||
```
|
||
|
||
- [ ] **Step 6: Verify served file**
|
||
|
||
```bash
|
||
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE "runResort|/api/resort|X-Sortof-Seq|couldn't recompute sort"
|
||
```
|
||
|
||
Expected: `>= 5`.
|
||
|
||
- [ ] **Step 7: Manual browser smoke - happy path**
|
||
|
||
Hard-refresh. Submit AuthenticZ. Expand. Untick `AuthenticZLite`.
|
||
|
||
Expected:
|
||
- Network tab shows POST `/api/resort` with body `{"selected_mod_ids":["Authentic Z - Current","AuthenticZBackpacks+"]}`.
|
||
- Response 200, `MODS_LINE` contains 2 mod_ids only.
|
||
- Output panel updates.
|
||
- Affordance reads `✓ 2 of 3`.
|
||
- WARNINGS no longer contains `ambiguous-multi-branch` for that wsid (because user touched it).
|
||
|
||
- [ ] **Step 8: Manual browser smoke - race**
|
||
|
||
Click toggle 2-3 times rapidly (untick, retick, untick). Inspect Network tab - multiple resort requests fire. Whichever response is the latest wins; older responses are dropped without state mutation. Final UI state matches the last toggle.
|
||
|
||
- [ ] **Step 9: Manual browser smoke - 5xx (optional)**
|
||
|
||
Edit `/opt/sortof/api/app.py` to add `raise HTTPException(500)` at the top of `resort_endpoint`. `py_compile`, restart api. Toggle a branch in the browser. Expect:
|
||
- Output state retains prior MOD_DB/MODS_LINE.
|
||
- A red WARNINGS entry appears: `couldn't recompute sort - try again`.
|
||
- Restore `app.py` from backup, restart api, toggle again - works again.
|
||
|
||
```bash
|
||
# Restore after the 5xx test
|
||
cp /opt/sortof/api/app.py.bak-* /opt/sortof/api/app.py
|
||
sudo systemctl restart sortof-api
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Frontend - CSS for picker
|
||
|
||
**Files:**
|
||
- Modify: `/opt/sortof/frontend/index.html`
|
||
|
||
- [ ] **Step 1: Backup**
|
||
|
||
```bash
|
||
cp /opt/sortof/frontend/index.html /opt/sortof/frontend/index.html.bak-$(date +%Y%m%d-%H%M)
|
||
```
|
||
|
||
- [ ] **Step 2: Add CSS rules**
|
||
|
||
Find the existing `.copy-btn.copied` rule in `index.html` (around line 374). Below it, add:
|
||
|
||
```css
|
||
/* multi-branch picker (Spec A) */
|
||
.branch-affordance {
|
||
appearance: none;
|
||
border: 1px solid var(--line);
|
||
background: transparent;
|
||
color: var(--acc-blue);
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
padding: 1px 6px;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
}
|
||
.branch-affordance:hover { color: var(--fg); border-color: var(--line-2); background: var(--bg-3); }
|
||
|
||
.branch-panel {
|
||
background: var(--bg-2);
|
||
border-top: 1px dashed var(--line);
|
||
border-bottom: 1px dashed var(--line);
|
||
padding: 0;
|
||
}
|
||
.branch-panel-inner {
|
||
padding: 8px 12px 10px;
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
}
|
||
.branch-panel-meta { color: var(--fg-3); margin-bottom: 6px; letter-spacing: 0.04em; }
|
||
|
||
.branch-row {
|
||
display: grid;
|
||
grid-template-columns: 18px 220px 1fr 70px 1fr 60px;
|
||
gap: 10px;
|
||
align-items: center;
|
||
padding: 4px 0;
|
||
border-top: 1px solid var(--line);
|
||
cursor: pointer;
|
||
}
|
||
.branch-row:hover { background: var(--bg-3); }
|
||
.branch-row.is-checked .branch-modid { color: var(--acc-green); }
|
||
.branch-input { accent-color: var(--acc-green); }
|
||
.branch-modid { color: var(--fg-2); }
|
||
.branch-name { color: var(--fg-3); }
|
||
.branch-deps { color: var(--fg-3); font-size: 10.5px; }
|
||
.branch-pos { color: var(--fg-3); font-size: 10.5px; text-align: right; }
|
||
```
|
||
|
||
- [ ] **Step 3: Verify served file**
|
||
|
||
```bash
|
||
curl -sS http://100.114.205.53:8801/ | grep -c 'branch-affordance\|branch-panel\|branch-row'
|
||
```
|
||
|
||
Expected: `>= 6`.
|
||
|
||
- [ ] **Step 4: Manual browser smoke**
|
||
|
||
Hard-refresh. Open AuthenticZ in the picker. Expected:
|
||
- Affordance is a small bordered button-like element with blue text.
|
||
- Expansion panel has dashed top/bottom borders, slightly inset background.
|
||
- Each branch row has a checkbox/radio, mod_id, name, category pill, deps, and position cell.
|
||
- Hovering a branch row tints it slightly. Checked rows show the mod_id in green.
|
||
|
||
---
|
||
|
||
## Task 8: Verification - run §13 test cases against running stack
|
||
|
||
For each case, document expected vs actual. If any fails, return to the relevant task and fix.
|
||
|
||
- [ ] **Step 1: §13.1 AuthenticZ canonical**
|
||
|
||
```bash
|
||
curl -sS -X POST http://100.114.205.53:8801/api/sort \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"input":"2335368829"}' \
|
||
| jq '{warns: [.WARNINGS[] | select(.tag=="ambiguous-multi-branch")] | length, mod_db_count: (.MOD_DB|length)}'
|
||
```
|
||
|
||
Expected: `{"warns":1,"mod_db_count":3}`. In the browser: parent row reads `▾ 3 branches`, expansion panel mode = checkboxes, all 3 ticked.
|
||
|
||
- [ ] **Step 2: §13.2 Cooperative pack**
|
||
|
||
(No 3-mod cooperative wsid is in the cache yet. Skip or use AuthenticZ as a stand-in: same UI behavior, the difference is only conceptual.)
|
||
|
||
- [ ] **Step 3: §13.3 Mutually exclusive 2-branch**
|
||
|
||
(No such wsid in cache. To test, manually inject in DB:
|
||
|
||
```sql
|
||
-- Run via sudo docker exec -i sortof_db psql -U sortof -d sortof -c "..."
|
||
UPDATE mod_parsed SET incompatible_mods = '{AuthenticZBackpacks+}'::text[]
|
||
WHERE workshop_id='2335368829' AND mod_id='Authentic Z - Current';
|
||
UPDATE mod_parsed SET incompatible_mods = '{Authentic Z - Current}'::text[]
|
||
WHERE workshop_id='2335368829' AND mod_id='AuthenticZBackpacks+';
|
||
```
|
||
|
||
Then re-submit `2335368829` in the browser. Expected: panel mode = radios, default = `Authentic Z - Current` only (first by `parsed_at, mod_id`). Restore via:
|
||
|
||
```sql
|
||
UPDATE mod_parsed SET incompatible_mods = '{}'::text[] WHERE workshop_id='2335368829';
|
||
```)
|
||
|
||
- [ ] **Step 4: §13.4 Persistence across reload**
|
||
|
||
In browser: untick `AuthenticZLite`. Hard-refresh. After the next /api/sort, the resort fires automatically (Task 6 step 5 useEffect) and the picker shows `✓ 2 of 3` again. `localStorage.getItem('sortof.branch.selections')` shows the persisted entry.
|
||
|
||
- [ ] **Step 5: §13.5 Stored mod_id no longer exists (checkbox mode)**
|
||
|
||
Console: `localStorage.setItem('sortof.branch.selections', JSON.stringify({"2335368829": ["GhostBranchThatDoesNotExist"]}))`. Hard-refresh. Submit AuthenticZ. Expected: panel default applied (all 3 ticked); no console error.
|
||
|
||
- [ ] **Step 6: §13.6 Cross-wsid incompatibility**
|
||
|
||
(Use the canonical 3-mod input; `TMC_TrueActions` requires `tsarslib` but they're on different wsids - both N=1.) Expected: no picker UI, no `ambiguous-multi-branch` warning.
|
||
|
||
- [ ] **Step 7: §13.7 Zero-tick wsid**
|
||
|
||
Untick all 3 AuthenticZ branches in the browser. Expected: parent row reads `✓ 0 of N`, MODS_LINE excludes all 3, a synthetic warning appears (`no mods selected - pick at least one branch` if multiple wsids present, or the resort returns 400 → red retry warning if AuthenticZ was the only input). Re-tick at least one branch - UI recovers.
|
||
|
||
- [ ] **Step 8: §13.8 Radio-mode eviction-to-empty**
|
||
|
||
Apply the SQL from Step 3 (radio mode). Console: `localStorage.setItem('sortof.branch.selections', JSON.stringify({"2335368829": ["GhostMod"]}))`. Hard-refresh. Submit. Expected: silently drops the ghost; default-first applied (only `Authentic Z - Current` ticked). Restore SQL.
|
||
|
||
- [ ] **Step 9: §13.10 Stale resort response discarded**
|
||
|
||
Throttle Network in DevTools to "Slow 3G". Click toggle 1 (untick Backpacks), wait ~200ms, click toggle 2 (untick Lite). Inspect Network: two requests fire. The slower (toggle 1) finishes second. Expected: only toggle 2's response is applied; UI shows `✓ 1 of 3` (only Current selected), not `✓ 2 of 3`.
|
||
|
||
- [ ] **Step 10: §13.12 Cross-tab sync**
|
||
|
||
Open two tabs at `http://100.114.205.53:8801/`. Submit AuthenticZ in both. In tab A, untick a branch. Expected: tab B's affordance updates to match, network tab in B shows a /api/resort (triggered by the storage event listener).
|
||
|
||
- [ ] **Step 11: §13.13 Unknown selected_mod_id from server**
|
||
|
||
```bash
|
||
curl -sS -X POST http://100.114.205.53:8801/api/resort \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"selected_mod_ids":["modoptions","ghostMod"]}' \
|
||
| jq '.MOD_DB | map(.modId)'
|
||
```
|
||
|
||
Expected: `["modoptions"]`. Server log: INFO "resort dropped unknown mod_ids count=1 sample=['ghostMod']".
|
||
|
||
```bash
|
||
sudo journalctl -u sortof-api --since "2 min ago" | grep "resort dropped"
|
||
```
|
||
|
||
- [ ] **Step 12: Final regression - canonical 3-mod**
|
||
|
||
```bash
|
||
curl -sS -X POST http://100.114.205.53:8801/api/sort \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"input":"2169435993;2392709985;2487022075"}' \
|
||
| jq '{status, MODS_LINE, warns: (.WARNINGS|length)}'
|
||
```
|
||
|
||
Expected: `{"status":"success","MODS_LINE":"modoptions;tsarslib;TMC_TrueActions","warns":0}`. No regression from baseline.
|
||
|
||
---
|
||
|
||
## Self-review (already applied)
|
||
|
||
- **Spec coverage:** all of §3–§10 acceptance criteria mapped to a task.
|
||
- **Placeholders:** none in the plan body.
|
||
- **Type consistency:** `branchSelections` is `{[wsid]: string[]}` everywhere; `expandedWsids` is `Set<string>`; `runResort` takes `nextSelections` of the same shape as `branchSelections`; `defaultSelectionForBranches` and `isRadioMode` both take `branches: MOD_DB[]` and read `.modId`/`.conflicts` consistently.
|
||
- **No git, by design:** every code-changing task starts with a `cp file file.bak-$(date)` step in lieu of a commit.
|
||
- **Restart-vs-no-restart:** backend tasks (1, 2) end with `sudo systemctl restart sortof-api`; frontend tasks (3–7) do not - `StaticFiles` serves from disk, hard-refresh in browser is sufficient.
|