T9.4 - S7: one sample-data affordance, confirmed, and fenced off the project

Four affordances under three names became ONE: "Load sample data", on the
creator's toolbar, at the far end of two separators from the live actions
(New / Duplicate), pushed right with its own gap. It confirms through the
T7.9 dialog, naming exactly what it does - and what it does not: "This page
only: nothing is written to the project unless you then save." The probe
verifies the fence the way the done-when demands - against a REAL project,
reading the server's SOP and work-package list before and after and asserting
byte-identical.

Gone: the wizard's header "Load sample" (the dangerous one: it filled the
state completeSOP() pushes to the LIVE project, one click, no confirm, no
undo - reconciled with D1 exactly as the task records: the creator's control
is the survivor, the wizard copy goes), the creator's split Sample SOP /
Load example pair (now internals behind the one entry point), and the
empty-state context bar's third button (its text now points at the toolbar
control). The location/material "Load sample values" buttons stay: they fill
a PASTE BOX that acts only through an explicit, dry-runnable import - a
different thing, stated in the code.

Probes re-pointed with reasons in place: frame_check's D1 toolbar list names
the consolidated control; validation_check's sample-driven toast checks
became the-affordance-is-gone checks (and its stale showAnalytics drive,
orphaned by T7.10, became a the-duplicate-stays-gone check).

Verification (each probe run alone): NEW tests/sample_check.py 10/10.
Regressions: validation_check 77/77, frame_check 38/38, kitting_check 26/26,
export_check 20/20, sections_check 95/95.

Items: S7 (D1 reconciliation honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 13:05:56 -07:00
parent c2a1cc7c26
commit b44afa7672
8 changed files with 221 additions and 147 deletions

View File

@@ -300,6 +300,7 @@ Wave 9 adds these:
```bash ```bash
python tests/export_check.py # CR-008/CR-017 - export walk + hours guard 20 checks python tests/export_check.py # CR-008/CR-017 - export walk + hours guard 20 checks
python tests/sample_check.py # S7 - one sample affordance, confirmed+fenced 10 checks
``` ```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live

View File

@@ -391,95 +391,14 @@ function initializeWPTypes(){
} }
// ── LOAD SAMPLE DATA ────────────────────────────────────────────────────────── // ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
// This page's sample is the sample SOP. It used to reach into the frame and call // Removed at T9.4 (S7). This was the affordance one click from live project
// the creator's loadExample() when the WP tab was open; the creator is its own // data: it filled the wizard state that completeSOP() pushes to the PROJECT,
// page since T7.1 and carries its own sample controls in its toolbar (D1), so // with no confirm and no undo. The suite's one sample control lives on the
// there is nothing to reach into and nothing to duplicate here. // creator's toolbar, where it fills that page only. The location and material
// // list steps keep their "Load sample values" buttons - those fill a PASTE BOX
// S7 still counts more sample affordances than it should. T9.4 reduces them; this // that acts only through an explicit, dry-runnable Import, which is a
// task only stops one of them pretending to belong to a document it is not in. // different thing from loading sample data into a live configuration.
function loadSampleData(){
if(currentTool && currentTool !== 'sop'){
wizardToast('The work package sample is on the Work Package Creation page, in its toolbar.',
{role: 'status'});
return;
}
// Populate Step 1
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
document.getElementById('proj_number').value = '26-67-008';
document.getElementById('proj_client').value = 'Micron Technology, Inc.';
document.getElementById('proj_division').value = 'Semiconductor';
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
// Populate Step 2. The leadership slots are account pickers now, so the sample's
// fictional names can't be "selected" — setting .value on a <select> with no
// matching option silently does nothing. Store them as names without an account,
// which is exactly how the picker shows a person who isn't a suite user yet.
state.team.pm = 'Mariano Sanchez';
state.team.apm = 'Assistant PM';
state.team.cm = 'K. Boyd';
state.team.qm = 'D. Nguyen';
state.teamIds = {pm:'', apm:'', cm:'', qm:''};
state.qaGroupIds = [];
renderTeamPickers();
// Step 3 — standard required roles
if(state.signoffRoles[0]) state.signoffRoles[0].role = 'Superintendent';
if(state.signoffRoles[1]) state.signoffRoles[1].role = 'Foreman';
const stEl = document.getElementById('role_super_title'); if(stEl) stEl.value = 'Superintendent';
const ftEl = document.getElementById('role_foreman_title'); if(ftEl) ftEl.value = 'Foreman';
state.signoffRoles[0].name = 'John Smith'; state.signoffRoles[0].userId = '';
state.signoffRoles[1].name = 'Mike Jones'; state.signoffRoles[1].userId = '';
renderSignoffRolePickers();
// Populate Step 5
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
document.getElementById('gov_wosize').value = 'Standard — 35 days (≈4080 hrs)';
document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech';
document.getElementById('gov_discmode').value = 'choice';
document.getElementById('gov_size_hours_max').value = '80';
// Populate Step 6
document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items';
document.getElementById('qual_photo').value = 'Key checkpoints only';
document.getElementById('qual_hold').value = 'HOLD: Prime QAQC to inspect rough-in before cover/cover-up.\nWITNESS: Client QC to observe megger test before energization.';
// Step 7 already has defaults
// CR-016 / T5.7. The sample is the Micron EUV configuration, and Micron does not
// use Assets: its content duplicates the database Clinton's team maintains, and
// that integration is deferred. Off by toggle, so the section and its model stay
// in the application for the integration to land in — and any other project can
// turn it back on from step 12 without a code change.
//
// A partial map on purpose. WPSections.normalize fills the rest in as ON, so a
// section added after today is not silently off for this SOP.
if(typeof WPSections !== 'undefined'){
state.sections = WPSections.normalize({assets: false, kitting: false}); // CR-009: Micron EUV is not kitting today
state.fields = WPSections.normalizeFields({costCode: false, acumaticaTask: false}); // CR-002, mirrored on the export (T9.1)
}
// The Micron FMCS sample includes BIM/VDC — enable it so the sequence shows the
// full BIM → construction flow (BIM steps first) and the Creator offers IWP/EWP.
state.bimEnabled = true;
const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = true;
enableBIM();
// Collect all data
collectStepData();
track('sample_loaded');
wizardToast('Sample data loaded. Every step now holds example values — edit or replace any of them.');
// Switch to step 1. Every step now holds sample content, so the rail marks them
// all as visited — otherwise a fully populated wizard shows ten unstarted steps.
for(let i = 1; i <= LAST_STEP; i++) _visitedSteps.add(i);
currentStep = 1;
updateStepUI();
updateProjectDisplay();
}
// ── RESTORE A COMPLETED SOP (for "Review" + WP tab across reloads) ─────────────
function restoreSavedSOP(){ function restoreSavedSOP(){
let savedState = null, savedSop = null, complete = false; let savedState = null, savedSop = null, complete = false;
try { try {

View File

@@ -40,7 +40,6 @@
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">Load sample</button>
<button class="header-button" onclick="toggleComments()" title="Leave feedback for the current step">Feedback</button> <button class="header-button" onclick="toggleComments()" title="Leave feedback for the current step">Feedback</button>
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">Help</button> <button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">Help</button>
<!-- A4/S9: the orphaned "1 / 10" counter lived here. It is retired — the <!-- A4/S9: the orphaned "1 / 10" counter lived here. It is retired — the

View File

@@ -397,6 +397,20 @@ function constraintNames(){
function nextSeq(){ return savedPackages.length+1; } function nextSeq(){ return savedPackages.length+1; }
// ── SOP LOADING ────────────────────────────────────────────────────────────── // ── SOP LOADING ──────────────────────────────────────────────────────────────
// S7 / T9.4: the one sample entry point. loadSampleSOP()/loadExample() stay as
// internals (probes and this function call them); the USER reaches sample data
// through exactly this, which says what it will and will not do before doing it.
async function loadSampleAll(){
if(!(await wpConfirmDialog({
title:'Load sample data',
message:'Replaces the SOP shown on this page with the fictional Micron sample and fills '
+ 'the form with the example work package.\n\nThis page only: nothing is written to '
+ 'the project unless you then save.',
okLabel:'Load the sample'}))) return;
loadSampleSOP();
loadExample();
}
function loadSampleSOP(){ SOP=JSON.parse(JSON.stringify(SAMPLE_SOP)); applySOP(); newPackage(); toast('Sample SOP loaded — ' + (SOP.project&&SOP.project.name||'project')); track('sample_loaded'); } function loadSampleSOP(){ SOP=JSON.parse(JSON.stringify(SAMPLE_SOP)); applySOP(); newPackage(); toast('Sample SOP loaded — ' + (SOP.project&&SOP.project.name||'project')); track('sample_loaded'); }
function importSOP(ev){ function importSOP(ev){
const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader();
@@ -531,7 +545,7 @@ function renderCtxBar(){
if(!SOP){ if(!SOP){
bar.innerHTML = activeProjectId bar.innerHTML = activeProjectId
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>` ? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>`
: `<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`; : `<div class="ctx-empty">No SOP loaded — import one from the Configuration tool, or use <strong>Load sample data</strong> in the toolbar above.</div>`;
return; return;
} }
const p=SOP.project||{}, g=SOP.governance||{}; const p=SOP.project||{}, g=SOP.governance||{};

View File

@@ -84,12 +84,15 @@
<button class="btn btn-ghost" onclick="openSopModal()">View SOP</button> <button class="btn btn-ghost" onclick="openSopModal()">View SOP</button>
<button class="btn btn-ghost" onclick="document.getElementById('sop-import').click()">Import SOP</button> <button class="btn btn-ghost" onclick="document.getElementById('sop-import').click()">Import SOP</button>
<input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)"> <input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)">
<button class="btn btn-ghost" onclick="loadSampleSOP()">Sample SOP</button> <!-- S7 / T9.4: THE sample-data affordance - exactly one in the whole suite,
<button class="btn btn-ghost" onclick="loadExample()">Load example</button> under one name. Pushed to the far end, away from the live actions (New /
<span class="wp-toolbar-sep" aria-hidden="true"></span> Duplicate sit at the other side of two separators), it confirms before
<!-- T7.10 moves this to the admin console and deletes one of the two acting and names exactly what it does. It fills THIS PAGE only: nothing
implementations. It stays visible here until then rather than being is written to the project unless the user then saves, which the probe
deleted by the task that dissolved the frame. --> verifies against a real project. The wizard's copy and the split
Sample SOP / Load example pair are gone. -->
<span class="wp-toolbar-sep" aria-hidden="true" style="margin-left:auto"></span>
<button class="btn btn-ghost" onclick="loadSampleAll()">Load sample data</button>
</div> </div>
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div> <div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>

View File

@@ -247,7 +247,9 @@ def page_checks(page, base, tok):
# "Usage data" left this list at T7.10: D5 moved the report to the admin # "Usage data" left this list at T7.10: D5 moved the report to the admin
# console. Its absence HERE is asserted, so the button cannot quietly return # console. Its absence HERE is asserted, so the button cannot quietly return
# and recreate the duplicate D5 existed to remove. # and recreate the duplicate D5 existed to remove.
for label in ("Sample SOP", "Load example", "View SOP", "Import SOP"): # "Sample SOP" + "Load example" became the ONE "Load sample data" at T9.4
# (S7): D1's point was that the sample control is reachable, and it still is.
for label in ("Load sample data", "View SOP", "Import SOP"):
chk("%-13s is visible on the unframed page" % label, vis.get(label) is True, vis) chk("%-13s is visible on the unframed page" % label, vis.get(label) is True, vis)
chk("Usage data is GONE from the creator toolbar (D5)", chk("Usage data is GONE from the creator toolbar (D5)",
"Usage data" not in vis, vis) "Usage data" not in vis, vis)

169
tests/sample_check.py Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Is there exactly one sample-data affordance, and is it fenced? — S7, T9.4.
Four affordances under three names, one of them a single click from live
project data with no confirm. Now: one control, one name ("Load sample data"),
on the creator's toolbar at the far end of two separators from the live
actions, confirming before it acts, naming exactly what it does - and unable
to touch live project data, verified by attempting it against a real project
and reading the server before and after.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
from qa_gate_check import api # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.5):
time.sleep(seconds)
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
# ── 1. exactly one, by grep ───────────────────────────────────────────────
print("\n1. grep: one affordance, one name")
hits = []
for name in os.listdir(HTML):
if not name.endswith((".html", ".js")):
continue
src = open(os.path.join(HTML, name), encoding="utf-8").read()
for m in re.finditer(r'onclick="(loadSampleAll|loadSampleSOP|loadExample|loadSampleData)\(\)"', src):
hits.append((name, m.group(1)))
chk("exactly one sample-data control exists in the whole suite",
hits == [("wp-creation-index.html", "loadSampleAll")], ascii_(hits))
chk("the other three affordances are removed; grep confirms",
not any(fn in ("loadSampleData",) for _, fn in hits)
and "loadSampleData" not in open(os.path.join(HTML, "work-package-suite-app.js"),
encoding="utf-8").read().replace(
"// Removed at T9.4", "").split("loadSampleData")[0])
tmpdir = tempfile.mkdtemp(prefix="wpsuite-sample-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
root = tok["root"]
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", root)
page.viewport(1440, 900)
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
chk("the creator boots on a REAL project", wait_creator(page))
settle(1.6)
# ── 2. placement and the confirm ─────────────────────────────────────
print("\n2. away from the live actions, confirmed before acting")
gap = page.eval("""(() => {
const btns = [...document.querySelectorAll('#wp-toolbar button')];
const sample = btns.find(b => b.textContent.trim() === 'Load sample data');
if (!sample) return null;
const prev = sample.previousElementSibling && sample.previousElementSibling
.previousElementSibling; // skip the separator
const r = sample.getBoundingClientRect();
const pr = prev ? prev.getBoundingClientRect() : {right: 0};
return {isLast: btns[btns.length-1] === sample, gap: Math.round(r.left - pr.right)};
})()""")
chk("the control sits at the far end, clearly separated from live actions",
gap and gap["isLast"] and gap["gap"] >= 40, ascii_(gap))
_, sop_before = api(base, "/api/sops/latest?project_id=projA", root)
_, wps_before = api(base, "/api/wps?project_id=projA&full=true", root)
page.eval("void loadSampleAll()")
settle(0.5)
d = json.loads(page.eval("""JSON.stringify((() => {
const ov = document.getElementById('wp-dialog');
return {open: ov.classList.contains('open'),
msg: (document.getElementById('wp-dialog-msg')||{}).textContent||''};
})())"""))
chk("it asks first, naming exactly what will happen",
d["open"] and "Replaces the SOP shown on this page" in d["msg"]
and "nothing is written to the project" in d["msg"], ascii_(d))
page.eval("wpDialogCancel()")
settle(0.4)
chk("backing out changes nothing",
page.eval("!(SOP && SOP.meta && SOP.meta.sample)"))
page.eval("void loadSampleAll()")
settle(0.4)
page.eval("wpDialogOk()")
settle(1.0)
chk("confirming loads the sample SOP and the example package, locally",
page.eval("!!(SOP && SOP.meta && SOP.meta.sample)")
and page.eval("!!gv('wp_subject')"))
# ── 3. it cannot touch live project data ─────────────────────────────
print("\n3. the fence, verified against a real project")
settle(1.5) # anything that WOULD sync has had time to
_, sop_after = api(base, "/api/sops/latest?project_id=projA", root)
_, wps_after = api(base, "/api/wps?project_id=projA&full=true", root)
chk("the project's SOP on the server is byte-identical after the sample load",
json.dumps(sop_before, sort_keys=True) == json.dumps(sop_after, sort_keys=True))
chk("...and so is its work package list",
json.dumps(wps_before, sort_keys=True) == json.dumps(wps_after, sort_keys=True))
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors anywhere in this run", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -231,48 +231,19 @@ def run(page, base, tok):
".filter(e => (e.textContent||'').trim()).map(e => e.id))")) ".filter(e => (e.textContent||'').trim()).map(e => e.id))"))
chk("no error is shown on a step nobody has tried to leave", not marks, marks) chk("no error is shown on a step nobody has tried to leave", not marks, marks)
print("\n5. the wizard opens no native dialog at all") print("\n5. the wizard opens no native dialog, and its sample affordance is gone")
open_wizard(page, base, tok) open_wizard(page, base, tok)
# Drive every path that used to raise one. # T9.4 (S7) removed this page's Load sample - it filled the state that
page.eval("loadSampleData()") # completeSOP() pushes to the LIVE project, one click, no confirm. The
settle(1.6) # suite's one sample control is on the creator. What this section still
chk("loading the sample announces instead of interrupting", # owns: the wizard must not regrow the affordance, and must stay dialog-free.
page.eval("!document.getElementById('wp-toast').hidden")) chk("the wizard's Load sample control is gone (S7/T9.4)",
chk("...politely, because it is a confirmation not an error", page.eval("!document.getElementById('load-sample-btn')")
page.eval("document.getElementById('wp-toast').getAttribute('role')") == "status") and page.eval("typeof loadSampleData === 'undefined'"))
chk("...and says what happened", chk("...and no native dialog fired anywhere so far",
"Sample data loaded" in (page.eval( not json.loads(page.eval("JSON.stringify(window.__dialogs||[])")),
"(document.getElementById('wp-toast')||{}).textContent||''")), page.eval("JSON.stringify(window.__dialogs||[])"))
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
chk("...and can be dismissed from the keyboard", page.eval("""(() => {
const b = document.querySelector('#wp-toast .wp-toast-close');
if (!b) return false;
b.focus();
const focused = document.activeElement === b;
b.click();
return focused && document.getElementById('wp-toast').hidden;
})()"""))
# This used to be "loading the sample on the wrong tab interrupts", because the
# wizard's Load sample was context-aware: on the WP tab it reached into the
# iframe and called the creator's own loadExample(), and complained when the
# frame was not there. B7/T7.1 made the creator a page, so there is no wrong
# tab to be on and nothing to reach into - this page's sample is the SOP
# sample, always. What is left to check is that the one remaining edge, the
# SOP gate, still says something rather than nothing.
page.eval("showCreatorGate(); loadSampleData()")
settle(1.2)
chk("from the gate, loading the sample announces rather than failing silently",
page.eval("!document.getElementById('wp-toast').hidden"))
chk("...politely, because being on the gate is not an error",
page.eval("document.getElementById('wp-toast').getAttribute('role')") == "status",
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
chk("...and says where the work package sample actually is",
"Work Package Creation" in (page.eval(
"(document.getElementById('wp-toast')||{}).textContent||''")),
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
page.eval("switchTool('sop')")
settle(0.6)
# Through the control a person uses — the modal's text input — not through the # Through the control a person uses — the modal's text input — not through the
# library helper, which has no duplicate message to give. # library helper, which has no duplicate message to give.
@@ -304,16 +275,12 @@ def run(page, base, tok):
"no feedback to export" in (page.eval( "no feedback to export" in (page.eval(
"(document.getElementById('wp-toast')||{}).textContent||''")).lower(), "(document.getElementById('wp-toast')||{}).textContent||''")).lower(),
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''"))) ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
page.eval("showAnalytics()") # showAnalytics() left this page at T7.10 (D5): the usage report lives on
settle(0.5) # the admin console now, one implementation for the whole suite. What this
chk("the usage summary is shown rather than crammed into a confirmation", # section keeps: the duplicate must not regrow here.
"Step 1:" in (page.eval("(document.getElementById('wp-toast')||{}).textContent||''")), chk("the wizard's usage-summary duplicate is gone (D5/T7.10)",
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''"))[:100]) page.eval("typeof showAnalytics === 'undefined'")
chk("...with the download offered as an action beside it", and page.eval("typeof analyticsLoad === 'undefined'"))
page.eval("""(() => {
const b = document.querySelector('#wp-toast .wp-toast-action');
return !!b && /download/i.test(b.textContent);
})()"""))
fired = json.loads(page.eval("JSON.stringify(window.__dialogs||[])")) fired = json.loads(page.eval("JSON.stringify(window.__dialogs||[])"))
chk("nothing anywhere in that opened alert / confirm / prompt", not fired, fired[:4]) chk("nothing anywhere in that opened alert / confirm / prompt", not fired, fired[:4])