main
Drew Owens 0.11.3 source 7364e19 5d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241
//! MockBackend — a fake SOLIDWORKS speaking the com-host command set.
//!
//! Selected by `SWX_MOCK=1` or any non-Windows host, so the bridge's whole verb
//! surface (routing, hints, error shapes, BOM/CSV plumbing, the stdio protocol
//! itself) is exercisable with no SOLIDWORKS and no Windows.
//!
//! It models a tiny in-memory app: open documents, custom properties, canned
//! mass properties, a 3-part demo assembly BOM. The canned values are the ones
//! the test suite asserts on — keep the RESPONSE SHAPES identical to
//! [`super::com::ComBackend`], because the mock's whole value is that a test
//! written against it stays true against the real thing.

use super::host::{Backend, HostError, COMMANDS};
use serde_json::{json, Map, Value};

/// `.ext` -> the `type` string every doc-shaped response carries.
pub const DOC_TYPES: &[(&str, &str)] = &[
    (".sldprt", "part"),
    (".sldasm", "assembly"),
    (".slddrw", "drawing"),
];

/// Extension -> doc type, case-insensitively. `None` for anything else.
pub fn doc_type_of(path: &str) -> Option<&'static str> {
    let lower = path.to_ascii_lowercase();
    DOC_TYPES.iter().find(|(ext, _)| lower.ends_with(ext)).map(|(_, t)| *t)
}

/// Last path segment, splitting on BOTH separators: these are Windows paths
/// that the mock may well be handling on Linux, where `/` alone would return
/// the entire string.
/// The TITLE SOLIDWORKS shows for a file: its name, without the extension when
/// Windows hides extensions for known file types (`mock-hide-file-ext`: the
/// Windows default, and Michael's machine — measured 2026-10-15).
pub fn shown_title(path: &str) -> String {
    let name = base_name(path);
    if fault("mock-hide-file-ext") { crate::verbs::title_stem(name).to_string() } else { name.to_string() }
}

pub fn base_name(path: &str) -> &str {
    path.rsplit(['/', '\\']).next().unwrap_or(path)
}

/// `base_name` minus the final extension.
pub fn file_stem(path: &str) -> &str {
    let name = base_name(path);
    match name.rfind('.') {
        Some(i) if i > 0 => &name[..i],
        _ => name,
    }
}

/// Directory part of a path, splitting on both separators ("" if none).
pub fn dir_name(path: &str) -> &str {
    match path.rfind(['/', '\\']) {
        Some(i) => &path[..i],
        None => "",
    }
}

/// A canned solid body in the `swx_inspect_bodies` shape (millimetres), so a
/// test written against the mock stays true against the real backend.
fn mock_body(name: &str, x: f64, y: f64, z: f64) -> Value {
    let vol = x * y * z;
    let area = 2.0 * (x * y + y * z + x * z);
    json!({
        "name": name,
        "bbox": {"minMm": [0.0, 0.0, 0.0], "maxMm": [x, y, z]},
        "sizeMm": {"x": x, "y": y, "z": z},
        "volumeMm3": vol,
        "areaMm2": area,
        "massKg": vol * 1.0e-9 * 2700.0,
        "centerOfMassMm": [x / 2.0, y / 2.0, z / 2.0],
        "faceCount": 6,
        "visible": true,
        "isSolid": true,
    })
}

/// The simulated material library's densities (kg/m³) — SOLIDWORKS' own
/// values for the materials the tests name. No material = SOLIDWORKS' default
/// part density, 1000 kg/m³.
fn mock_density(material: Option<&str>) -> f64 {
    material.and_then(crate::comhost::mock_model::library_density).unwrap_or(1000.0)
}

/// A document's density: what set_material stored in its model (a density
/// alone, 0.11.0), else its material's.
fn doc_density(d: &MockDoc) -> f64 {
    d.model.get("density").and_then(|v| v.as_f64()).unwrap_or_else(|| mock_density(d.material.as_deref()))
}

struct MockDoc {
    /// The IDENTITY key. For a document that has never been saved this is a
    /// synthetic `untitled:Part1` — SOLIDWORKS reports an empty `GetPathName()`
    /// for those, and the mock has to model that faithfully or every
    /// new-document test would be testing a fiction.
    path: String,
    title: String,
    saved: bool,
    doc_type: &'static str,
    config: String,
    dirty: bool,
    /// A COMPONENT AN ASSEMBLY HOLDS OPEN. SOLIDWORKS keeps a referenced part
    /// open while its parent is, and lets it go when the parent closes — so a
    /// close loop that only counts the documents it named would leave the count
    /// wrong in both directions. Modelled because the safety claim is "the count
    /// reaches zero".
    held_open_by_assembly: bool,
    props: Map<String, Value>,
    material: Option<String>,
    /// name -> value in METRES, mirroring IDimension::SystemValue.
    dimensions: Map<String, Value>,
    bodies: Vec<Value>,
    /// Component instances, for an assembly: (file, configuration). The mock's
    /// BOM aggregates THESE when there are any, so a test can insert components
    /// and then read a BOM built from what it inserted.
    components: Vec<(String, String)>,
    /// Modelled STEP/IGES-import temporary: a document SOLIDWORKS created under
    /// the bridge's temp dir with an `IC~~` conversion path. The real backend
    /// recognises these by that path; the mock carries the fact as a flag and a
    /// synthetic temp path, so `close_import_temps` and the enumeration cap can
    /// be tested without a live SOLIDWORKS.
    import_temp: bool,
    temp_path: Option<String>,
    /// The simulated solid model (0.10.0): features, bounding box, volume,
    /// appearance — see `mock_model`.
    model: Map<String, Value>,
    /// Created while ISldWorks::DocumentVisible was false for its type (0.10.6):
    /// no window. Per the help page, IModelDoc2::Visible cannot show it later.
    visible: bool,
}

impl MockDoc {
    /// The mock's documents live in the com-host CHILD, which the server kills
    /// and respawns by design — so without this they vanished on every respawn,
    /// and the mock quietly modelled a SOLIDWORKS that closes everything when
    /// the bridge's sidecar restarts. It does not. That fiction made bug #5
    /// (ownership lost across a respawn) untestable: the documents were gone,
    /// so no verb could ever reach the ownership check.
    fn to_json(&self) -> Value {
        json!({
            "path": self.path,
            "title": self.title,
            "saved": self.saved,
            "docType": self.doc_type,
            "config": self.config,
            "dirty": self.dirty,
            "props": Value::Object(self.props.clone()),
            "material": self.material,
            "dimensions": Value::Object(self.dimensions.clone()),
            "bodies": self.bodies,
            "components": self.components.iter().map(|(f, c)| json!([f, c])).collect::<Vec<_>>(),
            "importTemp": self.import_temp,
            "tempPath": self.temp_path,
            "model": Value::Object(self.model.clone()),
            "visible": self.visible,
        })
    }

    fn from_json(v: &Value) -> Option<MockDoc> {
        let doc_type = match v.get("docType")?.as_str()? {
            "part" => "part",
            "assembly" => "assembly",
            "drawing" => "drawing",
            _ => return None,
        };
        Some(MockDoc {
            path: v.get("path")?.as_str()?.to_string(),
            title: v.get("title")?.as_str()?.to_string(),
            saved: v.get("saved").and_then(|x| x.as_bool()).unwrap_or(true),
            doc_type,
            config: v.get("config").and_then(|x| x.as_str()).unwrap_or("Default").to_string(),
            dirty: v.get("dirty").and_then(|x| x.as_bool()).unwrap_or(false),
            held_open_by_assembly: v
                .get("heldOpenByAssembly")
                .and_then(|x| x.as_bool())
                .unwrap_or(false),
            props: v.get("props").and_then(|x| x.as_object()).cloned().unwrap_or_default(),
            material: v.get("material").and_then(|x| x.as_str()).map(String::from),
            dimensions: v.get("dimensions").and_then(|x| x.as_object()).cloned().unwrap_or_default(),
            bodies: v.get("bodies").and_then(|x| x.as_array()).cloned().unwrap_or_default(),
            components: v
                .get("components")
                .and_then(|x| x.as_array())
                .map(|rows| {
                    rows.iter()
                        .filter_map(|r| {
                            Some((r.get(0)?.as_str()?.to_string(), r.get(1)?.as_str()?.to_string()))
                        })
                        .collect()
                })
                .unwrap_or_default(),
            import_temp: v.get("importTemp").and_then(|x| x.as_bool()).unwrap_or(false),
            temp_path: v.get("tempPath").and_then(|x| x.as_str()).map(String::from),
            model: v.get("model").and_then(|x| x.as_object()).cloned().unwrap_or_default(),
            visible: v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true),
        })
    }

    fn opened(path: String, doc_type: &'static str, config: String) -> MockDoc {
        let title = shown_title(&path);
        MockDoc {
            path,
            title,
            saved: true,
            doc_type,
            config,
            dirty: false,
            held_open_by_assembly: false,
            props: Map::new(),
            material: None,
            dimensions: Map::new(),
            bodies: Vec::new(),
            components: Vec::new(),
            import_temp: false,
            temp_path: None,
            model: Map::new(),
            visible: true,
        }
    }
}

pub struct MockBackend {
    running: bool,
    /// The host's OWN record of what it created, PERSISTED — see
    /// `comhost::created_store`. The com host is killed and respawned by design,
    /// so an in-process-only record means a respawn starts refusing writes to
    /// the bridge's own documents.
    created: Vec<(String, Option<String>)>,
    /// The SOLIDWORKS session `created` belongs to — see `ComBackend`.
    created_session: Option<i64>,
    /// Which SOLIDWORKS "session" this is. A real SOLIDWORKS gets a new process
    /// id when it restarts AND restarts its Part1/Part2 numbering, which is
    /// exactly why document ownership has to be scoped to a session — so the
    /// mock models both, or the suite could not prove the scoping works.
    generation: i64,
    /// Untitled-document counter, exactly as SOLIDWORKS names them (Part1,
    /// Part2, ...), so a test can assert on the title it will really get.
    untitled: u32,
    preferences: Map<String, Value>,
    /// The launcher has been invoked and SOLIDWORKS is coming up. See
    /// `launch_shortcut`.
    launching: Option<std::time::Instant>,
    /// Insertion-ordered, like the Python dict it replaces: closing the active
    /// doc falls back to the first remaining one.
    docs: Vec<MockDoc>,
    active: Option<String>,
    /// Custom properties as they were at the moment a document was written to
    /// disk, keyed by lowercased path. A COPY-mode save_as leaves the open
    /// document unpathed, so the FILE it produced has no open document to read
    /// properties from — which is exactly the situation a BOM is in when its
    /// components are files on disk rather than windows on screen.
    files: std::collections::BTreeMap<String, Map<String, Value>>,
    /// A rung reported success and the simulated process is STILL GOING — it
    /// exits at this instant (epoch ms). A real `ExitApp` returns while
    /// SOLIDWORKS is still tearing itself down, so this is the divergence the
    /// mock has to be able to express: the census must see the process for a
    /// while after the rung returned.
    pending_exit_at_ms: Option<u64>,
}

/// The simulated machine, when a test has configured one. With a fake dir set,
/// the SIMULATOR is the single source of truth for whether a launch reached a
/// COM-attachable SOLIDWORKS — so a route that stalls never becomes attachable,
/// however long anybody polls, exactly as on the real machine.
fn fake_dir() -> Option<std::path::PathBuf> {
    std::env::var(crate::launchos::ENV_FAKE_DIR)
        .ok()
        .filter(|d| !d.is_empty())
        .map(std::path::PathBuf::from)
}

/// A runtime fault switch: a file in the scratch root, so a test can turn a
/// failure on and off BETWEEN calls without restarting the bridge. Test-only —
/// nothing in production ever creates these.
/// The simulated SOLIDWORKS's creation time, epoch seconds: the data root's
/// `mock-sldworks-created` if a test wrote one, else an hour ago, written once.
fn mock_created_at() -> u64 {
    let f = crate::scratch::root().join("mock-sldworks-created");
    if let Some(v) = std::fs::read_to_string(&f).ok().and_then(|t| t.trim().parse::<u64>().ok()) {
        return v;
    }
    let v = crate::launchos::epoch_secs().saturating_sub(3600);
    let _ = std::fs::write(&f, v.to_string());
    v
}

fn fault(name: &str) -> bool {
    crate::scratch::root().join(name).exists()
}

/// ISldWorks::DocumentVisible, simulated (0.10.6): a SESSION setting per
/// document type, persisted in the data root like SOLIDWORKS keeps it for the
/// life of the process; every call is appended to `document-visible.jsonl` so a
/// test can read the order (false, NewDocument, true).
fn doc_visible_path() -> std::path::PathBuf {
    crate::scratch::root().join("mock-documentvisible.json")
}

fn doc_visible_off(kind: &str) -> bool {
    std::fs::read_to_string(doc_visible_path())
        .ok()
        .and_then(|t| serde_json::from_str::<Value>(&t).ok())
        .and_then(|v| v.get(kind).and_then(|b| b.as_bool()))
        .map(|visible| !visible)
        .unwrap_or(false)
}

fn document_visible(kind: &str, visible: bool) -> Result<(), HostError> {
    let log = crate::scratch::root().join("document-visible.jsonl");
    let mut t = std::fs::read_to_string(&log).unwrap_or_default();
    let fails = visible && (fault("mock-docvisible-restore-fails") || fault("mock-docvisible-restore-fails-always"));
    t.push_str(&json!({"visible": visible, "type": kind, "ok": !fails}).to_string());
    t.push('\n');
    let _ = std::fs::write(&log, t);
    if fails {
        return Err(HostError::msg("simulated: DocumentVisible failed"));
    }
    let mut state = std::fs::read_to_string(doc_visible_path())
        .ok()
        .and_then(|t| serde_json::from_str::<Value>(&t).ok())
        .unwrap_or_else(|| json!({}));
    state[kind] = json!(visible);
    let _ = std::fs::write(doc_visible_path(), state.to_string());
    Ok(())
}

/// A fresh script run on the simulated install. Selection behaves as the first
/// live run on SOLIDWORKS 2026 measured (the VARIANT-array MultiSelect2 selects
/// nothing); `mock-select-dead-<rung>` kills a rung of the ladder too.
fn fresh_run() -> crate::comhost::mock_model::Run {
    use crate::modeling::Rung;
    let mut profile = crate::modeling::SelProfile::measured_2026();
    for (f, r) in [
        ("mock-select-dead-typed", Rung::MultiSelectTyped),
        ("mock-select-dead-select4", Rung::EntitySelect4),
        ("mock-select-dead-ray", Rung::SelectByRay),
        ("mock-select-dead-byid", Rung::SelectByIdAtPoint),
    ] {
        if fault(f) {
            profile.dead.push(r);
        }
    }
    crate::comhost::mock_model::Run { sel_profile: profile, ..Default::default() }
}

/// THE ORDER OF EVENTS, WRITTEN DOWN.
///
/// The safety property of the close sweep is not a fact about the final state —
/// a session can end with every document on disk and still have closed one of
/// them before it was written. It is a fact about ORDER, and order is only
/// checkable if it is recorded. Every recovery write and every close appends a
/// line here, so a test can assert what happened WHEN rather than what was true
/// at the end.
fn journal(event: &str, path: &str) {
    use std::io::Write;
    let f = crate::scratch::root().join("mock-close-order.log");
    if let Ok(mut h) = std::fs::OpenOptions::new().create(true).append(true).open(f) {
        let _ = writeln!(h, "{event}\t{path}");
    }
}

const MOCK_VERSION_DEFAULT: &str = "mock 33.0.0 (2025 SP0)";

/// The simulated RevisionNumber. `mock-revision` in the data root overrides it
/// (e.g. "29.5.1" for the SOLIDWORKS 2021 issue #1/#2 were filed on).
fn mock_version() -> String {
    std::fs::read_to_string(crate::scratch::root().join("mock-revision"))
        .ok()
        .map(|t| t.trim().to_string())
        .filter(|t| !t.is_empty())
        .unwrap_or_else(|| MOCK_VERSION_DEFAULT.to_string())
}

/// The `%TEMP%` the SIMULATED launcher writes under.
///
/// The real backend anchors the drain to the `%TEMP%` the com-host process
/// resolves, which on this Linux host would be `/tmp` — nothing like the Windows
/// path SOLIDWORKS reports. So the mock carries its own RESOLVED root and passes
/// it explicitly, which keeps the drain going through the same
/// `is_import_temp_under` rule the real backend uses rather than a mock-only
/// flag. Modelling this faithfully is what exposed the loose anchor twice.
pub const MOCK_TEMP_ROOT: &str = r"C:\Users\drew\AppData\Local\Temp";

/// The simulated resolved root, or `None` when the test asked for an UNREADABLE
/// `%TEMP%` (`mock-temp-root-unknown`) — the branch where the drain must refuse
/// instead of reporting that it found nothing.
/// PER-DOCUMENT COST, simulated. `SWX_MOCK_DOC_LATENCY_MS` (or the fault file
/// `mock-doc-latency-ms` holding a number) is the milliseconds a single document
/// costs to touch.
///
/// THIS EXISTS BECAUSE THE MOCK'S SPEED HID A LIVE WEDGE. `GetDocuments`
/// marshals every handle in one call; on Drew's 2144-document session it did not
/// answer within 30s and the com host was killed, while `GetDocumentCount`
/// answered in 0.7s. The mock's enumeration was instant, so the whole "degrades
/// instead of timing out" claim was mock-proven and false live. A mock that
/// cannot be slow cannot prove anything about a budget.
fn doc_latency_ms() -> u64 {
    if let Ok(v) = std::env::var("SWX_MOCK_DOC_LATENCY_MS") {
        if let Ok(n) = v.trim().parse::<u64>() {
            return n;
        }
    }
    std::fs::read_to_string(crate::scratch::root().join("mock-doc-latency-ms"))
        .ok()
        .and_then(|t| t.trim().parse::<u64>().ok())
        .unwrap_or(0)
}

/// Charge for touching `n` documents. Called wherever the real backend pays a
/// COM round trip per document — and, crucially, ONCE PER DOCUMENT IN THE WHOLE
/// SESSION wherever it would call `GetDocuments`, because that call's cost is a
/// function of everything open and not of what the caller wanted.
fn charge_docs(n: usize) {
    let ms = doc_latency_ms();
    if ms > 0 && n > 0 {
        std::thread::sleep(std::time::Duration::from_millis(ms.saturating_mul(n as u64)));
    }
}

/// THE OPEN-DOCUMENT COUNT, through the same seam that can be made UNREADABLE.
///
/// The mock used to read `self.docs.len()` directly, so `mock-doccount-none`
/// could not reach the drain — and the drain's discriminator has an arm that
/// fires only when the count cannot be read, which is the arm that decides
/// between "the call did nothing" and "a user's document may have closed". It
/// was dead code as far as the suite was concerned.
/// A PER-CALL SEQUENCE OF COM PROCESS IDS, so the attached session can CHANGE
/// between two reads inside one verb.
///
/// The quit ladder re-measures every precondition before every rung, and the
/// claim check is part of that sweep — but nothing could demonstrate it, because
/// the simulator's attached pid was fixed for the life of a run and the FRONT
/// GATE would always be the thing that caught a mismatch. `mock-com-pid` holds
/// whitespace-separated pids and hands out the next one on each read, so a test
/// can make the session identity valid at rung (a) and different by rung (b) —
/// which is the situation the per-rung sweep exists for.
fn mock_com_pid() -> Option<i64> {
    let path = crate::scratch::root().join("mock-com-pid");
    let text = std::fs::read_to_string(&path).ok()?;
    let pids: Vec<i64> = text.split_whitespace().filter_map(|t| t.parse().ok()).collect();
    if pids.is_empty() {
        return None;
    }
    // The cursor lives beside it, so the sequence survives the com host being
    // respawned mid-verb — which this bridge does on purpose.
    let cursor_path = crate::scratch::root().join("mock-com-pid.cursor");
    let i: usize = std::fs::read_to_string(&cursor_path)
        .ok()
        .and_then(|t| t.trim().parse().ok())
        .unwrap_or(0);
    let _ = std::fs::write(&cursor_path, (i + 1).to_string());
    // The LAST value stands once the sequence is exhausted: a test says what
    // changes, not how many times it will be asked.
    Some(*pids.get(i).unwrap_or(pids.last().expect("non-empty")))
}

fn mock_doc_count(n: usize) -> Option<i32> {
    if fault("mock-doccount-none") {
        None
    } else {
        Some(n as i32)
    }
}

fn mock_temp_roots() -> Vec<String> {
    if fault("mock-temp-root-unknown") {
        return Vec::new();
    }
    // The SAME resolver the real backend uses — which in mock mode answers with
    // MOCK_TEMP_ROOT unless SWX_TEMP_ROOT overrides it. Deferring to it means the
    // mock cannot drift into proving something about a root the real rule would
    // never accept, and it is what lets a test set a MISMATCHED root.
    super::host::temp_roots()
}

/// The mock's preference table — the same NAMES the real backend exposes, so a
/// caller cannot discover a preference here that SOLIDWORKS would reject.
const MOCK_PREFERENCES: &[(&str, i32)] = &[("lengthUnitRaw", 53), ("unitSystemRaw", 48)];

fn prop(value: &str) -> Value {
    json!({"value": value, "resolved": value})
}

impl MockBackend {
    pub fn new() -> Self {
        let mut me = Self {
            running: false,
            created: crate::comhost::created_store::load_session().1,
            created_session: crate::comhost::created_store::load_session().0,
            generation: 0,
            untitled: 0,
            preferences: Map::new(),
            docs: Vec::new(),
            active: None,
            files: std::collections::BTreeMap::new(),
            launching: None,
            pending_exit_at_ms: None,
        };
        me.restore();
        me
    }

    /// A backend with NO restored session — for the in-process unit tests,
    /// which all share one scratch root and would otherwise inherit each
    /// other's open documents. The integration suite gets a real restore
    /// because each bridge there has its own `SWX_SCRATCH_DIR`.
    #[cfg(test)]
    pub fn fresh() -> Self {
        Self {
            running: false,
            created: Vec::new(),
            created_session: None,
            generation: 0,
            untitled: 0,
            preferences: Map::new(),
            docs: Vec::new(),
            active: None,
            files: std::collections::BTreeMap::new(),
            launching: None,
            pending_exit_at_ms: None,
        }
    }

    /// Where the simulated SOLIDWORKS keeps its session between com-host
    /// processes. This is not bridge state: it stands in for the application's
    /// OWN memory, which a com-host respawn does not touch.
    fn session_file() -> std::path::PathBuf {
        crate::scratch::root().join("mock-solidworks-open-docs.json")
    }

    /// `ISldWorks::CloseDoc(name)` — BY NAME, which is what makes the close key
    /// matter: a full path identifies one document, a title identifies whichever
    /// SOLIDWORKS finds first.
    ///
    /// Modelled faithfully as of 0.5.6. Before that the mock removed by its own
    /// unique internal key, so it closed exactly the right document every time
    /// and 254 green tests said nothing whatsoever about the close key — while
    /// the real backend was passing a TITLE that Drew's session had two of.
    fn find_by_name(&self, name: &str) -> Option<usize> {
        // A full path wins, and matches only itself.
        if let Some(i) = self
            .docs
            .iter()
            .position(|d| {
                let p = Self::reported_path(d);
                !p.is_empty() && crate::verbs::same_path(p, name)
            })
        {
            return Some(i);
        }
        // Otherwise it is a TITLE, and SOLIDWORKS resolves it to the FIRST open
        // document that carries it.
        self.docs.iter().position(|d| d.title.eq_ignore_ascii_case(name))
    }

    fn close_doc_by_name(&mut self, name: &str) -> bool {
        // AN UNPROVEN PREMISE, MODELLED. `CloseDoc`'s help says only "name of the
        // document to close"; that a FULL PATH is accepted is this bridge's
        // assumption and has never been run live. With this fault set, the path
        // form returns SUCCESS and closes nothing — which is what the count-based
        // discriminator exists to catch, and what would otherwise look like a
        // drain that quietly did nothing.
        if fault("mock-closedoc-ignores-path") && name.contains('\\') {
            return true;
        }
        let Some(pos) = self.find_by_name(name) else { return false };
        // A COMPONENT CANNOT CLOSE WHILE ITS ASSEMBLY HOLDS IT OPEN. This is why
        // the sweep closes parents before children at all; modelling it as a
        // REFUSAL rather than a silent success is what makes the ordering
        // testable — reverse the sort and this returns false, the document is
        // reported as a close failure, and `done` goes false.
        if self.docs[pos].held_open_by_assembly
            && self.docs.iter().any(|x| x.doc_type == "assembly")
        {
            // ON THE RECORD. A refusal that left no trace made the ordering
            // untestable: close the children first and the sweep still finishes,
            // because it retries and the parent eventually goes. "It got there in
            // the end" is not the property — asking in an order SOLIDWORKS can
            // answer is, and only a written-down refusal can show it was violated.
            journal("refused", &self.docs[pos].path);
            return false;
        }
        let d = self.docs.remove(pos);
        // THIS document closes FIRST, then whatever it was holding: the parent is
        // what the sweep asked to close, and the children go out with it.
        journal("closed", &d.path);
        // CLOSING AN ASSEMBLY RELEASES WHAT IT HELD, exactly as SOLIDWORKS does:
        // the components go with their parent, and they go on the record.
        if d.doc_type == "assembly" {
            let released: Vec<String> = self
                .docs
                .iter()
                .filter(|x| x.held_open_by_assembly)
                .map(|x| x.path.clone())
                .collect();
            for p in &released {
                journal("closed", p);
            }
            self.docs.retain(|x| !x.held_open_by_assembly);
        }
        self.created.retain(|(t, _)| t != &d.title);
        if self.active.as_deref() == Some(d.path.as_str()) {
            self.active = self.docs.first().map(|x| x.path.clone());
        }
        true
    }

    /// FLOOD THE SESSION, for the tests that are about scale.
    ///
    /// `SWX_MOCK_OPEN_DOCS=2144` opens that many import temporaries at start, so
    /// the suite can hold the exact session the live defect was found on: 2144
    /// documents, a cap of 200, and a `swx_docs` that must still answer in
    /// seconds. Creating them through `import` one at a time would take longer
    /// than the test it enables.
    fn flood(&mut self) {
        let Ok(n) = std::env::var("SWX_MOCK_OPEN_DOCS") else { return };
        let Ok(n) = n.trim().parse::<usize>() else { return };
        for i in 0..n {
            let key = format!("untitled:flood:{i}:Widget{i}.SLDPRT");
            let mut d = MockDoc::opened(key.clone(), "part", "Default".into());
            d.title = format!("Widget{i}.SLDPRT");
            d.saved = false;
            d.import_temp = true;
            d.temp_path = Some(format!("{MOCK_TEMP_ROOT}\\swx4242\\IC~~\\Widget{i}.SLDPRT"));
            self.docs.push(d);
        }
        self.active = self.docs.first().map(|d| d.path.clone());
    }

    /// Reload the simulated application's open documents. Called once, at host
    /// boot — so a respawned host attaches to the SAME session it left, exactly
    /// as `GetActiveObject` does against a SOLIDWORKS that never went away.
    fn restore(&mut self) {
        let Ok(text) = std::fs::read_to_string(Self::session_file()) else { return };
        let Ok(v) = serde_json::from_str::<Value>(&text) else { return };
        self.running = v.get("running").and_then(|x| x.as_bool()).unwrap_or(false);
        self.untitled = v.get("untitled").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
        self.active = v.get("active").and_then(|x| x.as_str()).map(String::from);
        self.docs = v
            .get("docs")
            .and_then(|x| x.as_array())
            .map(|rows| rows.iter().filter_map(MockDoc::from_json).collect())
            .unwrap_or_default();
        if let Some(files) = v.get("files").and_then(|x| x.as_object()) {
            for (k, val) in files {
                if let Some(props) = val.as_object() {
                    self.files.insert(k.clone(), props.clone());
                }
            }
        }
    }

    /// Persist the simulated application's session. Called after EVERY command
    /// from the dispatch entry point, not from the individual commands, for the
    /// same reason `register_created` is: a command that forgets cannot exist.
    fn persist(&self) {
        let files: Map<String, Value> = self
            .files
            .iter()
            .map(|(k, v)| (k.clone(), Value::Object(v.clone())))
            .collect();
        let body = json!({
            "running": self.running,
            "untitled": self.untitled,
            "active": self.active,
            "docs": self.docs.iter().map(|d| d.to_json()).collect::<Vec<_>>(),
            "files": Value::Object(files),
        });
        let p = Self::session_file();
        if let Some(parent) = p.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let _ = std::fs::write(p, body.to_string());
    }

    /// Find an open document by its key. CASE-INSENSITIVE, via the same
    /// `same_path` the server uses: SOLIDWORKS normalises what it reports (ask
    /// it to save `mine.sldprt` and it will tell you the document lives at
    /// `mine.SLDPRT`), so a mock that compared strings exactly would answer
    /// "document not open" for a document that is open — which is precisely
    /// what it did to a respawned host holding a restored session.
    fn find(&self, path: &str) -> Option<&MockDoc> {
        self.docs.iter().find(|d| d.path == path || crate::verbs::same_path(&d.path, path))
    }

    fn doc_info(&self, path: &str) -> Value {
        match self.find(path) {
            None => Value::Null,
            Some(d) => json!({
                "title": d.title,
                // Never saved -> no path... EXCEPT an import temporary, whose
                // `GetPathName` on the real backend returns its
                // `…\Temp\swx<pid>\IC~~\…` conversion path even though it was
                // never saved anywhere real. The mock used to report "" for
                // those, so `is_import_temp` was FALSE against the mock's own
                // doc_info and every path-keyed consumer (the drain match, the
                // write gate's exists-check, save_as, ownership-by-path) was
                // exercised on a value the real backend never produces.
                "path": match (&d.temp_path, d.saved) {
                    (Some(t), _) => t.clone(),
                    (None, true) => d.path.clone(),
                    (None, false) => String::new(),
                },
                "type": d.doc_type,
                "config": d.config,
                // SOLIDWORKS DOES NOT ALWAYS ANSWER THIS. `GetSaveFlag` is
                // unavailable on some documents and some editions, which is why
                // every caller of this field treats absent as DIRTY. A mock
                // that always answers a clean boolean cannot exercise that
                // rule — and it did not, so the "unanswerable question is not
                // permission" guard in swx_close_all_documents had no test.
                "dirty": if std::env::var("SWX_MOCK_DIRTY_UNKNOWN").as_deref() == Ok("1") {
                    Value::Null
                } else {
                    Value::Bool(d.dirty)
                },
            }),
        }
    }

    /// The target document: explicit `path` arg, else the active doc.
    /// The path SOLIDWORKS would REPORT for a document: empty until it is saved.
    /// The mock's internal key (`untitled:Part1`) is not a path and must never
    /// be matchable as one — treating it as one is what made every mock test
    /// pass while the first real unsaved document was unaddressable.
    /// What `GetPathName` would return: a saved document's file, an import
    /// temporary's conversion path, and "" for a document that has neither.
    /// Must agree with `doc_info`, or resolve-by-path cannot find a document the
    /// listing just advertised.
    fn reported_path(d: &MockDoc) -> &str {
        match (&d.temp_path, d.saved) {
            (Some(t), _) => t,
            (None, true) => &d.path,
            (None, false) => "",
        }
    }

    /// The SAME resolution the real backend performs, including the part that
    /// broke live: a PATH matches only a document that actually has one, and a
    /// TITLE is resolved by walking the open documents.
    fn resolve(&self, args: &Value) -> Result<String, HostError> {
        if let Some(path) =
            args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty())
        {
            return match self.docs.iter().find(|d| {
                Self::reported_path(d) == path || crate::verbs::same_path(&Self::reported_path(d), path)
            }) {
                Some(d) => Ok(d.path.clone()),
                None => Err(HostError::coded(
                    "document_not_open",
                    format!("document not open: {path} (swx_docs lists the open titles and paths)"),
                )),
            };
        }
        let title = args
            .get("docName")
            .and_then(|v| v.as_str())
            .filter(|s| !s.is_empty())
            .or_else(|| args.get("name").and_then(|v| v.as_str()).filter(|s| !s.is_empty()));
        if let Some(title) = title {
            let hits: Vec<&MockDoc> = self.docs.iter().filter(|d| crate::verbs::same_title(&d.title, title)).collect();
            return match hits.len() {
                1 => Ok(hits[0].path.clone()),
                0 => Err(HostError::coded(
                    "document_not_open",
                    format!("document not open: {title} (swx_docs lists the open titles and paths)"),
                )),
                n => Err(HostError::coded(
                    "ambiguous_target",
                    format!("{n} open documents are titled '{title}' — name the one you mean by path"),
                )),
            };
        }
        match self.active.clone() {
            Some(p) if self.find(&p).is_some() => Ok(p),
            _ => Err(HostError::coded(
                "no_target_document",
                "no active document (pass path or name, or swx_open one)",
            )),
        }
    }


    /// THE HOST'S OWN VETO, mirroring `ComBackend::authorize`. It re-decides
    /// ownership from its OWN record and reads the process-level opt-in from the
    /// ENVIRONMENT, so a server that sent `allowUserWrites` it had no right to
    /// send is still refused.
    /// Remember a document THIS HOST created, keyed by its identity, and persist it.
    fn remember_created(&mut self, key: &str) {
        let (title, path) = match self.find(key) {
            Some(d) => (d.title.clone(), if d.saved { Some(d.path.clone()) } else { None }),
            None => return,
        };
        self.remember_created_parts(title, path);
    }

    /// The same, from the `{title, path}` a result carries — the form the
    /// dispatch loop applies to every command without knowing what it does.
    fn remember_created_parts(&mut self, title: String, path: Option<String>) {
        if title.is_empty() {
            return;
        }
        let session = Some(self.session());
        if self.created_session != session {
            self.created.clear();
            self.created_session = session;
        }
        // Evict by IDENTITY, not by title — see `ComBackend::remember_created`.
        match &path {
            Some(p) => self.created.retain(|(t, cp)| {
                !cp.as_deref().map(|q| crate::verbs::same_path(q, p)).unwrap_or(false)
                    && !(cp.is_none() && crate::verbs::same_title(t, &title))
            }),
            None => self.created.retain(|(t, cp)| !(cp.is_none() && crate::verbs::same_title(t, &title))),
        }
        self.created.push((title, path));
        crate::comhost::created_store::save_session(self.created_session, &self.created);
    }

    /// Does this host believe it created the document at `key`? Path where there
    /// is one, title otherwise — the same identity rule everywhere else.
    /// What IModelDoc2::Visible = true does to a document, as the help page
    /// documents it: a document created invisibly STAYS hidden.
    fn make_visible_report(visible: bool) -> Value {
        if visible {
            return json!({"wasVisible": true, "madeVisible": false});
        }
        json!({
            "wasVisible": false,
            "putVisible": true,
            "visibleAfter": false,
            "madeVisible": false,
            "note": "SOLIDWORKS kept this document hidden: a document created invisibly cannot be made visible \
                     (help.solidworks.com, ISldWorks::DocumentVisible). Save it and reopen it (swx_save_as, then \
                     swx_open) to see it.",
        })
    }

    fn host_owns(&self, key: &str) -> bool {
        if self.created_session != Some(self.session()) {
            return false;
        }
        let Some(d) = self.find(key) else { return false };
        // BY THE PATH SOLIDWORKS WOULD REPORT, not by the mock's internal key and
        // not by `saved`. An import temporary is UNSAVED and still has a path
        // (its `IC~~` conversion file), so keying this on `saved` looked for a
        // pathless record while `register_created` had stored the path — and the
        // host refused to close the document the bridge had just imported. The
        // real backend has always compared `GetPathName`; this is that rule.
        let reported = Self::reported_path(d);
        if reported.is_empty() {
            self.created.iter().any(|(t, p)| p.is_none() && crate::verbs::same_title(t, &d.title))
        } else {
            self.created
                .iter()
                .any(|(_, p)| p.as_deref().map(|q| crate::verbs::same_path(q, reported)).unwrap_or(false))
        }
    }

    /// Which document a mutating command is aimed at, resolved as the command
    /// itself will resolve it.
    fn authorize_target(&self, cmd: &str, args: &Value) -> Option<String> {
        if cmd == "run_script" {
            if let Some(t) = args.get("targetDocument").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) {
                return self.resolve(&json!({"docName": t})).ok();
            }
            if let Ok(ops) = crate::script::parse(&args.get("script").cloned().unwrap_or(Value::Null)) {
                if matches!(ops.first(), Some(crate::script::Op::NewPart { .. } | crate::script::Op::NewAssembly { .. })) {
                    return None;
                }
            }
        }
        self.resolve(args).ok()
    }

    fn authorize(&self, args: &Value, what: &str, doc: Option<&str>, destination: Option<&str>) -> Result<(), HostError> {
        self.authorize_inner(args, what, doc, destination, false)
    }

    fn authorize_inner(&self, args: &Value, what: &str, doc: Option<&str>, destination: Option<&str>, always: bool) -> Result<(), HostError> {
        // THE SAME THREE-STATE RULE, through the same function the real backend
        // and the server use. The mock's veto has to be the real veto or the
        // suite proves nothing about the layer that actually stops writes.
        let clobbers = match crate::writes::destination_state(destination) {
            crate::writes::Destination::Occupied => true,
            crate::writes::Destination::Free => false,
            crate::writes::Destination::Unverifiable(io) => {
                return Err(HostError::coded(
                    "destination_unverifiable",
                    crate::writes::unverifiable_message(destination.unwrap_or(""), &io),
                ));
            }
        };
        let foreign = doc.map(|p| !self.host_owns(p)).unwrap_or(false);
        if !clobbers && !foreign && !always {
            return Ok(());
        }
        // THE FULL OPT-IN CHAIN, re-checked here from the SAME function the
        // server uses — env, plus allowUserWrites AND confirm AND a reason.
        //
        // This used to test `allowUserWrites` alone, which had two consequences,
        // both live-proven on 2026-09-03. It made the host's veto a WEAKER test
        // than the server's, so "two independent vetoes" was not true in the
        // direction that mattered; and because a `Grant` only carried that one
        // boolean, a verb that authorized a CLOSE and then sent the host a
        // payload without it was refused by the host even though the human had
        // authorized it in full. An escape hatch nobody can open is not a safety
        // property, it is a bug — Drew authorized closing his own documents and
        // the bridge told him no.
        let env_on = crate::writes::user_writes_enabled();
        // DREW'S STANDING AUTHORIZATION for ENDING THE SESSION, honoured by the
        // host's own veto so the two layers agree. `userAuthorized:true` with a
        // reason grants the quit and nothing else — see writes::authorize. The
        // host still checks it for itself rather than trusting the server's word,
        // which is the point of being an independent veto.
        let authorized_quit = always
            && args.get("userAuthorized").and_then(|v| v.as_bool()).unwrap_or(false)
            && args
                .get("reason")
                .and_then(|v| v.as_str())
                .map(|r| r.trim().len() >= 8)
                .unwrap_or(false);
        if authorized_quit {
            return Ok(());
        }

        let missing = crate::writes::missing_opt_in(args);
        if env_on && missing.is_empty() {
            return Ok(());
        }
        Err(HostError::detailed(
            format!(
                "the com host refuses to {what}: {}{}",
                if foreign { "this document was not created by this bridge" } else { "" },
                if clobbers { " the destination already exists" } else { "" }
            ),
            format!(
                "This is the com host's OWN check, not the server's — it keeps its own record of \
                 the documents it created and reads {} from the environment it was started with. \
                 The host applies the IDENTICAL opt-in test the server does. \
                 envOptIn={env_on} missing=[{}]",
                crate::writes::ENV_ALLOW,
                missing.join(", ")
            ),
        ))
    }

    /// SOLIDWORKS' process id, as this mock models it.
    ///
    /// It must NOT be derived from the com host's own pid: SOLIDWORKS is a
    /// separate application that OUTLIVES the host, and the host is killed and
    /// respawned by design. Keying the session to the host process made a
    /// respawned host see a different "SOLIDWORKS" and discard its own record —
    /// the very bug the persisted store exists to fix, reintroduced by the mock.
    /// So the session lives in a file, created on `start` and removed on `stop`,
    /// exactly as a real SOLIDWORKS process id does.
    fn session(&self) -> i64 {
        let p = crate::scratch::root().join("mock-solidworks-session");
        if let Ok(t) = std::fs::read_to_string(&p) {
            if let Ok(v) = t.trim().parse::<i64>() {
                return v;
            }
        }
        let v = 700_000 + self.generation * 1_000 + (std::process::id() as i64 % 1_000);
        if let Some(parent) = p.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let _ = std::fs::write(&p, v.to_string());
        v
    }

    /// Publish the simulated SOLIDWORKS pid into the fake-OS directory so the
    /// PROCESS CENSUS can see it.
    ///
    /// The quit ladder measures success by census (pid + creation time absent),
    /// never by a COM call — so the census and the mock backend must agree about
    /// which process is SOLIDWORKS. This is the same one-source-of-truth wiring
    /// `quit.jsonl` and launch attachability use: the backend publishes, the
    /// census reads.
    fn publish_session(&self) {
        let Some(dir) = fake_dir() else { return };
        let _ = std::fs::create_dir_all(&dir);
        let f = dir.join("mock-session");
        // ONLY WHEN IT IS ACTUALLY RUNNING. Publishing unconditionally put a
        // SOLIDWORKS into the census of every launch fixture the moment anything
        // called status — and a launch machine that sees SOLIDWORKS already up
        // correctly refuses to start a second copy, so the launch suite went
        // red. The census must describe the machine, not the fact that someone
        // asked about it.
        // A SESSION'S HISTORY, kept the way the real one keeps it: once a document
        // has been open in this session, ExitApp behaves differently (measured
        // 2026-10-01 — see the `exitedIfEverHadDocument` quit-script outcome).
        if self.running && !self.docs.is_empty() {
            let _ = std::fs::write(dir.join("mock-session-ever-had-document"), self.session().to_string());
        }
        if self.running {
            let _ = std::fs::write(&f, self.session().to_string());
        } else {
            let _ = std::fs::remove_file(&f);
        }
    }

    /// THE SESSION IS OVER: the simulated process is gone, and the next one is a
    /// NEW SOLIDWORKS — new process id, and the untitled numbering restarts at
    /// Part1, which is the whole reason document ownership is scoped to a
    /// session.
    fn finish_exit(&mut self) {
        if let Some(d) = fake_dir() {
            let _ = std::fs::remove_file(d.join("mock-session-ever-had-document"));
        }
        self.running = false;
        self.docs.clear();
        self.active = None;
        self.generation += 1;
        self.untitled = 0;
        self.end_session();
        let _ = std::fs::remove_file(Self::session_file());
        crate::comhost::created_store::clear();
        self.publish_session();
    }

    /// The SOLIDWORKS "process" went away: a new one gets a new id.
    fn end_session(&self) {
        let _ = std::fs::remove_file(crate::scratch::root().join("mock-solidworks-session"));
    }

    fn require_running(&self) -> Result<(), HostError> {
        if self.running {
            Ok(())
        } else {
            Err(HostError::msg(
                "SOLIDWORKS is not running (pass launch:true or call swx_start first)",
            ))
        }
    }

    fn require_assembly(&self, path: &str, what: &str) -> Result<(), HostError> {
        if self.find(path).map(|d| d.doc_type) == Some("assembly") {
            Ok(())
        } else {
            Err(HostError::msg(what))
        }
    }
}

impl Default for MockBackend {
    fn default() -> Self {
        Self::new()
    }
}

impl Backend for MockBackend {
    fn commands(&self) -> &'static [&'static str] {
        COMMANDS
    }

    fn register_created(&mut self, doc: &Value) {
        let title = doc.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
        let path = doc
            .get("path")
            .and_then(|p| p.as_str())
            .filter(|s| !s.is_empty())
            .map(String::from);
        self.remember_created_parts(title, path);
    }

    /// The SAME deny-by-default veto the real backend applies. Modelled here
    /// because a mock that omits the security-relevant behaviour makes every
    /// test that covers it prove nothing — which is how `run_script`,
    /// `props_set`, `rebuild_check` and `eval` shipped with no host-side guard
    /// while the suite was green.
    fn authorize_command(&mut self, cmd: &str, args: &Value) -> Result<(), HostError> {
        if cmd == "eval" {
            let expr = args.get("expr").and_then(|v| v.as_str()).unwrap_or("");
            if let Some((member, why)) = super::expr::refused_member(expr) {
                return Err(HostError::detailed(
                    format!("the com host refuses to evaluate '{member}': {why}"),
                    "This is the host's own re-check of the read allowlist, not the server's."
                        .to_string(),
                ));
            }
            return Ok(());
        }
        if matches!(cmd, "stop" | "close_all_documents") {
            return self.authorize_inner(args, "quit or close the user's SOLIDWORKS session", None, None, true);
        }
        // An EXPORT (and a save_as in Copy mode) writes a NEW file and leaves
        // the document alone, so reading the user's part to produce one is not a
        // write to their data — only the destination has to be clear. Applying
        // the ownership rule here as well would refuse the single most useful
        // thing this bridge does.
        let copy_only = cmd == "export"
            || (cmd == "save_as" && args.get("copy").and_then(|v| v.as_bool()).unwrap_or(false));
        if cmd == "open" {
            let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
            let ours = self
                .created
                .iter()
                .any(|(_, p)| p.as_deref().map(|q| crate::verbs::same_path(q, path)).unwrap_or(false));
            if !ours {
                return self.authorize_inner(args, "open this document FOR WRITING", None, None, true);
            }
            return Ok(());
        }
        let doc: Option<String> = if copy_only { None } else { self.authorize_target(cmd, args) };
        let destination = args
            .get("out")
            .or_else(|| args.get("outputPath"))
            .and_then(|v| v.as_str())
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());
        self.authorize(args, cmd, doc.as_deref(), destination.as_deref())
    }

    fn call(&mut self, cmd: &str, args: &Value) -> Result<Value, HostError> {
        // WHAT REACHED THE SIMULATED SOLIDWORKS, when a test asks
        // (`mock-log-commands`): how a test proves a verb never called it.
        if fault("mock-log-commands") {
            let f = crate::scratch::root().join("mock-commands.log");
            let mut t = std::fs::read_to_string(&f).unwrap_or_default();
            t.push_str(cmd);
            t.push('\n');
            let _ = std::fs::write(&f, t);
        }
        // A DIALOG THAT APPEARS WHILE A COMMAND RUNS (0.11.1): `dialog-on-<cmd>.json`
        // in the simulated machine's dir holds window rows; the command puts them
        // up (once), as SOLIDWORKS raising a File ▸ Open would.
        if let Ok(dir) = std::env::var(crate::launchos::ENV_FAKE_DIR) {
            let trigger = std::path::Path::new(&dir).join(format!("dialog-on-{cmd}.json"));
            if let Some(rows) = std::fs::read_to_string(&trigger).ok().and_then(|t| serde_json::from_str::<Vec<Value>>(&t).ok()) {
                let wpath = std::path::Path::new(&dir).join("windows.json");
                let mut all: Vec<Value> = std::fs::read_to_string(&wpath).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
                all.extend(rows);
                let _ = std::fs::write(&wpath, Value::Array(all).to_string());
                let _ = std::fs::remove_file(&trigger);
            }
        }
        // SOLIDWORKS DIES SHORTLY AFTER A DOCUMENT COMMAND (0.11.3) — the
        // 2026-10-15 22:40 crash: `mock-dies-after-new-document` arms a countdown
        // of commands that still answer (default 1: the identity save); then the
        // process is gone, its documents with it.
        let countdown = crate::scratch::root().join("mock-death-countdown");
        if let Some(k) = std::fs::read_to_string(&countdown).ok().and_then(|t| t.trim().parse::<u64>().ok()) {
            if k == 0 {
                let _ = std::fs::remove_file(&countdown);
                self.finish_exit();
            } else {
                let _ = std::fs::write(&countdown, (k - 1).to_string());
            }
        }
        let out = self.dispatch(cmd, args);
        // The simulated APPLICATION's session outlives the com host, because a
        // real one does. Written after every command so a respawn resumes from
        // exactly where the last one left off.
        self.persist();
        out
    }
}

impl MockBackend {
    fn dispatch(&mut self, cmd: &str, args: &Value) -> Result<Value, HostError> {
        // A SLOW EXIT that has now completed. Applied lazily rather than on a
        // timer so the simulator has no thread of its own and stays exactly as
        // deterministic as the file-backed census that measures it.
        if let Some(at) = self.pending_exit_at_ms {
            if crate::launchos::epoch_ms() as u64 >= at {
                self.pending_exit_at_ms = None;
                self.finish_exit();
            }
        }
        // A COM CALL THAT OVERRAN ITS BUDGET, simulated at the exact boundary
        // the server judges: the message the ComHost produces on a real timeout.
        // Test-only; it is the only way to reach the `host_app_hung` branch,
        // which needs a genuine timeout and not merely a failure.
        if fault("mock-com-timeout") && cmd != "ping" {
            return Err(HostError::timeout(format!(
                "com host did not answer '{cmd}' within 20s (SOLIDWORKS may be busy with a modal \
                 dialog or a huge rebuild); it was killed and respawns on the next call"
            )));
        }
        match cmd {
            "ping" => Ok(json!({"pong": true, "pid": std::process::id()})),

            "status" => {
                // A REAL `status` CALLS `GetActiveObject`: it does not report a
                // cached flag, it asks the machine. So the mock asks the
                // simulator — otherwise `running` could only ever become true
                // inside `start`, and a session that came up while nobody was
                // calling swx_start (the user signing in, which is exactly the
                // case the sign-in work exists for) was invisible to swx_status.
                if !self.running {
                    if let Some(dir) = fake_dir() {
                        if crate::launchos::FakeOs::attachable(&dir) {
                            self.running = true;
                            self.launching = None;
                        }
                    }
                }
                self.publish_session();
                Ok(json!({
                "running": self.running,
                "version": if self.running { Value::from(mock_version()) } else { Value::Null },
                "openDocs": self.docs.len(),
                "attached": self.running,
                // THE PID COM WOULD REPORT: the simulated SOLIDWORKS process,
                // not the mock's internal session number. They are the same
                // process, and a scenario where they differ is not one that can
                // occur on a real machine.
                "processId": if self.running {
                    Value::from(mock_com_pid().unwrap_or_else(|| {
                        fake_dir()
                            .and_then(|d| crate::launchos::FakeOs::attached_sldworks_pid(&d))
                            .map(|p| p as i64)
                            .unwrap_or_else(|| self.session())
                    }))
                } else {
                    Value::Null
                },
                }))
            }

            "start" => {
                // ATTACH vs LAUNCH, modelled faithfully: with launch:false this
                // only succeeds if the app is already up. That distinction is
                // load-bearing on SOLIDWORKS Connected, where launching is
                // impossible and attaching is the only path — so the mock has to
                // have it, or the suite would prove nothing about swx_start.
                let launch = args.get("launch").and_then(|v| v.as_bool()).unwrap_or(false);
                // A launcher invocation that is still coming up becomes
                // attachable once it has had its time — so an ATTACH (launch:
                // false) starts failing and then starts succeeding, exactly as
                // it does while a Connected edition boots.
                if let Some(dir) = fake_dir() {
                    // The simulator decides. A stalled route reports invoked but
                    // never attachable, which is the branch the whole launch
                    // state machine exists for.
                    if crate::launchos::FakeOs::attachable(&dir) {
                        self.running = true;
                        self.launching = None;
                    }
                } else if let Some(since) = self.launching {
                    let after = std::env::var("SWX_MOCK_SHORTCUT_ATTACHES_AFTER_MS")
                        .ok()
                        .and_then(|v| v.parse::<u64>().ok());
                    if let Some(ms) = after {
                        if since.elapsed() >= std::time::Duration::from_millis(ms) {
                            self.running = true;
                            self.launching = None;
                        }
                    }
                }
                if !self.running && !launch {
                    return Err(HostError::msg(
                        "SOLIDWORKS is not running (start it, then retry — swx_start reports \
                         whether this edition can be launched programmatically at all)",
                    ));
                }
                // Test-only knob: make the "cold start" take real time, so the
                // suite can prove swx_progress still answers WHILE a slow verb
                // holds the com host. Never set in production.
                if let Ok(ms) = std::env::var("SWX_MOCK_SLOW_MS") {
                    if let Ok(ms) = ms.parse::<u64>() {
                        std::thread::sleep(std::time::Duration::from_millis(ms));
                    }
                }
                let was_launched = !self.running;
                self.running = true;
                if was_launched && self.docs.is_empty() {
                    self.flood();
                }
                Ok(json!({
                    "started": true,
                    "version": mock_version(),
                    "wasLaunched": was_launched,
                    "mainHwnd": 4242,
                    "processId": self.session(),
                }))
            }

            // THE SANCTIONED LAUNCH, modelled in both its branches.
            //
            // The mock's job here is to be faithful to the CONSTRAINT, not the
            // mechanism: invoking the shortcut is fire-and-forget, and whether
            // SOLIDWORKS has become COM-attachable by the time the call returns
            // is genuinely not knowable — a Connected cold start runs one to
            // three minutes. So the default branch is "invoked, NOT yet
            // attached", which is the branch a real machine almost always takes
            // and the one the polling logic exists for. `SWX_MOCK_SHORTCUT_ATTACHES=1`
            // models the fast machine.
            // SOLIDWORKS VISUALIZE (0.9.19): its platform shortcut, invoked.
            // Recorded for FakeOs, which then shows the Visualize process.
            "launch_visualize" => {
                let shortcut = args.get("shortcut").and_then(|v| v.as_str()).unwrap_or("").to_string();
                if shortcut.is_empty() {
                    return Err(HostError::coded("visualize_shortcut_missing", "no shortcut was given to invoke"));
                }
                if let Some(d) = fake_dir() {
                    let _ = std::fs::write(d.join("visualize-invoked.json"), json!({"shortcut": shortcut}).to_string());
                }
                return Ok(json!({"invoked": true, "shortcut": shortcut, "route": "shellExecute"}));
            }
            "launch_shortcut" => {
                let shortcut =
                    args.get("shortcut").and_then(|v| v.as_str()).unwrap_or("").to_string();
                if shortcut.is_empty() {
                    return Err(HostError::coded(
                        "host_app_launch_blocked",
                        "no shortcut was given to invoke",
                    ));
                }
                if self.running {
                    return Ok(json!({"invoked": false, "attached": true, "alreadyRunning": true}));
                }
                // The SERVER picks the route; the host only carries it out.
                let route = args
                    .get("route")
                    .and_then(|v| v.as_str())
                    .filter(|r| crate::launch::is_route(r))
                    .unwrap_or(crate::launch::ROUTES[0])
                    .to_string();
                if let Some(dir) = fake_dir() {
                    // Running the shortcut, simulated: the invocation is
                    // recorded and everything the state machine can observe —
                    // the chain, the launcher log, Run Begin, sldworks.exe,
                    // attachability — is derived from it by `FakeOs`.
                    let seq = crate::launchos::FakeOs::record_invoke(&dir, &route, &shortcut);
                    self.launching = Some(std::time::Instant::now());
                    // ATTRIBUTION, modelled per route exactly as the real thing
                    // can and cannot supply it: the two shell-handle routes hand
                    // back the root they started, the other two hand back
                    // nothing. And a route that started no process has nothing
                    // to attribute whichever route it is.
                    let sim = crate::launchos::FakeOs { dir: dir.clone() };
                    let inv = sim.invocations().into_iter().find(|i| i.seq == seq);
                    // A route attributes only when it CAN in principle AND
                    // this particular invocation actually got a handle back.
                    let reports_root = sim.route_reports_root(&route);
                    let root = match (crate::launch::route_is_attributable(&route), reports_root, inv) {
                        (true, true, Some(i)) => Some((i.catstart_pid(), (i.at_ms / 1000) as u64)),
                        _ => None,
                    };
                    return Ok(json!({
                        "invoked": true,
                        "route": route,
                        "rootPid": root.map(|(p, _)| p),
                        "rootCreatedAt": root.map(|(_, c)| c),
                        "attributionSource": match route.as_str() {
                            "shellExecute" => "shellExecuteHandle",
                            "powershell" => "startProcessPassThru",
                            _ => "none",
                        },
                        "attributionNote": if root.is_some() {
                            Value::Null
                        } else {
                            Value::from(format!(
                                "route '{route}' cannot say what it started, so this bridge will \
                                 not terminate anything on its behalf"
                            ))
                        },
                        "attached": false,
                        "shortcut": shortcut,
                        "version": Value::Null,
                    }));
                }
                let attaches = std::env::var("SWX_MOCK_SHORTCUT_ATTACHES").as_deref() == Ok("1");
                if attaches {
                    self.running = true;
                } else {
                    // COMING UP. The launcher was invoked and SOLIDWORKS will
                    // appear after a while — which is the real machine's
                    // behaviour and the only shape that exercises the server's
                    // attach POLL. Without this the mock could only model
                    // "instantly up" or "never up", and the polling loop — the
                    // part that exists because a Connected cold start runs
                    // minutes — would have no test at all.
                    self.launching = Some(std::time::Instant::now());
                }
                Ok(json!({
                    "invoked": true,
                    "route": route,
                    "attached": attaches,
                    "shortcut": shortcut,
                    "version": if attaches { Value::from(mock_version()) } else { Value::Null },
                }))
            }

            "stop" => {
                if !self.running {
                    return Ok(json!({"stopped": false, "reason": "not running"}));
                }
                self.running = false;
                self.docs.clear();
                self.active = None;
                // A restart is a NEW session: new process id, and the untitled
                // numbering starts over at Part1 — just like the real thing.
                self.generation += 1;
                self.untitled = 0;
                self.end_session();
                let _ = std::fs::remove_file(Self::session_file());
                crate::comhost::created_store::clear();
                Ok(json!({"stopped": true, "stillRunning": false, "rpcDisconnect": Value::Null}))
            }

            "docs" => {
                self.require_running()?;
                let active = match &self.active {
                    Some(p) => self.doc_info(p),
                    None => Value::Null,
                };
                // SOLIDWORKS DECLINING TO COUNT. Modelled so the
                // unreadable-count branch (which must NOT enumerate) has a test.
                if fault("mock-doccount-none") {
                    return Ok(json!({
                        "docs": [],
                        "count": Value::Null,
                        "documentCount": Value::Null,
                        "truncated": true,
                        "sample": [],
                        "active": active,
                        "_hint": "SOLIDWORKS would not say how many documents are open, so this                                   bridge did NOT try to enumerate them. `count:null` means                                   UNKNOWN, not zero. Retry.",
                    }));
                }
                let count = self.docs.len();
                if count > super::host::doc_enum_cap() {
                    // THE WALK, not the marshal: the real backend now visits
                    // exactly DOC_SAMPLE documents here, so the mock pays for
                    // exactly that many. Before this fix it paid for none, and a
                    // path that cost 2144 round trips live looked free.
                    charge_docs(self.docs.len().min(super::host::DOC_SAMPLE));
                    // Built from the doc's own fields, not via doc_info, to keep
                    // the iterator's immutable borrow of self.docs clean — and
                    // an import temp shows its synthetic IC~~ path here (as the
                    // real backend shows GetPathName) even though it is unsaved.
                    let sample: Vec<Value> = self
                        .docs
                        .iter()
                        .take(super::host::DOC_SAMPLE)
                        .map(|d| json!({
                            "title": d.title,
                            "path": d.temp_path.clone().unwrap_or_else(|| if d.saved { d.path.clone() } else { String::new() }),
                            "importTemp": d.import_temp,
                        }))
                        .collect();
                    // COUNTED OVER THE SAMPLE ONLY, like the real backend — the
                    // session-wide count used to be computed here with one path
                    // read per document, which is the 2144-round-trip defect.
                    let temps = self
                        .docs
                        .iter()
                        .take(super::host::DOC_SAMPLE)
                        .filter(|d| {
                            d.temp_path
                                .as_deref()
                                .map(|p| {
                                    super::host::is_import_temp_under_any(p, &mock_temp_roots())
                                })
                                .unwrap_or(false)
                        })
                        .count();
                    return Ok(json!({
                        "docs": [],
                        "count": count,
                        "documentCount": count,
                        "truncated": true,
                        "sample": sample,
                        "active": active,
                        "importTempsInSample": temps,
                        "importTempsInSampleOf": sample.len(),
                        "sampleTruncatedByTime": false,
                        "_hint": "too many documents to enumerate in the budget — count plus a                                   sample, not a timeout. Likely leaked import temps; run                                   swx_close_import_temps.",
                    }));
                }
                // UNDER the cap the real backend does marshal them all, so the
                // mock pays for all of them — which is what makes the cap itself
                // testable rather than decorative.
                charge_docs(count);
                let docs: Vec<Value> =
                    self.docs.iter().map(|d| self.doc_info(&d.path)).collect();
                Ok(json!({"docs": docs, "count": count, "truncated": false, "active": active}))
            }

            // THE QUIT LADDER, per-rung, scripted. `quit-script.json` in the
            // fake dir says what each rung does — declined / exited / dialog /
            // uipiBlocked / skipped — so every branch of the ladder has a test
            // instead of the mock's old "rung (a) always quits", which made
            // rungs (b)-(f) unreachable from any test.
            "quit_rung" => {
                let rung = args.get("rung").and_then(|v| v.as_str()).unwrap_or("").to_string();
                if !crate::quit::is_rung(&rung) {
                    return Err(HostError::coded(
                        "unknown_rung",
                        format!("'{rung}' is not a rung of the quit ladder"),
                    ));
                }
                // Every rung releases every reference, exactly as the real one
                // does — the ladder's invariant is that no rung runs, and no
                // measurement is taken, while the host holds an IDispatch.
                // The script entry is either a plain outcome ("exited") or an
                // object: {"result":"exited","exitAfterMs":4000}. The delay is
                // the important one — a real `ExitApp` RETURNS and the process
                // then takes seconds to go, and a single post-rung sample called
                // that "declined" and escalated into an exiting application.
                let entry = fake_dir().and_then(|d| {
                    std::fs::read_to_string(d.join("quit-script.json"))
                        .ok()
                        .and_then(|t| serde_json::from_str::<Value>(&t).ok())
                        .and_then(|v| v.get(&rung).cloned())
                });
                let (outcome, exit_after_ms) = match &entry {
                    Some(Value::String(s)) => (s.clone(), 0u64),
                    Some(v @ Value::Object(_)) => (
                        v.get("result").and_then(|x| x.as_str()).unwrap_or("declined").to_string(),
                        v.get("exitAfterMs").and_then(|x| x.as_u64()).unwrap_or(0),
                    ),
                    // No script at all: with no fake dir there is no census to
                    // measure against either, so the simplest useful default is
                    // a clean immediate exit.
                    _ if fake_dir().is_none() => ("exited".to_string(), 0),
                    _ => ("declined".to_string(), 0),
                };
                // MEASURED 2026-10-01 (pid 20144): rung (a) was DECLINED three times
                // on a bridge-launched session that had NEVER had a document open,
                // and quit in 3.4 s on the same session once one scratch part had
                // been created and closed. `exitedIfEverHadDocument` models that:
                // declined until this session has held a document, then exited.
                let outcome = if outcome == "exitedIfEverHadDocument" {
                    let had = fake_dir()
                        .and_then(|d| std::fs::read_to_string(d.join("mock-session-ever-had-document")).ok())
                        .map(|t| t.trim() == self.session().to_string())
                        .unwrap_or(false);
                    if had { "exited".to_string() } else { "declined".to_string() }
                } else {
                    outcome
                };
                let mut detail = vec![format!("simulated rung '{rung}': {outcome}")];
                // THE USER OPENS A DOCUMENT just as the rung is declined — the
                // moment priming must notice, because priming is never done to a
                // session somebody is working in.
                // THE USER COMES BACK just as the rung is declined (0.9.10, a
                // mid-verb fixture): `presence-after-decline.json` in the
                // simulator dir — `{"foreground": {...}, "lastInputSecs": n}` —
                // becomes the machine's presence the moment ExitApp is declined,
                // between the declined rung and the prime.
                if outcome == "declined" {
                    if let Some(d) = fake_dir() {
                        if let Ok(t) = std::fs::read_to_string(d.join("presence-after-decline.json")) {
                            if let Ok(v) = serde_json::from_str::<Value>(&t) {
                                if let Some(fg) = v.get("foreground") {
                                    let _ = std::fs::write(d.join("foreground.json"), fg.to_string());
                                }
                                if let Some(n) = v.get("lastInputSecs").and_then(|n| n.as_u64()) {
                                    let _ = std::fs::write(d.join("last-input-secs"), n.to_string());
                                }
                            }
                        }
                    }
                }
                if outcome == "declined" && fault("mock-doc-appears-after-decline") {
                    let path = "C:\\Users\\drew\\work\\opened-meanwhile.SLDPRT".to_string();
                    if self.find_by_name(&path).is_none() {
                        self.docs.push(MockDoc::opened(path, "part", "Default".into()));
                        self.publish_session();
                    }
                }
                match outcome.as_str() {
                    "exited" | "quit" => {
                        // ONE source of truth: the census (FakeOs) reads this
                        // same file, so the measurement cannot disagree.
                        let now_ms = crate::launchos::epoch_ms() as u64;
                        let effective = now_ms + exit_after_ms;
                        if let Some(d) = fake_dir() {
                            let f = d.join("quit.jsonl");
                            let mut t = std::fs::read_to_string(&f).unwrap_or_default();
                            t.push_str(
                                &json!({"rung": rung, "atMs": now_ms, "effectiveAtMs": effective})
                                    .to_string(),
                            );
                            t.push('\n');
                            let _ = std::fs::write(&f, t);
                        }
                        // A QUIT ENDS THE SESSION, and the next one is a NEW
                        // SOLIDWORKS: new process id, and the untitled numbering
                        // restarts at Part1 — which is the whole reason document
                        // ownership is scoped to a session. The `stop` command
                        // has always modelled this; the ladder must too, or a
                        // test could not tell a real quit from a no-op.
                        if exit_after_ms == 0 {
                            self.finish_exit();
                            detail.push(
                                "the simulated SOLIDWORKS process exited; the session ended (new \
                                 process id, untitled numbering restarts)"
                                    .into(),
                            );
                        } else {
                            // STILL RUNNING for now: the rung returned, the
                            // process has not gone yet, and the census says so.
                            self.pending_exit_at_ms = Some(effective);
                            detail.push(format!(
                                "the rung returned; the simulated process exits {exit_after_ms} ms \
                                 later (it is still in the census until then)"
                            ));
                        }
                    }
                    "dialog" | "dialogAppeared" => {
                        if let Some(d) = fake_dir() {
                            let _ = std::fs::write(
                                d.join("dialog-open.json"),
                                json!({
                                    "hwnd": 9001,
                                    "className": "#32770",
                                    "title": "Save Modified Documents",
                                    "visible": true
                                })
                                .to_string(),
                            );
                        }
                        detail.push("a top-level dialog appeared".into());
                    }
                    _ => {}
                }
                Ok(json!({
                    "rung": rung,
                    "result": outcome,
                    "detail": detail.join("; "),
                    "comReferencesOutstanding": 0,
                }))
            }

            "release_com" => Ok(json!({
                "released": true,
                "outstanding": 0,
                "note": "the mock holds no IDispatch; the count is zero by construction",
            })),

            "com_refs" => Ok(json!({"outstanding": 0})),

            // DREW'S STANDING AUTHORIZATION, modelled: preserve every document
            // to disk, THEN close it. The property under test is a SEQUENCE —
            // nothing closes before it is on disk — so the mock writes real files
            // and refuses to close a document whose file is not there.
            // ONE DOCUMENT PER CALL. The server censuses for a dialog between
            // every save and every close, so the host does one step at a time —
            // see `ComBackend::cmd_preserve_and_close` for why a batch was the
            // wrong unit.
            "preserve_and_close" => {
                self.require_running()?;
                let phase = args.get("phase").and_then(|v| v.as_str()).unwrap_or("preserve");
                let count = mock_doc_count(self.docs.len());
                if phase == "close" {
                    let path = args
                        .get("path")
                        .and_then(|v| v.as_str())
                        .filter(|s| !s.trim().is_empty())
                        .ok_or_else(|| HostError::msg("path is required"))?
                        .to_string();
                    let written_here =
                        args.get("writtenHere").and_then(|v| v.as_bool()).unwrap_or(false);
                    // NOTHING CLOSES THAT IS NOT ON DISK — re-verified at the
                    // moment of the close, not merely at the moment of the save,
                    // and through the SAME verdict the real backend uses.
                    if written_here {
                        // THE KIND TRAVELS WITH THE DOCUMENT — re-deriving it
                        // from the name would ask the question to its own answer.
                        let kind = args
                            .get("docKind")
                            .and_then(|v| v.as_str())
                            .and_then(|t| super::super::disk::DocKind::from_type_name(t));
                        let original_bytes = args.get("originalBytes").and_then(|v| v.as_u64());
                        let v = super::host::recovery_file_verdict(&path, kind, original_bytes);
                        if !v.ok {
                            return Ok(json!({
                                "phase": "close", "path": path, "closed": false,
                                "documentCount": count,
                                "why": v.why.unwrap_or_else(|| "unverifiable".into()),
                            }));
                        }
                    }
                    // A CLOSE CAN RAISE A PROMPT TOO. This fault puts one on
                    // screen as the first document closes, so the census the
                    // server runs before the NEXT close is the only thing that
                    // can catch it.
                    if fault("mock-dialog-during-close") {
                        if let Some(d) = fake_dir() {
                            let _ = std::fs::write(
                                d.join("dialog-open.json"),
                                json!({
                                    "hwnd": 9102, "className": "#32770",
                                    "title": "Rebuild before closing?", "visible": true
                                })
                                .to_string(),
                            );
                        }
                    }
                    // ALREADY GONE WITH ITS PARENT: the assembly that held this
                    // component closed it on the way out. That is a close, and it
                    // happened after this document's own recovery write.
                    let already = self.find_by_name(&path).is_none();
                    let closed = if already {
                        true
                    } else {
                        self.close_doc_by_name(&path) && self.find_by_name(&path).is_none()
                    };
                    let after = mock_doc_count(self.docs.len());
                    self.publish_session();
                    return Ok(json!({
                        "phase": "close", "path": path, "closed": closed, "alreadyClosed": already,
                        "documentCount": after,
                        "why": if closed { Value::Null } else { json!("still open after CloseDoc") },
                    }));
                }
                if phase != "preserve" {
                    return Err(HostError::msg(format!(
                        "unknown preserve_and_close phase {phase:?}"
                    )));
                }
                let recovery = args
                    .get("recoveryDir")
                    .and_then(|v| v.as_str())
                    .filter(|s| !s.is_empty())
                    .ok_or_else(|| HostError::msg("recoveryDir is required"))?
                    .to_string();
                // A HOST FAILURE THAT CARRIES REAL COM DETAIL, mid-sweep.
                //
                // `mock-com-timeout` produces a SidecarError with no `data`, so a
                // test using it cannot tell `hostError: null` (the host sent
                // nothing) from the field being ABSENT because nothing constructs
                // it. This fault gives the failure the shape a live COM exception
                // has — HRESULT, source, description — so the structured path is
                // provable end to end.
                if fault("mock-preserve-host-error-with-data") {
                    let mut e = HostError::detailed(
                        "SOLIDWORKS rejected 'SaveAs3' while the sweep was preserving a document",
                        "Type mismatch. (0x80020005) [SldWorks] (argument index 1)",
                    );
                    e.data = Some(json!({
                        "call": "SaveAs3",
                        "hresult": "0x80020005",
                        "source": "SldWorks",
                        "description": "Type mismatch.",
                        "argumentIndex": 1,
                    }));
                    return Err(e);
                }
                let index = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
                // A SLOW SESSION (0.9.12): each preserve step takes this long, so
                // a sweep runs long enough to be watched through swx_progress.
                if let Some(ms) = std::fs::read_to_string(crate::scratch::root().join("mock-preserve-delay-ms"))
                    .ok()
                    .and_then(|t| t.trim().parse::<u64>().ok())
                {
                    std::thread::sleep(std::time::Duration::from_millis(ms));
                }
                let _ = std::fs::create_dir_all(&recovery);
                // A TYPE THE BACKEND CANNOT READ. On the real backend this is
                // `GetType` failing on a document being closed under memory
                // pressure — the condition the sweep exists for. It must become
                // UNKNOWN, never a part.
                let type_unreadable = fault("mock-doctype-unreadable");
                let Some((title, dirty, doc_type, reported)) = self.docs.get(index).map(|d| {
                    (d.title.clone(), d.dirty, d.doc_type, Self::reported_path(d).to_string())
                }) else {
                    return Ok(json!({
                        "phase": "preserve", "index": index, "walked": 0,
                        "documentCount": count, "record": Value::Null, "ready": Value::Null,
                    }));
                };
                let pathless = reported.trim().is_empty();
                // The same chain the real backend walks: the type, then the
                // document's OWN path, then its title. Neither of the last two is
                // a name this sweep produced.
                let kind = if type_unreadable {
                    None
                } else {
                    super::super::disk::DocKind::from_type_name(doc_type)
                }
                .or_else(|| super::super::disk::DocKind::from_extension(&reported))
                .or_else(|| super::super::disk::DocKind::from_extension(&title));
                let kind_name = kind.map(|k| json!(k.type_name())).unwrap_or(Value::Null);
                // The original's size, read before anything is written.
                let original_bytes =
                    if pathless { None } else { std::fs::metadata(&reported).ok().map(|m| m.len()) };
                if !dirty && !pathless {
                    // CLOSED IN PLACE: the same measurement the real backend
                    // takes, so the record can later say whether the user's own
                    // file is still what the sweep closed it against.
                    let original_modified_ms = crate::scratch::modified_ms(&reported);
                    return Ok(json!({
                        "phase": "preserve", "index": index, "walked": 1,
                        "documentCount": count,
                        "record": json!({
                            "title": title, "path": reported, "recoveryPath": Value::Null,
                            "recoveryBytes": Value::Null, "recoverySignatureOk": Value::Null,
                            "docKind": kind_name, "originalBytes": original_bytes,
                            "originalModifiedMs": original_modified_ms,
                            "wasDirty": false, "wasPathless": false, "preserved": true,
                        }),
                        // Already SOLIDWORKS' own file: on disk by definition in
                        // the simulated world, and its path is a Windows one that
                        // does not exist on the host running the suite.
                        "ready": json!({
                            "path": reported, "docType": kind_name, "writtenHere": false,
                            "originalBytes": Value::Null,
                        }),
                    }));
                }
                // A UNIQUE FILE, actually written: several open documents can
                // share a title, and each must get its own.
                // NEVER A GUESSED EXTENSION. An unknown kind takes the title's
                // own extension if it has one, else a neutral name that claims
                // nothing — the downstream verdict must not be able to read a
                // guess back out of the file name.
                // SOLIDWORKS MAY REFUSE THE NEUTRAL NAME. `SaveAs3` dispatches
                // its writer on the extension and `.sldrecover` matches none, so
                // the simulator can be told to reject it — and the sweep must
                // then re-save as `.SLDPRT` and say that it did, WITHOUT the
                // verification softening: the kind is still unknown.
                // MEASURED LIVE 2026-09-17: SaveAs3 to `.sldrecover` answers
                // "format not supported" and writes nothing, so an unknown-kind
                // document is named as a part up front and the RECORD carries the
                // fact that the name is a guess. The simulator models the same
                // decision, and the fault below models the world in which a
                // neutral name IS attempted and refused — the path kept for a
                // future version that accepts it.
                let mut named_as_part = true;
                let ext = match kind {
                    Some(k) => {
                        named_as_part = false;
                        k.extension()
                    }
                    None if fault("mock-neutral-extension-accepted")
                        && !fault("mock-saveas-rejects-unknown-extension") =>
                    {
                        named_as_part = false;
                        "sldrecover"
                    }
                    None => super::super::disk::DocKind::Part.extension(),
                };
                let stem: String = title
                    .rsplit_once('.')
                    .map(|(a, _)| a)
                    .unwrap_or(&title)
                    .chars()
                    .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
                    .collect();
                let stem = if stem.trim_matches('_').is_empty() { "document" } else { stem.trim_matches('_') };
                let mut out = format!("{recovery}/{stem}.{ext}");
                let mut n = 1;
                while std::path::Path::new(&out).exists() {
                    out = format!("{recovery}/{stem}-{n}.{ext}");
                    n += 1;
                }
                let fail = |why: String, bytes: u64, sig: bool| -> Value {
                    json!({
                        "phase": "preserve", "index": index, "walked": 1,
                        "documentCount": count,
                        "record": json!({
                            "title": title, "path": reported,
                            "recoveryPath": if bytes == 0 && !sig && why.starts_with("simulated") {
                                Value::Null
                            } else {
                                json!(out)
                            },
                            "recoveryBytes": bytes, "recoverySignatureOk": sig,
                            "docKind": kind_name, "originalBytes": original_bytes,
                            "namingFallback": if named_as_part { json!("unknownKindNamedAsPart") } else { Value::Null },
                            "wasDirty": dirty, "wasPathless": pathless, "preserved": false,
                            "why": why,
                        }),
                        "ready": Value::Null,
                    })
                };
                if fault("mock-saveas-fails") {
                    return Ok(fail(
                        "simulated: SaveAs3 failed, so this document was NOT closed".into(),
                        0,
                        false,
                    ));
                }
                // WHAT A REAL DOCUMENT LOOKS LIKE, and the three ways a failing
                // disk makes something that is not one:
                //   * `mock-recovery-partial-write` — the entry exists, empty.
                //     `Path::exists()` says yes to it.
                //   * `mock-recovery-truncated` — a genuine header and then the
                //     write stops. Nonzero, plausible, and not the document. This
                //     is the case a byte floor alone was chosen badly for.
                //   * `mock-recovery-bad-header` — full size, wrong container.
                //     What a format change, or a file written by something else,
                //     would look like.
                // A REAL HEADER, measured from real files: four bytes that vary
                // per document, then the format marker every one of the 166
                // documents surveyed carries at offset 4. The simulator writes
                // what SOLIDWORKS writes, not what the internet says it writes —
                // modelling this as a CFBF file would have hidden the fact that
                // modern SOLIDWORKS is not a compound file at all.
                let mut header = vec![0x9f, 0xe4, 0x18, 0x9f];
                header.extend_from_slice(&super::host::SWDOC_FORMAT_MARKER);
                // A DOCUMENT OF THIS KIND'S SIZE. The floors differ per kind
                // because the real files do (the smallest assembly measured is
                // three times the smallest part, the smallest drawing eleven
                // times), so a simulator that wrote one size for all three would
                // have had assemblies failing their own floor.
                let full = match kind {
                    Some(super::super::disk::DocKind::Assembly) => 128 * 1024,
                    Some(super::super::disk::DocKind::Drawing) => 512 * 1024,
                    Some(super::super::disk::DocKind::Part) => 32 * 1024,
                    // A document whose kind is unknown is written at a size that
                    // would satisfy the strictest floor — the simulator models a
                    // COMPLETE write; it is the FAULTS that model incomplete ones.
                    None => 512 * 1024,
                };
                let payload: Vec<u8> = if fault("mock-recovery-partial-write") {
                    Vec::new()
                } else if fault("mock-recovery-truncated") {
                    let mut v = header.clone();
                    v.resize(2048, b'x');
                    v
                } else if fault("mock-recovery-bad-header") {
                    vec![b'x'; full]
                } else if fault("mock-recovery-legacy-container") {
                    // An older document, opened from an archive: genuinely a
                    // compound file, and genuinely closable.
                    let mut v = super::host::OLE_COMPOUND_FILE_SIGNATURE.to_vec();
                    v.resize(full, b'x');
                    v
                } else {
                    let mut v = header.clone();
                    v.resize(full, b'x');
                    v
                };
                if std::fs::write(&out, &payload).is_err() {
                    return Ok(fail("the recovery file could not be written".into(), 0, false));
                }
                // THE SAME VERDICT THE REAL BACKEND APPLIES, through the same
                // function: signature first, then size, failing closed.
                let v = super::host::recovery_file_verdict(&out, kind, original_bytes);
                // A SAVE CAN RAISE A PROMPT — a missing external reference, a
                // rebuild question. This fault puts one on screen the instant the
                // first document has been written, which is precisely the moment
                // a once-per-batch census could not see it.
                if fault("mock-dialog-after-save") {
                    if let Some(d) = fake_dir() {
                        let _ = std::fs::write(
                            d.join("dialog-open.json"),
                            json!({
                                "hwnd": 9101, "className": "#32770",
                                "title": "Save external references?", "visible": true
                            })
                            .to_string(),
                        );
                    }
                }
                if !v.ok {
                    return Ok(fail(
                        v.why.unwrap_or_else(|| "unverifiable".into()),
                        v.bytes,
                        v.signature_ok,
                    ));
                }
                journal("preserved", &out);
                // SOLIDWORKS DIES right after this save — the 2026-10-01 crash,
                // simulated: the save landed, and nothing answers after it.
                if fault("mock-crash-after-first-save") {
                    self.running = false;
                }
                // THE DOCUMENT RE-POINTS at the recovery file, exactly as
                // SaveAs3 with Silent alone does: the close key is read from
                // the document AFTER the save, and it must be this path.
                {
                    let d = &mut self.docs[index];
                    d.saved = true;
                    d.dirty = false;
                    d.temp_path = None;
                    d.path = out.clone();
                    d.title = shown_title(&out);
                }
                self.publish_session();
                Ok(json!({
                    "phase": "preserve", "index": index, "walked": 1,
                    "documentCount": count,
                    "record": json!({
                        "title": title, "path": reported, "recoveryPath": out,
                        "recoveryBytes": v.bytes, "recoverySignatureOk": true,
                        "docKind": kind_name, "originalBytes": original_bytes,
                        "namingFallback": if named_as_part { json!("unknownKindNamedAsPart") } else { Value::Null },
                        "wasDirty": dirty, "wasPathless": pathless, "preserved": true,
                    }),
                    "ready": json!({
                        "path": out, "docType": kind_name, "writtenHere": true,
                        "originalBytes": original_bytes,
                    }),
                }))
            }

            "close_import_temps" => {
                self.require_running()?;
                let max = args.get("max").and_then(|v| v.as_u64()).unwrap_or(200).max(1) as usize;
                let before = mock_doc_count(self.docs.len());
                // AN UNREADABLE %TEMP% IS A REFUSAL, NOT AN EMPTY DRAIN. The
                // anchor is the whole location proof, so without it nothing can
                // be identified as a temp — and reporting `closed:0, done:true`
                // for that would be the silent no-op this bridge keeps being
                // rewritten to stop making.
                let roots = mock_temp_roots();
                if roots.is_empty() {
                    return Ok(json!({
                        "closed": 0,
                        "closedDocs": [],
                        "documentCountBefore": before,
                        "documentCountAfter": before,
                        "done": false,
                        "batchLimit": max,
                        "tempRoot": Value::Null,
                        "tempRoots": [],
                    }));
                }
                // BY PATH, through the SAME `is_import_temp` the real backend
                // calls — not by a mock-only flag. The flag would have made the
                // mock prove something about itself while the real drain proved
                // something about paths, which is exactly how F1 (a loose path
                // proof that closed user documents) had no e2e test.
                let is_temp = |d: &MockDoc| -> bool {
                    d.temp_path
                        .as_deref()
                        .map(|p| super::host::is_import_temp_under_any(p, &roots))
                        .unwrap_or(false)
                };
                // ── THE WINDOW IS THE WHOLE POINT. The real backend walks a
                //    bounded window (about 4x the batch) one document at a time
                //    and reasons ONLY over what it passed. The mock used to select
                //    victims and check collisions across the entire session, so it
                //    closed documents its own reported scan never reached — and
                //    every window property of the real backend was untested.
                let window = self.docs.len().min(max.saturating_mul(4).max(max + 50));
                charge_docs(window);
                // The window as (title, reported path, is-a-temp, has-the-tail) —
                // everything the real backend learns by walking, and nothing it
                // could not have learned that way.
                let walked: Vec<(String, String, bool, bool)> = self
                    .docs
                    .iter()
                    .take(window)
                    .map(|d| {
                        (
                            d.title.clone(),
                            Self::reported_path(d).to_string(),
                            is_temp(d),
                            d.temp_path
                                .as_deref()
                                .map(super::host::has_import_temp_tail)
                                .unwrap_or(false),
                        )
                    })
                    .collect();
                let walk_complete = window >= self.docs.len();
                // The NEAR MISSES, over the WINDOW: the launcher's tail under a
                // directory that is not one of the roots this process resolved.
                let elsewhere: Vec<&(String, String, bool, bool)> =
                    walked.iter().filter(|(_, _, temp, tail)| !*temp && *tail).collect();
                let elsewhere_count = elsewhere.len() as u64;
                let elsewhere_sample = elsewhere.first().map(|(_, p, _, _)| p.clone());
                let total_temps = walked.iter().filter(|(_, _, temp, _)| *temp).count();
                // THE VICTIM IS (title, reported path) — the same pair the real
                // backend collects, because the close key is the whole point of
                // this round.
                let victims: Vec<(String, String)> = walked
                    .iter()
                    .filter(|(_, _, temp, _)| *temp)
                    .take(max)
                    .map(|(t, p, _, _)| (t.clone(), p.clone()))
                    .collect();
                let hit_limit = total_temps > victims.len();
                // A TITLE IS NOT AN IDENTITY. Any other open document sharing a
                // victim's title makes `CloseDoc` ambiguous — SOLIDWORKS would
                // pick — so the victim is skipped and the collision reported.
                let mut collisions: Vec<Value> = Vec::new();
                let victims: Vec<(String, String)> = victims
                    .into_iter()
                    .filter(|(title, path)| {
                        // OVER THE WINDOW, exactly as the real backend does — a
                        // same-titled document outside it is invisible to this
                        // check, which is what `titleCollisionsCheckedOver` says.
                        let others: Vec<String> = walked
                            .iter()
                            .filter(|(t, p, _, _)| crate::verbs::same_title(t, title) && !crate::verbs::same_path(p, path))
                            .map(|(_, p, _, _)| p.clone())
                            .collect();
                        if others.is_empty() {
                            return true;
                        }
                        let mut paths = vec![path.clone()];
                        paths.extend(others);
                        collisions.push(json!({"title": title, "paths": paths}));
                        false
                    })
                    .collect();
                let mut closed = Vec::new();
                let mut close_failed: Vec<Value> = Vec::new();
                let mut wrong_document_closed = false;
                let mut close_ignored_path = false;
                let mut close_outcome_unknown = false;
                for (title, path) in &victims {
                    // BY NAME, exactly as CloseDoc resolves it — see
                    // `close_doc_by_name`. Passing the title here would close
                    // whichever document SOLIDWORKS found first, which is the
                    // defect the previous round existed for.
                    let count_before_this = mock_doc_count(self.docs.len());
                    let asked = self.close_doc_by_name(path);
                    let still_open = self.find_by_name(path).is_some();
                    if asked && still_open {
                        // The SAME discriminator the real backend uses, including
                        // its third case: an UNREADABLE count cannot tell "nothing
                        // happened" from "something else closed", and saying so is
                        // the only honest answer.
                        let now = mock_doc_count(self.docs.len());
                        let nothing_happened = match (count_before_this, now) {
                            (Some(e), Some(n)) => n >= e,
                            _ => {
                                close_outcome_unknown = true;
                                close_failed.push(json!({
                                    "title": title, "path": path, "outcome": "undetermined",
                                    "documentCountBeforeClose": count_before_this,
                                    "why": "CloseDoc returned success, the document is still open, \
                                            and the count could not be read afterwards"}));
                                break;
                            }
                        };
                        if nothing_happened {
                            close_ignored_path = true;
                            close_failed.push(json!({"title": title, "path": path,
                                "why": "CloseDoc(path) did nothing and the count did not move"}));
                            break;
                        }
                        wrong_document_closed = true;
                        close_failed.push(json!({"title": title, "path": path,
                            "why": "CloseDoc reported success, the document is still open, and the \
                                    count DROPPED"}));
                        break;
                    }
                    if !asked || still_open {
                        close_failed.push(json!({"title": title, "path": path,
                            "why": "SOLIDWORKS declined to close it"}));
                        continue;
                    }
                    closed.push(json!({"title": title, "path": path}));
                }
                let after = mock_doc_count(self.docs.len());
                Ok(json!({
                    "closed": closed.len(),
                    "closedDocs": closed,
                    "documentCountBefore": before,
                    "documentCountAfter": after,
                    "done": !hit_limit
                        && walk_complete
                        && collisions.is_empty()
                        && close_failed.is_empty()
                        && !(elsewhere_count > 0 && closed.is_empty()),
                    "batchLimit": max,
                    "tempRoot": roots.first(),
                    "tempRoots": roots,
                    "importTempsElsewhere": elsewhere_count,
                    "importTempsElsewhereSample": elsewhere_sample,
                    "documentsScanned": window,
                    "walkTruncatedByTime": false,
                    "walkComplete": walk_complete,
                    "walkStop": if walk_complete { "reachedEnd" } else { "hitMax" },
                    "titleCollisions": collisions,
                    "titleCollisionsCheckedOver": window,
                    "closeFailed": close_failed,
                    "wrongDocumentClosed": wrong_document_closed,
                    "closeIgnoredPath": close_ignored_path,
                    "closeOutcomeUnknown": close_outcome_unknown,
                }))
            }

            "open" => {
                // Mirror the real backend: opening only starts the app when the
                // SERVER said it may. Without this the mock would make swx_open
                // look like a launch road that always works.
                let launch = args.get("launch").and_then(|v| v.as_bool()).unwrap_or(false);
                if !self.running && !launch {
                    return Err(HostError::msg(
                        "SOLIDWORKS is not running (start it, then retry — swx_start reports \
                         whether this edition can be launched programmatically at all)",
                    ));
                }
                self.running = true;
                let path = args
                    .get("path")
                    .and_then(|v| v.as_str())
                    .ok_or_else(|| HostError::msg("path is required"))?
                    .to_string();
                let doc_type = doc_type_of(&path).ok_or_else(|| {
                    let ext = match path.rfind('.') {
                        Some(i) => path[i..].to_ascii_lowercase(),
                        None => String::new(),
                    };
                    HostError::msg(format!(
                        "not a SOLIDWORKS document: {ext} (want .sldprt/.sldasm/.slddrw)"
                    ))
                })?;
                let config = args
                    .get("configuration")
                    .and_then(|v| v.as_str())
                    .filter(|s| !s.is_empty())
                    .unwrap_or("Default")
                    .to_string();
                // ALREADY OPEN? SOLIDWORKS hands back the document it already
                // has — it does not open a second copy. The mock used to
                // replace the entry, which quietly produced a DUPLICATE whenever
                // the caller's path differed from the reported one by case, and
                // that artifact hid an ownership bug in the test suite.
                if let Some(existing) =
                    self.docs.iter().find(|d| d.saved && crate::verbs::same_path(&d.path, &path))
                {
                    let key = existing.path.clone();
                    self.active = Some(key.clone());
                    return Ok(json!({
                        "opened": self.doc_info(&key),
                        "readOnly": args.get("readOnly").and_then(|v| v.as_bool()).unwrap_or(true),
                        "errors": 0,
                        "warnings": 0,
                        "alreadyOpen": true,
                    }));
                }
                // The mock treats any path as existing, so tests need no fixtures.
                self.docs.retain(|d| d.path != path);
                let mut doc = MockDoc::opened(path.clone(), doc_type, config);
                doc.props.insert("PartNo".into(), prop("MOCK-001"));
                doc.dimensions.insert("D1@Boss-Extrude1".into(), json!(0.02));
                doc.bodies.push(mock_body("Boss-Extrude1", 50.0, 30.0, 20.0));
                self.docs.push(doc);
                self.active = Some(path.clone());
                Ok(json!({
                    "opened": self.doc_info(&path),
                    "readOnly": args.get("readOnly").and_then(|v| v.as_bool()).unwrap_or(true),
                    "errors": 0,
                    "warnings": 0,
                }))
            }

            "close_doc" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                // A CLOSE THAT FAILS: the document stays open. What the quit
                // ladder's prime must report as "the bridge's own part is still
                // open; drain it" (adversary pass 42, S6).
                if fault("mock-closedoc-fails") {
                    return Err(HostError::msg("CloseDoc left the document open (simulated)"));
                }
                let title = base_name(&path).to_string();
                self.docs.retain(|d| d.path != path);
                if self.active.as_deref() == Some(path.as_str()) {
                    self.active = self.docs.first().map(|d| d.path.clone());
                }
                // A closed document is no longer ours — the server prunes its
                // registry the same way.
                self.created.retain(|(t, p)| *t != title && p.as_deref() != Some(path.as_str()));
                crate::comhost::created_store::save_session(self.created_session, &self.created);
                Ok(json!({"closed": title}))
            }

            "save" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {
                    d.dirty = false;
                }
                Ok(json!({
                    "saved": true, "errors": 0, "warnings": 0, "doc": self.doc_info(&path)
                }))
            }

            "export" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                let out = args
                    .get("out")
                    .and_then(|v| v.as_str())
                    .filter(|s| !s.is_empty())
                    .ok_or_else(|| HostError::msg("out (destination path) is required"))?;
                // SaveAs3 returns swFileSaveError_e, and on a live install it
                // returns 256 for a format the document type cannot produce.
                // The mock reproduces that, because "export reported failure
                // while writing good bytes" was a real shipped bug and it had
                // NO mock coverage — the fake always succeeded.
                // Path::extension, not rsplit('.') — a destination with no
                // extension at all would otherwise "match" the whole path.
                let ext = std::path::Path::new(out)
                    .extension()
                    .map(|e| e.to_string_lossy().to_ascii_lowercase())
                    .unwrap_or_default();
                let doc_type = self.find(&path).map(|d| d.doc_type).unwrap_or("part");
                if doc_type == "part" && matches!(ext.as_str(), "obj" | "dxf" | "dwg" | "u3d") {
                    return Err(HostError::msg(format!(
                        "SOLIDWORKS refused to write {out}: format not supported for this \
                         document type (swFileSaveError_e 256)"
                    )));
                }
                let parent = std::path::Path::new(out).parent();
                if let Some(p) = parent {
                    if !p.as_os_str().is_empty() {
                        std::fs::create_dir_all(p)
                            .map_err(|e| HostError::msg(format!("cannot create {p:?}: {e}")))?;
                    }
                }
                let body = format!("mock export of {path}\n");
                std::fs::write(out, &body)
                    .map_err(|e| HostError::msg(format!("cannot write {out}: {e}")))?;
                // Test-only fault injection (never set in production): make the
                // export RENAME the document, which is what SaveAs3 does without
                // the Copy flag. It is how the suite proves the bridge REPORTS a
                // rename rather than silently passing it on.
                let renamed = std::env::var("SWX_MOCK_RENAME_ON_EXPORT").as_deref() == Ok("1");
                if renamed {
                    let title = shown_title(out);
                    if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {
                        d.title = title;
                    }
                }
                Ok(json!({
                    "exported": out,
                    "bytes": body.len(),
                    "fileSizeKB": (body.len() as f64 / 1024.0).round() as u64,
                    "saveErrorCode": 0,
                    "documentRenamed": renamed,
                    "doc": self.doc_info(&path),
                }))
            }

            "props_get" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                let props = self.find(&path).map(|d| d.props.clone()).unwrap_or_default();
                Ok(json!({
                    "doc": self.doc_info(&path),
                    "configuration": args.get("configuration").cloned().unwrap_or_else(|| Value::from("")),
                    "properties": Value::Object(props),
                    "configurations": ["Default"],
                }))
            }

            "props_set" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                let incoming = args
                    .get("properties")
                    .and_then(|v| v.as_object())
                    .cloned()
                    .unwrap_or_default();
                let mut set = Map::new();
                if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {
                    for (k, v) in incoming {
                        let s = match v {
                            Value::String(s) => s,
                            other => other.to_string(),
                        };
                        d.props.insert(k.clone(), prop(&s));
                        set.insert(k, Value::from(1));
                    }
                    d.dirty = true;
                }
                Ok(json!({"doc": self.doc_info(&path), "set": Value::Object(set)}))
            }

            "mass_properties" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                // A part with simulated bodies: computed from them, so this and
                // inspect_bodies agree (issue #2). Otherwise the canned answer.
                if let Some(d) = self.find(&path).filter(|d| !d.bodies.is_empty()) {
                    let density = doc_density(d);
                    let vol_m3: f64 = d.bodies.iter().filter_map(|b| b.get("volumeMm3").and_then(|v| v.as_f64())).sum::<f64>() * 1.0e-9;
                    let model: crate::comhost::mock_model::Model = serde_json::from_value(Value::Object(d.model.clone())).unwrap_or_default();
                    // THE COLUMN-INTEGRATED SOLID where the part allows it (0.11.0):
                    // exact for partial cuts the box model can only approximate.
                    let (vol_m3, com, how) = match model.csg_mass() {
                        Ok((v, c)) => (v, Some(c), json!({"volume": "column integration of the feature primitives (mock_csg; grid error ~0.1 %)"})),
                        Err(why) => (vol_m3, model.center_of_mass(), json!({"volume": "the box model's arithmetic", "columnModelDeclined": why})),
                    };
                    let area_mm2 = d.bodies.iter().filter_map(|b| b.get("areaMm2").and_then(|v| v.as_f64())).sum::<f64>();
                    return Ok(json!({
                        "doc": self.doc_info(&path),
                        "massKg": vol_m3 * density,
                        "massG": vol_m3 * density * 1000.0,
                        "volumeM3": vol_m3,
                        "volumeMm3": vol_m3 * 1.0e9,
                        "surfaceAreaM2": area_mm2 * 1.0e-6,
                        "surfaceAreaMm2": area_mm2,
                        "density": density,
                        "densityKgM3": density,
                        "materialName": d.material,
                        "centerOfMass": com.map(|c| json!(c)).unwrap_or(json!([0.0, 0.0, 0.0])),
                        "centerOfMassMm": com.map(|c| json!([c[0] * 1000.0, c[1] * 1000.0, c[2] * 1000.0])),
                        "boundingBox": Value::Null,
                        "simulated": "mock model: surface area is the bodies' boxes', never compared",
                        "simulatedBy": how,
                    }));
                }
                Ok(json!({
                    "doc": self.doc_info(&path),
                    "massKg": 0.4173,
                    "massG": 417.3,
                    "volumeM3": 5.31e-05,
                    "volumeMm3": 53100.0,
                    "surfaceAreaM2": 0.0234,
                    "surfaceAreaMm2": 23400.0,
                    "density": 7860.0,
                    "densityKgM3": 7860.0,
                    "materialName": "Plain Carbon Steel",
                    "centerOfMass": [0.012, 0.0, 0.0345],
                    "centerOfMassMm": [12.0, 0.0, 34.5],
                    "boundingBox": [-0.05, -0.02, 0.0, 0.05, 0.02, 0.09],
                }))
            }

            "bom" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                self.require_assembly(
                    &path,
                    "BOM needs an assembly (.sldasm) as the target document",
                )?;
                // COMPONENTS THE CALLER INSERTED WIN. Without this the mock's
                // BOM was a canned three rows whatever the assembly contained,
                // so `add_component` could not be tested through the one verb
                // that proves it did anything.
                let real: Vec<(String, String)> =
                    self.docs.iter().find(|d| d.path == path).map(|d| d.components.clone()).unwrap_or_default();
                if !real.is_empty() {
                    let mut keys: Vec<String> = Vec::new();
                    let mut rows: Vec<Map<String, Value>> = Vec::new();
                    for (file, configuration) in &real {
                        let configuration =
                            if configuration.is_empty() { "Default" } else { configuration.as_str() };
                        let key = format!("{file}|{configuration}");
                        if let Some(i) = keys.iter().position(|k| *k == key) {
                            let q = rows[i].get("quantity").and_then(|v| v.as_i64()).unwrap_or(0) + 1;
                            rows[i].insert("quantity".into(), Value::from(q));
                            continue;
                        }
                        // The {value, resolved} shape the CSV writer reads —
                        // the same one the canned rows and the live backend use.
                        let source = self
                            .find(file)
                            .map(|d| d.props.clone())
                            .or_else(|| self.files.get(&file.to_lowercase()).cloned())
                            .unwrap_or_default();
                        // `props` is ALREADY stored in the {value, resolved}
                        // shape the CSV writer reads — the same shape the live
                        // backend's custom_props returns.
                        let props = Value::Object(source);
                        let mut row = Map::new();
                        row.insert("file".into(), Value::from(file.clone()));
                        row.insert("name".into(), Value::from(file_stem(file).to_string()));
                        row.insert("configuration".into(), Value::from(configuration));
                        row.insert("quantity".into(), Value::from(1));
                        row.insert("suppressedInstances".into(), Value::from(0));
                        row.insert("properties".into(), props);
                        keys.push(key);
                        rows.push(row);
                    }
                    let rows: Vec<Value> = rows.into_iter().map(Value::Object).collect();
                    return Ok(json!({
                        "doc": self.doc_info(&path),
                        "topLevelOnly": args.get("topLevelOnly").and_then(|v| v.as_bool()).unwrap_or(false),
                        "rowCount": rows.len(),
                        "rows": rows,
                    }));
                }
                let dir = dir_name(&path);
                let base = if dir.is_empty() { "C:\\mock" } else { dir };
                let file = |n: &str| format!("{base}\\{n}");
                let rows = json!([
                    {
                        "file": file("bracket.sldprt"), "name": "bracket",
                        "configuration": "Default", "quantity": 2, "suppressedInstances": 0,
                        "properties": {
                            "PartNo": prop("BRK-100"),
                            "Material": prop("6061-T6"),
                        },
                    },
                    {
                        "file": file("shaft.sldprt"), "name": "shaft",
                        "configuration": "Long", "quantity": 1, "suppressedInstances": 0,
                        "properties": {"PartNo": prop("SHF-220")},
                    },
                    {
                        "file": file("m4-screw.sldprt"), "name": "m4-screw",
                        "configuration": "Default", "quantity": 8, "suppressedInstances": 0,
                        "properties": {},
                    },
                ]);
                Ok(json!({
                    "doc": self.doc_info(&path),
                    "topLevelOnly": args.get("topLevelOnly").and_then(|v| v.as_bool()).unwrap_or(false),
                    "rowCount": 3,
                    "rows": rows,
                }))
            }

            "interference" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                self.require_assembly(
                    &path,
                    "interference detection needs an assembly (.sldasm)",
                )?;
                // SOLIDWORKS DECLINING TO COUNT, modelled — because `count:
                // null` is the most consequential null in this bridge (a
                // clearance report somebody builds a physical part from) and a
                // shape no test could reach is a shape nothing checks.
                if fault("mock-interference-count-unknown") {
                    return Ok(json!({
                        "doc": self.doc_info(&path),
                        "count": Value::Null,
                        "countKnown": false,
                        "interferences": [
                            {"components": ["bracket-1", "shaft-1"], "volumeM3": 2.1e-08}
                        ],
                        "listed": 1,
                    }));
                }
                Ok(json!({
                    "doc": self.doc_info(&path),
                    "count": 1,
                    "countKnown": true,
                    "listed": 1,
                    "interferences": [
                        {"components": ["bracket-1", "shaft-1"], "volumeM3": 2.1e-08}
                    ],
                }))
            }

            "rebuild_check" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                Ok(json!({
                    "doc": self.doc_info(&path),
                    "rebuilt": true,
                    "featureErrors": [],
                    "errorCount": 0,
                    "unreadableFeatures": 0,
                    "whatsWrongCount": 0,
                    "featuresWalked": 4,
                }))
            }

            "pack_and_go" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                let out = args
                    .get("out")
                    .and_then(|v| v.as_str())
                    .filter(|s| !s.is_empty())
                    .ok_or_else(|| HostError::msg("out (destination directory) is required"))?;
                std::fs::create_dir_all(out)
                    .map_err(|e| HostError::msg(format!("cannot create {out}: {e}")))?;
                let dest = std::path::Path::new(out).join(base_name(&path));
                std::fs::write(&dest, "mock pack-and-go copy\n")
                    .map_err(|e| HostError::msg(format!("cannot write {dest:?}: {e}")))?;
                Ok(json!({
                    "doc": self.doc_info(&path),
                    "out": out,
                    "files": [dest.to_string_lossy()],
                    "statuses": [0],
                }))
            }

            "eval" => {
                // Run the SAME parser the real backend uses, so the grammar is
                // actually exercised over the wire (an unparseable expression is
                // an error in mock mode too, not a cheerful echo). This is what
                // keeps the e2e "com_eval runs" test from proving nothing.
                let expr = args.get("expr").and_then(|v| v.as_str()).unwrap_or("");
                let (root, segments) = super::expr::parse_chain(expr)?;
                let chain: Vec<String> = segments.iter().map(|s| s.name.clone()).collect();
                Ok(json!({
                    "result": format!("mock eval of ${root}.{}", chain.join(".")),
                    "value": Value::Null,
                    "root": root,
                    "chain": chain,
                    "expr": expr,
                }))
            }


            // ── v0.3 ────────────────────────────────────────────────────────
            "new_document" => {
                self.running = true;
                let kind = args.get("type").and_then(|v| v.as_str()).unwrap_or("part");
                let visible = args.get("visible").and_then(|v| v.as_bool()).unwrap_or(true);
                if !visible {
                    // THE BRACKET: off, create, back on — on every path.
                    let canonical = match kind.trim().to_lowercase().as_str() {
                        "assembly" | "asm" | "sldasm" => "assembly",
                        "drawing" | "drw" | "slddrw" => "drawing",
                        _ => "part",
                    };
                    let mut inner = args.clone();
                    inner["visible"] = json!(true);
                    let b = crate::memory::hidden_bracket(
                        |visible| document_visible(canonical, visible),
                        || {
                            if fault("mock-newdoc-fails") {
                                return Err(HostError::msg(
                                    "simulated: SOLIDWORKS created no document (NewDocument failed inside the hidden bracket)",
                                ));
                            }
                            self.call("new_document", &inner)
                        },
                        3,
                    );
                    let mut visibility = json!({
                        "requested": false,
                        "hiddenWith": "ISldWorks::DocumentVisible",
                        "documentVisibleSet": b.set_ok,
                        "documentVisibleRestored": b.restored,
                    });
                    return match b.made {
                        Ok(mut r) => {
                            let key = r.pointer("/created/title").and_then(|t| t.as_str()).map(|t| format!("untitled:{t}"));
                            let read_back = key.as_deref().and_then(|k| self.find(k)).map(|d| d.visible);
                            visibility["visibleReadBack"] = json!(read_back);
                            // Its scratch file, written through the document itself.
                            if let (Some(path), Some(title)) = (
                                args.get("scratchPath").and_then(|v| v.as_str()).filter(|p| !p.is_empty()),
                                r.pointer("/created/title").and_then(|t| t.as_str()).map(String::from),
                            ) {
                                let saved = self.call("save_as", &json!({"docName": title, "out": path, "copy": false}));
                                visibility["scratchSaved"] = json!(saved.is_ok());
                                if let Ok(s) = saved {
                                    if let Some(doc) = s.get("doc").cloned() {
                                        r["created"] = doc;
                                    }
                                }
                            }
                            r["visibility"] = visibility;
                            Ok(r)
                        }
                        Err(e) => Err(e.with_data(json!({"visibility": visibility}))),
                    };
                }
                // EXHAUSTION, as the real thing reports it: SOLIDWORKS accepts
                // the call and hands back nothing. Observed live on 2026-09-03
                // for BOTH the part and the assembly template, at 17.4 GB and
                // 52 open documents.
                if fault("mock-newdoc-empty") {
                    return Err(HostError::msg(
                        "SOLIDWORKS created no document from C:\\ProgramData\\SolidWorks\\templates\\Part.prtdot (is the template valid?)",
                    ));
                }
                // The REAL untitled names SOLIDWORKS uses. A mock that invents
                // its own ("Assembly1") lets a test assert on a title the live
                // application will never produce.
                let (doc_type, prefix) = match kind.trim().to_lowercase().as_str() {
                    "part" | "prt" | "sldprt" => ("part", "Part"),
                    "assembly" | "asm" | "sldasm" => ("assembly", "Assem"),
                    "drawing" | "drw" | "slddrw" => ("drawing", "Draw"),
                    other => {
                        return Err(HostError::msg(format!(
                            "unknown document type '{other}' — use part, assembly or drawing"
                        )))
                    }
                };
                self.untitled += 1;
                let title = format!("{prefix}{}", self.untitled);
                let key = format!("untitled:{title}");
                let template = args
                    .get("template")
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| format!("C:\\ProgramData\\SolidWorks\\templates\\{prefix}.prtdot"));
                let mut doc = MockDoc::opened(key.clone(), doc_type, "Default".into());
                doc.title = title;
                doc.saved = false;
                // A document is created hidden whenever DocumentVisible is false
                // for its type — asked for, or LEFT false by a failed restore.
                doc.visible = !doc_visible_off(doc_type);
                // THE MEASURED GROWTH (2026-10-14): every document created costs
                // SOLIDWORKS memory it keeps after the close. The simulated
                // machine counts creations; `sldworks.json`'s
                // `growthPerDocumentMB` turns the count into private bytes.
                if let Some(d) = fake_dir() {
                    let f = d.join("documents-created");
                    let n = std::fs::read_to_string(&f).ok().and_then(|t| t.trim().parse::<u64>().ok()).unwrap_or(0);
                    let _ = std::fs::write(&f, (n + 1).to_string());
                }
                let hidden = !doc.visible;
                self.docs.push(doc);
                if fault("mock-dies-after-new-document") {
                    let n = std::fs::read_to_string(crate::scratch::root().join("mock-dies-after-new-document"))
                        .ok()
                        .and_then(|t| t.trim().parse::<u64>().ok())
                        .unwrap_or(1);
                    let _ = std::fs::write(crate::scratch::root().join("mock-death-countdown"), n.to_string());
                    let _ = std::fs::remove_file(crate::scratch::root().join("mock-dies-after-new-document"));
                }
                // THE REPLY IS NOT THE EFFECT (0.11.3): `mock-newdoc-vanishes` —
                // the call reports a document that is not there.
                if fault("mock-newdoc-vanishes") {
                    let info = self.doc_info(&key);
                    self.docs.retain(|d| d.path != key);
                    self.active = None;
                    return Ok(json!({"created": info, "template": template, "type": kind}));
                }
                // A hidden document is not made the active one (UNMEASURED live;
                // modelled conservatively).
                if !hidden {
                    self.active = Some(key.clone());
                }
                Ok(json!({"created": self.doc_info(&key), "template": template, "type": kind}))
            }

            "restore_document_visible" => {
                let kind = args.get("type").and_then(|v| v.as_str()).unwrap_or("part");
                if fault("mock-docvisible-restore-fails-always") {
                    return Err(HostError::msg(format!(
                        "ISldWorks::DocumentVisible(true, {kind}) failed three times — documents of that type may \
                         open INVISIBLE in this session until SOLIDWORKS restarts"
                    )));
                }
                let _ = std::fs::remove_file(crate::scratch::root().join("mock-docvisible-restore-fails"));
                document_visible(kind, true)?;
                Ok(json!({"documentVisibleRestored": true, "type": kind}))
            }

            "activate_document" => {
                self.require_running()?;
                let name = args
                    .get("name")
                    .or_else(|| args.get("path"))
                    .and_then(|v| v.as_str())
                    .filter(|s| !s.is_empty())
                    .ok_or_else(|| HostError::msg("name (the document title, as swx_docs reports it) is required"))?;
                let key = self
                    .docs
                    .iter()
                    .find(|d| crate::verbs::same_title(&d.title, name) || d.path == name)
                    .map(|d| d.path.clone())
                    .ok_or_else(|| {
                        HostError::msg(format!(
                            "SOLIDWORKS could not activate '{name}' (errors=1) — swx_docs lists \
                             the titles that are actually open"
                        ))
                    })?;
                if self.find(&key).map(|d| !d.visible).unwrap_or(false) {
                    return Err(HostError::msg(format!(
                        "SOLIDWORKS could not activate '{name}' (errors=0) — it was created invisible, and a hidden \
                         document cannot be activated (measured 2026-10-14)"
                    )));
                }
                self.active = Some(key.clone());
                let visibility = if args.get("visible").and_then(|v| v.as_bool()) == Some(true) {
                    self.find(&key).map(|d| Self::make_visible_report(d.visible))
                } else {
                    None
                };
                Ok(json!({"activeDocument": self.doc_info(&key), "errors": 0, "visibility": visibility}))
            }

            "close_all_documents" => {
                self.require_running()?;
                let close_unsaved =
                    args.get("closeUnsaved").and_then(|v| v.as_bool()).unwrap_or(false);
                let before: Vec<Value> =
                    self.docs.iter().map(|d| self.doc_info(&d.path)).collect();
                let would_prompt: Vec<Value> =
                    self.docs.iter().filter(|d| d.dirty).map(|d| self.doc_info(&d.path)).collect();
                if close_unsaved {
                    self.docs.clear();
                } else {
                    self.docs.retain(|d| d.dirty);
                }
                self.active = self.docs.first().map(|d| d.path.clone());
                let remaining: Vec<Value> =
                    self.docs.iter().map(|d| self.doc_info(&d.path)).collect();
                Ok(json!({
                    "closedAll": true,
                    "closed": before,
                    "remaining": remaining,
                    "wouldPrompt": would_prompt,
                    "closeUnsaved": close_unsaved,
                }))
            }

            "document_info" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                let info = self.doc_info(&path);
                let d = self.find(&path).expect("resolved");
                Ok(json!({
                    "name": d.title,
                    "productType": d.doc_type,
                    "path": info.get("path").cloned().unwrap_or(Value::Null),
                    "isSaved": d.saved,
                    "dirty": d.dirty,
                    "configuration": d.config,
                    "configurations": ["Default"],
                    "customPropertyCount": d.props.len(),
                    "needsRebuild": 0,
                    "doc": info,
                }))
            }

            "design_info" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                let info = self.doc_info(&path);
                let d = self.find(&path).expect("resolved");
                Ok(json!({
                    "doc": info,
                    "features": 4,
                    "sketches": if d.bodies.is_empty() { 0 } else { 1 },
                    "bodies": d.bodies.len(),
                    "components": if d.doc_type == "assembly" { 3 } else { 0 },
                    "featureErrors": 0,
                    "configurations": ["Default"],
                    "material": d.material.as_ref().map(|m| json!({"name": m, "database": "SOLIDWORKS Materials.sldmat"})).unwrap_or(Value::Null),
                    "units": {"linearRaw": 0, "linear": "mm", "systemRaw": 2, "note": "mock"},
                    "needsRebuild": 0,
                }))
            }

            "get_parameters" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                let info = self.doc_info(&path);
                let d = self.find(&path).expect("resolved");
                let params: Vec<Value> = d
                    .dimensions
                    .iter()
                    .map(|(name, v)| {
                        let m = v.as_f64().unwrap_or(0.0);
                        json!({
                            "name": name,
                            "expression": Value::Null,
                            "value": m,
                            "valueMm": (m * 1000.0 * 1000.0).round() / 1000.0,
                            "unit": "m",
                            "source": "dimension",
                        })
                    })
                    .collect();
                Ok(json!({
                    "doc": info,
                    "parameters": params,
                    "count": params.len(),
                    "featuresWalked": 4,
                    "truncated": false,
                }))
            }

            "set_parameter" => {
                self.require_running()?;
                let name = args
                    .get("name")
                    .and_then(|v| v.as_str())
                    .filter(|s| !s.is_empty())
                    .ok_or_else(|| HostError::msg("name is required, e.g. \"D1@Boss-Extrude1\""))?
                    .to_string();
                let (value, unit) = match (
                    args.get("valueMm").and_then(|v| v.as_f64()),
                    args.get("value").and_then(|v| v.as_f64()),
                ) {
                    (Some(mm), _) => (mm * 0.001, "mm"),
                    (None, Some(m)) => (m, "m"),
                    _ => return Err(HostError::msg("value (metres) or valueMm (millimetres) is required")),
                };
                if !value.is_finite() {
                    return Err(HostError::msg("value must be a finite number"));
                }
                // `name` here is the DIMENSION, not the document. The document
                // comes from the server's authorized `docName`/`path`.
                let path = self.resolve(&json!({
                    "docName": args.get("docName").cloned(),
                    "path": args.get("path").cloned(),
                }))?;
                let before = self
                    .find(&path)
                    .and_then(|d| d.dimensions.get(&name).and_then(|v| v.as_f64()));
                if before.is_none() {
                    return Err(HostError::msg(format!(
                        "no dimension named '{name}' — swx_get_parameters lists what this \
                         document has (dimension names look like D1@Boss-Extrude1)"
                    )));
                }
                if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {
                    d.dimensions.insert(name.clone(), json!(value));
                    d.dirty = true;
                }
                Ok(json!({
                    "doc": self.doc_info(&path),
                    "name": name,
                    "requested": value,
                    "unitGiven": unit,
                    "before": before,
                    "value": value,
                    "valueMm": (value * 1000.0 * 1000.0).round() / 1000.0,
                    "rebuilt": true,
                }))
            }

            "inspect_bodies" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                if self.find(&path).map(|d| d.doc_type) != Some("part") {
                    return Err(HostError::msg(
                        "inspect_bodies needs a part (.sldprt); for an assembly use swx_bom or \
                         swx_mass_properties",
                    ));
                }
                let info = self.doc_info(&path);
                let filter = args.get("nameContains").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
                // MASS = VOLUME x THE MATERIAL'S DENSITY (issue #2): the same
                // density swx_mass_properties reports, so the two verbs agree.
                let density = self.find(&path).map(doc_density).unwrap_or(1000.0);
                let unreadable = fault("mock-density-unreadable");
                let bodies: Vec<Value> = self
                    .find(&path)
                    .map(|d| d.bodies.clone())
                    .unwrap_or_default()
                    .into_iter()
                    .map(|mut b| {
                        let vol = b.get("volumeMm3").and_then(|v| v.as_f64()).unwrap_or(0.0);
                        if unreadable {
                            b["massKg"] = Value::Null;
                            b["densityKgM3"] = Value::Null;
                            b["densitySource"] = json!("unreadable");
                        } else {
                            b["massKg"] = json!(vol * 1.0e-9 * density);
                            b["densityKgM3"] = json!(density);
                            b["densitySource"] = json!("bodyMassProperty");
                        }
                        b
                    })
                    .filter(|b| {
                        filter.is_empty()
                            || b.get("name")
                                .and_then(|n| n.as_str())
                                .map(|n| n.to_lowercase().contains(&filter))
                                .unwrap_or(false)
                    })
                    .collect();
                Ok(json!({
                    "documentName": info.get("title").cloned().unwrap_or(Value::Null),
                    "doc": info,
                    "units": "mm",
                    "bodyCount": bodies.len(),
                    "bodies": bodies,
                }))
            }

            "import" => {
                self.running = true;
                let path = args
                    .get("filePath")
                    .or_else(|| args.get("path"))
                    .and_then(|v| v.as_str())
                    .filter(|s| !s.is_empty())
                    .ok_or_else(|| HostError::msg("filePath is required"))?
                    .to_string();
                // EVERY STEP IMPORT FAILING, with the translator's own answer.
                // Live, 2026-09-17: LoadFile4 raised a COM exception on every
                // STEP and the reply carried only "SOLIDWORKS rejected
                // 'LoadFile4'" — the HRESULT and EXCEPINFO were dropped between
                // the COM layer and the import verb. This fault models the
                // failure INCLUDING the structured remains, so the wiring that
                // carries them to the caller is tested rather than assumed.
                // THE USER'S 3D INTERCONNECT SETTING, modelled because it decides
                // WHICH ROUTE the import takes — and because the live install has
                // it ON, which is the default. Measured 2026-09-17: toggle 691 is
                // TRUE on Drew's machine, so the simulator's default matches the
                // machine this bridge actually runs on, and the translator route
                // is what a fault turns on.
                // F3 — THREE STATES, NOT TWO. `None` is "SOLIDWORKS would not
                // answer"; `Some(false)` is "the user turned it off". They take
                // the same route and mean opposite things about how much to
                // trust a failure.
                let readable = !fault("mock-interconnect-toggle-unreadable");
                let interconnect = readable && !fault("mock-3d-interconnect-off");
                // ── THE MEASUREMENT SURFACE, ECHOED AND NOT INTERPRETED.
                //
                // The simulator has no SOLIDWORKS to ask, so it makes NO claim
                // about what any of these produce — that is the entire point of
                // the exercise. It validates the route name (a typo that quietly
                // measures something else is worse than no measurement) and
                // reports the invocation back, so the shape of a matrix row is
                // testable here and only the ANSWERS have to come from the live
                // application.
                let route = args
                    .get("route")
                    .and_then(|v| v.as_str())
                    .unwrap_or("auto")
                    .to_ascii_lowercase();
                if !matches!(route.as_str(), "auto" | "loadfile4" | "loadfile2" | "opendoc6") {
                    return Err(HostError::coded(
                        "unknown_import_route",
                        format!(
                            "unknown import route {route:?} — expected \"auto\", \"loadfile4\", \
                             \"loadfile2\" or \"opendoc6\""
                        ),
                    ));
                }
                let open_options = args.get("openOptions").and_then(|v| v.as_i64()).unwrap_or(1);
                let doc_type_arg = args.get("docType").and_then(|v| v.as_i64()).unwrap_or(1);
                let arg_string =
                    args.get("argString").and_then(|v| v.as_str()).unwrap_or("r").to_string();
                let with_data = args.get("withImportData").and_then(|v| v.as_bool()).unwrap_or(false);
                // THE MEASURED DEAD END, modelled: with 3D Interconnect on, the
                // `auto` route makes one LoadFile4 attempt and then refuses by
                // NAMING THE SETTING. The simulator's default has the toggle on,
                // matching the live machine, so this fault is what turns the
                // measured refusal on — the simulator has no SOLIDWORKS to fail
                // for it.
                if route == "auto" && !readable && fault("mock-interconnect-blocks-import") {
                    let mut e = HostError::coded(
                        "interconnect_setting_unreadable",
                        format!(
                            "SOLIDWORKS could not import {path}, and this bridge could not read \
                             whether 3D Interconnect is on. With that setting ON no programmatic \
                             import route works on this edition, so the most likely cause cannot \
                             be confirmed or ruled out. Nothing was opened and nothing was changed."
                        ),
                    );
                    e.detail = Some(
                        "Check Tools > Options > System Options > Import > \"Enable 3D \
                         Interconnect\". This bridge does not change user settings."
                            .into(),
                    );
                    e.data = Some(json!({
                        "interconnect": Value::Null,
                        "interconnectSettingReadable": false,
                        "path": path,
                    }));
                    return Err(e);
                }
                if route == "auto" && interconnect && fault("mock-interconnect-blocks-import") {
                    let mut e = HostError::coded(
                        "interconnect_blocks_import",
                        format!(
                            "SOLIDWORKS cannot import {path} while 3D Interconnect is on. \
                             Measured on this edition 2026-09-17: no programmatic route imports a \
                             neutral file with the toggle TRUE. Nothing was opened and nothing was \
                             changed."
                        ),
                    );
                    e.detail = Some(
                        "ASK THE USER to turn 3D Interconnect off if they want this import (it \
                         lives at Tools > Options > System Options > Import > \"Enable 3D \
                         Interconnect\"), then try again. THIS BRIDGE DOES NOT CHANGE USER \
                         SETTINGS, not even temporarily."
                            .into(),
                    );
                    e.data = Some(json!({
                        "interconnect": true,
                        "userSettingPath": "Tools > Options > System Options > Import > Enable 3D Interconnect",
                        "path": path,
                    }));
                    return Err(e);
                }
                let effective = if route == "auto" {
                    // `auto` attempts the TRANSLATOR even under 3D Interconnect:
                    // OpenDoc6 is a measured failure there, so preferring it
                    // would be preferring a known dead end.
                    "loadfile4"
                } else {
                    route.as_str()
                };
                let invocation = match effective {
                    "opendoc6" => json!({
                        "method": "OpenDoc6",
                        "args": {
                            "path": path, "docType": doc_type_arg, "options": open_options,
                            "configuration": "", "errors": "byref i4", "warnings": "byref i4",
                        },
                    }),
                    "loadfile2" => json!({"method": "LoadFile2", "args": {"path": path, "arg": ""}}),
                    _ => json!({
                        "method": "LoadFile4",
                        "args": {
                            "path": path, "argString": arg_string,
                            "importData": if with_data {
                                "GetImportFileData(path)"
                            } else {
                                "Nothing (null VT_DISPATCH)"
                            },
                            "errors": "byref i4",
                        },
                        // The simulator has no import-data object to hand back and
                        // says so rather than inventing one.
                        "importFileData": if with_data {
                            json!({"returnedObject": Value::Null, "simulated": true})
                        } else {
                            Value::Null
                        },
                    }),
                };
                // F2 — REFUSED BEFORE ANY ROUTE IS CHOSEN. Inside the
                // interconnect branch it was silently dropped whenever the
                // toggle was off or unreadable: the caller asked for an unlinked
                // import and got no answer either way.
                if fault("mock-dissolve-requested-without-measurement")
                    || args.get("dissolve").and_then(|v| v.as_bool()).unwrap_or(false)
                {
                    let mut e = HostError::coded(
                        "dissolve_not_measured",
                        "this bridge will not break a 3D Interconnect link with an API it has not \
                         verified on this install. The import itself went ahead as a LINKED part \
                         (that is what 3D Interconnect does); nothing was changed.",
                    );
                    e.detail = Some(
                        "measure which call breaks the link on this edition and verify the EFFECT, \
                         not the return value"
                            .into(),
                    );
                    return Err(e);
                }
                // LOADFILE4 UNDER 3D INTERCONNECT: it executes and returns
                // nothing, with errors=1 (swGenericError). Measured live
                // 2026-09-17 after 0.7.9 fixed the variant encoding. A bridge
                // that routed to the translator with the toggle ON would produce
                // exactly this, so the simulator can produce it too.
                if interconnect && fault("mock-import-translator-under-interconnect") {
                    return Err(HostError::detailed(
                        format!(
                            "SOLIDWORKS could not import {path} (LoadFile4 opened nothing, \
                             errors=1; LoadFile2 refused). Importable formats depend on the \
                             installed translators."
                        ),
                        "LoadFile4 returned no document; errors=1 (swGenericError)",
                    )
                    .with_data(json!({
                        "interconnect": true,
                        "loadFile4": {"message": "LoadFile4 returned no document", "detail": Value::Null, "code": Value::Null},
                        "loadFile2": {"returned": false, "failure": Value::Null},
                        "errors": 1,
                        // WHAT THE COUNT DID — the one number that says whether a
                        // document was left behind, and the fact ownership turns
                        // on. It was in prose on one path and nowhere on the other.
                        // THE SAME NAMES THE SUCCESS REPLY USES for the same
                        // facts, so a caller reads one vocabulary either way.
                        "documentCountBefore": self.docs.len() as i32,
                        "documentCount": self.docs.len() as i32,
                        "documentsOpened": 0,
                        "documentAppeared": false,
                        "path": path,
                    })));
                }
                // THE LIVE FAILURE OF 2026-09-17, exactly as it came back: a type
                // mismatch on `importData`, which was being passed as VT_EMPTY
                // where SOLIDWORKS wanted a NULL DISPATCH.
                //
                // THE SIMULATOR CANNOT CHECK THE ENCODING. It has no VARIANTs —
                // it is Rust data structures — so no fault here can tell
                // `Var::empty()` from `Var::null_dispatch()`, and a test that
                // claimed to would be theatre. What this fault keeps honest is
                // the REPORTING path: the HRESULT and argument index reaching the
                // caller, which is what made the diagnosis possible at all. The
                // encoding itself is pinned where it exists — the Windows-only
                // unit test in variant.rs — and in tests/wiring.rs, which reads
                // the call site.
                if fault("mock-import-typemismatch") {
                    return Err(HostError::detailed(
                        format!(
                            "SOLIDWORKS could not import {path} (LoadFile4 opened nothing, \
                             errors=0; LoadFile2 refused). Importable formats depend on the \
                             installed translators."
                        ),
                        "SOLIDWORKS rejected 'LoadFile4': Type mismatch. (0x80020005) \
                         (argument index 1)",
                    )
                    .with_data(json!({
                        "loadFile4": {
                            "message": "SOLIDWORKS rejected 'LoadFile4'",
                            "detail": "Type mismatch. (0x80020005) (argument index 1)",
                            "code": Value::Null,
                            "hresult": "0x80020005",
                            "argumentIndex": 1,
                        },
                        "loadFile2": {"returned": false, "failure": Value::Null},
                        "errors": 0,
                        "path": path,
                    })));
                }
                if fault("mock-import-load4-throws") {
                    return Err(HostError::detailed(
                        format!(
                            "SOLIDWORKS could not import {path} (LoadFile4 opened nothing, \
                             errors=0; LoadFile2 refused). Importable formats depend on the \
                             installed translators."
                        ),
                        "SOLIDWORKS rejected 'LoadFile4': 0x80004005 [SldWorks] (argument index 0)",
                    )
                    .with_data(json!({
                        "loadFile4": {
                            "message": "SOLIDWORKS rejected 'LoadFile4'",
                            "detail": "0x80004005 [SldWorks]: translator not available \
                                       (argument index 0)",
                            "code": Value::Null,
                            "hresult": "0x80004005",
                        },
                        "loadFile2": {"returned": false, "failure": Value::Null},
                        "errors": 0,
                        "path": path,
                    })));
                }
                self.untitled += 1;
                // SOLIDWORKS names an imported document after the file's STEM
                // with a PART extension: importing `smoke.step` yields a
                // document titled `smoke.SLDPRT`. The mock used the bare stem,
                // so it could not express the title collision the live run hit
                // when the suite imported a STEP beside its own saved part.
                let before = self.docs.len() as i32;
                let title = format!("{}.SLDPRT", file_stem(&path));
                // A UNIQUE key per import (SOLIDWORKS keeps every leaked temp as
                // its own document), and a synthetic IC~~ temp path so the drain
                // verb and the cap have something faithful to match — this is
                // how the 1342-temp leak is modelled.
                let n = self.untitled;
                let key = format!("untitled:import:{n}:{title}");
                // LIVE, 2026-09-11: a fresh `LoadFile4` import has NO PATH AT
                // ALL. The `IC~~` conversion paths belonged to the older route
                // that produced the 1342 leaked temps. `mock-import-pathless`
                // models the live behaviour so the identity-at-birth step, and
                // the drain's ownership-based rule, have the real shape to work
                // against.
                // BUG C's shape: the document IS created and the call then
                // times out. The server must reconcile on the respawned host
                // rather than leaving a document nobody owns.
                if fault("mock-import-timeout-after-open") {
                    // A SECOND document appearing in the same window — the user
                    // opening something of their own while our call hangs. The
                    // session then gains two, and only one of them is ours.
                    if fault("mock-open-extra-doc") {
                        let mut theirs = MockDoc::opened(
                            "C:\\users\\drew\\theirs.SLDPRT".into(),
                            "part",
                            "Default".into(),
                        );
                        theirs.title = "theirs.SLDPRT".into();
                        theirs.saved = true;
                        self.docs.push(theirs);
                    }
                    let title = format!("{}.SLDPRT", file_stem(&path));
                    let n = self.untitled;
                    let key = format!("untitled:import:{n}:{title}");
                    let mut doc = MockDoc::opened(key.clone(), "part", "Default".into());
                    doc.title = title;
                    doc.saved = false;
                    doc.import_temp = true;
                    doc.temp_path = None;
                    self.docs.push(doc);
                    self.active = Some(key.clone());
                    self.publish_session();
                    // IT MADE THE DOCUMENT. The error carries it, so the host
                    // owns what it made — exactly as the real backend now does by
                    // registering at birth rather than on the way out.
                    let info = self.doc_info(&key);
                    return Err(HostError::timeout(
                        "com host did not answer 'import' within 280s (the file was already                          loaded when it stopped answering)",
                    )
                    .with_created(info));
                }
                if fault("mock-import-pathless") {
                    let title = format!("{}.SLDPRT", file_stem(&path));
                    let n = self.untitled;
                    let key = format!("untitled:import:{n}:{title}");
                    let mut doc = MockDoc::opened(key.clone(), "part", "Default".into());
                    doc.title = title;
                    doc.saved = false;
                    doc.import_temp = true;
                    doc.temp_path = None;
                    doc.bodies.push(mock_body("Imported1", 50.0, 30.0, 20.0));
                    self.docs.push(doc);
                    self.active = Some(key.clone());
                    let after = self.docs.len() as i32;
                    return Ok(json!({
                        "imported": {"title": self.docs.last().map(|d| d.title.clone()),
                                     "path": "", "type": "part"},
                        "documentCountBefore": before,
                        "documentCountAfter": after,
                        "net": after - before,
                        "netCountOk": after - before == 1,
                    }));
                }
                // F1 — THE INTERCONNECT ROUTE PRODUCES A PATHLESS DOCUMENT, as
                // the real one does. Giving it a `…\Temp\swx<pid>\IC~~\…` path
                // meant every drain and sweep test over this route exercised
                // FIND-BY-TEMP-PATH — the branch that cannot run on Drew's
                // machine — while the branch the route's own comment calls
                // load-bearing, registration at birth, was never the thing under
                // test. A simulator that makes the hard case easy is a simulator
                // that agrees with the bug.
                let mut doc = MockDoc::opened(key.clone(), "part", "Default".into());
                doc.title = title;
                doc.saved = false;
                if effective == "opendoc6" {
                    doc.import_temp = false;
                    doc.temp_path = None;
                } else {
                    doc.import_temp = true;
                    doc.temp_path = Some(format!(
                        "{MOCK_TEMP_ROOT}\\swx4242\\IC~~\\{}-{n}.SLDPRT",
                        file_stem(&path)
                    ));
                }
                doc.bodies.push(mock_body("Imported1", 50.0, 30.0, 20.0));
                self.docs.push(doc);
                self.active = Some(key.clone());
                let after = self.docs.len() as i32;
                let net = after - before;
                // THE SAME TWO ROUTES THE REAL BACKEND TAKES, and the same
                // reporting — including `linked`, which is the fact a caller most
                // needs and cannot see from the document itself: an interconnect
                // part is NOT self-contained.
                Ok(json!({
                    "imported": self.doc_info(&key),
                    "via": match effective {
                        "opendoc6" => "OpenDoc6 (3D Interconnect)",
                        "loadfile2" => "LoadFile2",
                        _ => "LoadFile4",
                    },
                    "invocation": invocation,
                    "route": route,
                    "interconnect": if readable { json!(interconnect) } else { Value::Null },
                    "interconnectSettingReadable": readable,
                    "linked": effective == "opendoc6",
                    "sourcePath": if effective == "opendoc6" { json!(path) } else { Value::Null },
                    "errors": 0,
                    "documentsOpened": net,
                    "netCountOk": net == 1,
                    "documentCount": after,
                    "_hint": if effective == "opendoc6" {
                        "Imported by OPENING it as a part holding a LINKED 3D Interconnect feature \
                         — the user's SOLIDWORKS has 3D Interconnect ON, which this bridge reads \
                         and never changes. The part is NOT self-contained: moving or deleting the \
                         source file changes it. It is one new open, unsaved document; \
                         swx_close_import_temps drains any left behind."
                    } else {
                        "Imported. This left ONE new open, unsaved document (like \
                         swx_new_document) — it stays open and holds memory until you save or \
                         close it. Importing repeatedly without closing is what leaks documents; \
                         swx_close_import_temps drains any left behind."
                    },
                }))
            }

            "save_as" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                let out = args
                    .get("outputPath")
                    .or_else(|| args.get("out"))
                    .and_then(|v| v.as_str())
                    .filter(|s| !s.is_empty())
                    .ok_or_else(|| HostError::msg("outputPath is required"))?
                    .to_string();
                let copy = args.get("copy").and_then(|v| v.as_bool()).unwrap_or(false);
                if let Some(p) = std::path::Path::new(&out).parent() {
                    if !p.as_os_str().is_empty() {
                        let _ = std::fs::create_dir_all(p);
                    }
                }
                std::fs::write(&out, "mock save-as\n")
                    .map_err(|e| HostError::msg(format!("cannot write {out}: {e}")))?;
                // Remember what the FILE carries. A copy-mode save leaves the
                // document unpathed, so this is the only record of the
                // properties that went to disk — and a BOM reads files.
                if let Some(d) = self.find(&path) {
                    self.files.insert(out.to_lowercase(), d.props.clone());
                }
                let bytes = std::fs::metadata(&out).map(|m| m.len()).ok();
                // SOLIDWORKS UPPERCASES the extension of the path it reports:
                // ask it to save `smoke.sldprt` and `GetPathName` comes back
                // `smoke.SLDPRT`. The mock did not, so an exact string compare
                // between the requested and reported paths looked correct here
                // and disowned the bridge's own document on the real machine.
                let out = match out.rsplit_once('.') {
                    Some((stem, ext)) if !ext.is_empty() && !ext.contains(['/', '\\']) => {
                        format!("{stem}.{}", ext.to_uppercase())
                    }
                    _ => out.clone(),
                };
                if !copy {
                    // A real save-as RETITLES the open document; copy mode does not.
                    if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {
                        d.title = shown_title(&out);
                        d.saved = true;
                        d.dirty = false;
                    }
                    let old = path.clone();
                    if let Some(d) = self.docs.iter_mut().find(|d| d.path == old) {
                        d.path = out.clone();
                    }
                    if self.active.as_deref() == Some(old.as_str()) {
                        self.active = Some(out.clone());
                    }
                    // Ownership follows the document through the rename.
                    self.created.retain(|(_, p)| {
                        !p.as_deref().map(|q| crate::verbs::same_path(q, &old)).unwrap_or(false)
                    });
                    self.remember_created(&out);
                    return Ok(json!({
                        "savedAs": out, "copy": copy, "bytes": bytes, "doc": self.doc_info(&out)
                    }));
                }
                Ok(json!({"savedAs": out, "copy": copy, "bytes": bytes, "doc": self.doc_info(&path)}))
            }

            "show" => {
                self.require_running()?;
                let path = self.resolve(args)?;
                self.active = Some(path.clone());
                let info = self.doc_info(&path);
                let visibility = self.find(&path).map(|d| Self::make_visible_report(d.visible));
                Ok(json!({
                    "document": info.get("title").cloned().unwrap_or(Value::Null),
                    "doc": info,
                    "activated": args.get("activate").and_then(|v| v.as_bool()).unwrap_or(true),
                    "zoomedToFit": true,
                    "activateErrors": 0,
                    "visibility": visibility,
                }))
            }

            // NOTE ON COST: `app_state` reads the COUNT first and enumerates only
            // under the cap — so it is charged inside that branch, below.
            // HAS SOLIDWORKS FINISHED STARTING? The simulator says yes unless told
            // otherwise: `mock-startup-incomplete` (still starting) or
            // `mock-startup-unreadable` (SOLIDWORKS will not say).
            // ── swx_render_image (0.9.18): the simulated viewport. The DISPLAY
            //    STATE lives in the data root (`mock-display-state.json`) so a
            //    test can read it before and after: whatever the render applies
            //    is restored on every path, the error path included.
            "render_image" => {
                self.require_running()?;
                let state_path = crate::scratch::root().join("mock-display-state.json");
                let default_state = json!({
                    "displayMode": 5, "perspective": false, "realView": false, "shadows": false,
                    "ambientOcclusion": false, "backgroundAppearance": 3, "backgroundColor": 13421772,
                    "orientation": "user-view", "scale": 1.0
                });
                let before: Value = std::fs::read_to_string(&state_path)
                    .ok()
                    .and_then(|t| serde_json::from_str(&t).ok())
                    .unwrap_or(default_state);
                if fault("mock-render-enum-unresolved")
                    && ["shadows", "ambientOcclusion"].iter().any(|k| args.get(*k).map(|v| !v.is_null()).unwrap_or(false))
                {
                    return Err(HostError::coded(
                        "render_enum_unresolved",
                        "the swconst ids for the shadow / ambient-occlusion toggles could not be read from the installed SolidWorks.Interop.swconst.dll (simulated)",
                    ));
                }
                let mut now = before.clone();
                let mut applied = serde_json::Map::new();
                // `mock-render-unreadable-<key>`: that setting's read fails (the
                // measured `shadows` gap: requested, absent from applied).
                let unreadable = |k: &str| fault(&format!("mock-render-unreadable-{k}"));
                // `mock-render-ignores-<key>`: the set "succeeds" and SOLIDWORKS
                // leaves the value as it was (MEASURED 2026-10-05: RealView).
                let ignored = |k: &str| fault(&format!("mock-render-ignores-{k}"));
                let mut set = |k: &str, v: Value, applied: &mut serde_json::Map<String, Value>| {
                    if unreadable(k) || ignored(k) {
                        return;
                    }
                    if now.get(k) != Some(&v) {
                        applied.insert(k.into(), json!({"from": now.get(k).cloned(), "to": v}));
                        now[k] = v;
                    }
                };
                if let Some(id) = args.get("viewId").and_then(|v| v.as_i64()) {
                    set("orientation", json!(format!("standard-view-{id}")), &mut applied);
                }
                if let Some(m) = args.get("displayModeId").and_then(|v| v.as_i64()) {
                    set("displayMode", json!(m), &mut applied);
                }
                for k in ["perspective", "realView", "shadows", "ambientOcclusion"] {
                    if let Some(b) = args.get(k).and_then(|v| v.as_bool()) {
                        set(k, json!(b), &mut applied);
                    }
                }
                if let Some(c) = args.get("backgroundColor").and_then(|v| v.as_i64()) {
                    set("backgroundAppearance", json!(0), &mut applied);
                    set("backgroundColor", json!(c), &mut applied);
                }
                if args.get("zoomToFit").and_then(|v| v.as_bool()).unwrap_or(false) {
                    set("scale", json!(0.42), &mut applied);
                }
                drop(set);
                if let Some(z) = args.get("zoomFactor").and_then(|v| v.as_f64()).filter(|z| (*z - 1.0).abs() > 1e-9) {
                    if !unreadable("scale") {
                        let base = now.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0);
                        applied.insert("scale".into(), json!({"from": now.get("scale").cloned(), "to": base * z}));
                        now["scale"] = json!(base * z);
                    }
                }
                let after_apply = now.clone();
                let _ = std::fs::write(&state_path, now.to_string());
                // THE EXPORT: a 24-bit BMP of the asked size — or the window's
                // when `mock-render-capped` says SaveBMP ignored the size.
                let (mut w, mut h) = (
                    args.get("width").and_then(|v| v.as_u64()).unwrap_or(1920) as u32,
                    args.get("height").and_then(|v| v.as_u64()).unwrap_or(1080) as u32,
                );
                if fault("mock-render-capped") {
                    w = w.min(1568);
                    h = h.min(882);
                }
                let saved = !fault("mock-render-fails") && {
                    let bmp_path = args.get("bmpPath").and_then(|v| v.as_str()).unwrap_or("");
                    let stride = ((w * 3 + 3) & !3) as usize;
                    let mut b = vec![0u8; 54 + stride * h as usize];
                    b[0..2].copy_from_slice(b"BM");
                    b[10..14].copy_from_slice(&54u32.to_le_bytes());
                    b[14..18].copy_from_slice(&40u32.to_le_bytes());
                    b[18..22].copy_from_slice(&(w as i32).to_le_bytes());
                    b[22..26].copy_from_slice(&(h as i32).to_le_bytes());
                    b[26..28].copy_from_slice(&1u16.to_le_bytes());
                    b[28..30].copy_from_slice(&24u16.to_le_bytes());
                    for y in 0..h as usize {
                        for x in 0..w as usize {
                            let o = 54 + y * stride + x * 3;
                            b[o..o + 3].copy_from_slice(&[(x % 256) as u8, (y % 256) as u8, 128]);
                        }
                    }
                    std::fs::write(bmp_path, &b).is_ok()
                };
                // RESTORE — on every path, then read back.
                let restore = !fault("mock-render-skips-restore");
                if restore {
                    let _ = std::fs::write(&state_path, before.to_string());
                }
                let after: Value = std::fs::read_to_string(&state_path)
                    .ok()
                    .and_then(|t| serde_json::from_str(&t).ok())
                    .unwrap_or(Value::Null);
                let mut restored = serde_json::Map::new();
                let mut failures = Vec::new();
                for (k, _) in applied.iter() {
                    let ok = after.get(k) == before.get(k);
                    restored.insert(k.clone(), json!({"value": before.get(k), "readBack": after.get(k), "restored": ok}));
                    if !ok {
                        failures.push(json!(k));
                    }
                }
                // EVERY REQUESTED SETTING, accounted for (0.9.19): what was asked,
                // what it was, what it was after applying, whether it changed,
                // and whether it read back restored — so "already as asked" is
                // never confused with "not applied".
                let mut settings = serde_json::Map::new();
                let account = |name: &str, key: &str, requested: Value, settings: &mut serde_json::Map<String, Value>| {
                    let b = if unreadable(key) { Value::Null } else { before.get(key).cloned().unwrap_or(Value::Null) };
                    let a = if unreadable(key) { Value::Null } else { after_apply.get(key).cloned().unwrap_or(Value::Null) };
                    let changed = !b.is_null() && a != b;
                    let why = if b.is_null() {
                        "unreadable"
                    } else if changed {
                        "applied"
                    } else if requested.is_boolean() && a != requested {
                        "setIgnored"
                    } else {
                        "alreadyAsRequested"
                    };
                    let restored_now = if changed { json!(after.get(key) == before.get(key)) } else { Value::Null };
                    settings.insert(name.into(), json!({"requested": requested, "before": b, "after": a,
                                                        "changed": changed, "restored": restored_now, "why": why}));
                };
                if let Some(v) = args.get("view").and_then(|v| v.as_str()).filter(|v| *v != "current") {
                    account("view", "orientation", json!(v), &mut settings);
                }
                if let Some(v) = args.get("displayStyle").filter(|v| !v.is_null()) {
                    account("displayStyle", "displayMode", v.clone(), &mut settings);
                }
                for k in ["perspective", "realView", "shadows", "ambientOcclusion"] {
                    if let Some(v) = args.get(k).filter(|v| !v.is_null()) {
                        account(k, k, v.clone(), &mut settings);
                    }
                }
                if let Some(v) = args.get("background").and_then(|v| v.as_str()).filter(|v| *v != "current") {
                    account("background", "backgroundColor", json!(v), &mut settings);
                }
                if args.get("zoomToFit").and_then(|v| v.as_bool()).unwrap_or(false) {
                    account("zoomToFit", "scale", json!(true), &mut settings);
                }
                if let Some(z) = args.get("zoomFactor").and_then(|v| v.as_f64()).filter(|z| (*z - 1.0).abs() > 1e-9) {
                    account("zoomFactor", "scale", json!(z), &mut settings);
                }
                let data = json!({"appliedSettings": applied, "restoredSettings": restored, "restoreFailures": failures,
                                  "settings": settings,
                                  "enumIds": {"swDisplayShadowsInShadedMode": 9001, "swDisplayAmbientOcclusionShadows": 9002,
                                              "swColorsBackgroundAppearance": 9003, "swSystemColorsViewportBackground": 9004,
                                              "simulated": true}});
                if !saved {
                    return Err(HostError::coded("render_failed", "SaveBMP returned false (simulated)").with_data(data));
                }
                let mut out = data;
                out["document"] = json!(self.active.clone().map(|p| base_name(&p).to_string()));
                out["saveBmp"] = json!(true);
                Ok(out)
            }

            "startup_state" => {
                self.publish_session();
                Ok(json!({
                    "running": self.running,
                    "startupCompleted": if !self.running || fault("mock-startup-unreadable") {
                        Value::Null
                    } else {
                        json!(!fault("mock-startup-incomplete"))
                    },
                    // The session, NOT `mock_com_pid()`: that is a scripted
                    // per-read sequence the quit tests count read by read, and the
                    // readiness gate must not consume a read of it.
                    "processId": if self.running { json!(self.session()) } else { Value::Null },
                    // The poll that records a session as having held a document
                    // (session_history), exactly as com.rs reports it.
                    "documentCount": if self.running && !fault("mock-doccount-none") {
                        // A PHANTOM COUNT — measured live 2026-10-01 (pid 29108): a
                        // session nobody ever opened a document in was recorded as
                        // having held one. Two shapes: a count of 1 while the start
                        // is incomplete, and a count of 1 on ONE read only.
                        let phantom_starting =
                            fault("mock-phantom-doc-while-starting") && fault("mock-startup-incomplete");
                        // `mock-phantom-doc-once` may hold a number: that many reads
                        // (default 1). Deleting `.used` re-arms it.
                        let root = crate::scratch::root();
                        let once = root.join("mock-phantom-doc-once.used");
                        let reads: u64 = std::fs::read_to_string(root.join("mock-phantom-doc-once"))
                            .ok()
                            .and_then(|t| t.trim().parse().ok())
                            .unwrap_or(1);
                        let used: u64 = std::fs::read_to_string(&once)
                            .ok()
                            .and_then(|t| t.trim().parse().ok())
                            .unwrap_or(0);
                        let phantom_once = fault("mock-phantom-doc-once") && used < reads;
                        if phantom_once {
                            let _ = std::fs::write(&once, (used + 1).to_string());
                        }
                        json!(self.docs.len() + usize::from(phantom_starting || phantom_once))
                    } else {
                        Value::Null
                    },
                    // The simulated process's creation time, for a host with no
                    // process census (off Windows, no launch simulator): stable,
                    // written once — an hour ago unless a test says otherwise.
                    "processCreatedAt": if self.running { json!(mock_created_at()) } else { Value::Null },
                }))
            }

            "app_state" => {
                // THE USER OPENS A DOCUMENT behind the bridge's back (0.9.9): the
                // presence guard must see a document the bridge did not open.
                if fault("mock-user-opens-document") {
                    let path = "C:\\Users\\Public\\adom-crate\\ASM-CRATE.SLDASM".to_string();
                    if self.find_by_name(&path).is_none() {
                        self.docs.push(MockDoc::opened(path, "assembly", "Default".into()));
                    }
                }
                self.publish_session();
                // A com host that cannot answer. The point of the switch is that
                // the SERVER must treat this as "I do not know" and keep its
                // ownership registry — which is bug #5.
                if fault("mock-fail-app-state") {
                    return Err(HostError::msg(
                        "com host did not answer 'app_state' (simulated: SOLIDWORKS is busy)",
                    ));
                }
                // DEGRADE ABOVE THE CAP, exactly as the real backend does: this is
                // read on nearly every verb, so a flooded session must report
                // the count and set documentsTruncated rather than enumerate.
                let count_known = !fault("mock-doccount-none");
                let count = self.docs.len();
                let truncated = !count_known || count > super::host::doc_enum_cap();
                let docs: Vec<Value> = if truncated {
                    Vec::new()
                } else {
                    charge_docs(count);
                    self.docs.iter().map(|d| self.doc_info(&d.path)).collect()
                };
                let active = self.active.as_ref().map(|p| self.doc_info(p));
                Ok(json!({
                    "running": self.running,
                    "version": if self.running { Value::from(mock_version()) } else { Value::Null },
                    "activeDocument": active.clone().map(|a| a.get("title").cloned().unwrap_or(Value::Null)).unwrap_or(Value::Null),
                    "activeDoc": active,
                    "documents": docs,
                    "documentsTruncated": truncated,
                    "documentCount": if count_known { Value::from(count) } else { Value::Null },
                    "unsavedDocuments": self.docs.iter().filter(|d| d.dirty).count(),
                    "workspace": self
                        .active
                        .as_ref()
                        .and_then(|p| self.find(p))
                        .map(|d| d.doc_type)
                        .unwrap_or("none"),
                    "busy": false,
                    "visible": self.running,
                    // THE SAME PER-CALL SEQUENCE `status` uses: this is the read
                    // the quit ladder's precondition sweep makes, so it is the
                    // one that has to be able to change between rungs.
                    "processId": mock_com_pid().unwrap_or_else(|| self.session()),
                    "mainHwnd": if self.running { json!(4242) } else { Value::Null },
                }))
            }

            "check_recovery" => {
                self.require_running()?;
                Ok(json!({
                    "openDocuments": if fault("mock-doccount-unknown") {
                        Value::Null
                    } else {
                        Value::from(self.docs.len())
                    },
                    "comApiHasRecoveryCheck": false,
                    "note": "SOLIDWORKS exposes no COM member for the Document Recovery pane; \
                             the server pairs this with the window census, which is where a \
                             recovery dialog actually shows up.",
                }))
            }

            "cloud_status" => {
                self.require_running()?;
                Ok(json!({
                    "connected": Value::Null,
                    "comApiExposesCloud": false,
                    "probedMembers": {
                        "IsPLMConnected": false,
                        "GetPLMConnection": false,
                        "GetSearchFolders": false,
                        "EnumDocuments2": false,
                    },
                    "licenseType": 8,
                    "version": mock_version(),
                }))
            }

            // The mock is faithful to the CONSTRAINTS, not the VBA: it cannot
            // run a macro, so it models what SOLIDWORKS reports about one. The
            // distinctions that matter to a caller are all here — a missing
            // file, a module/procedure that is not in the project, and a macro
            // that compiles and runs — because `runError` is the entire content
            // of a failed run and a mock that always answered 0 would make
            // every test of that meaningless.
            "run_macro" => {
                self.require_running()?;
                let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
                let module = args.get("module").and_then(|v| v.as_str()).unwrap_or("main");
                let procedure =
                    args.get("procedure").and_then(|v| v.as_str()).unwrap_or("main");
                let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
                if !matches!(ext.as_str(), "swp" | "swb" | "dll") {
                    return Err(HostError::coded(
                        "macro_not_a_macro",
                        format!(
                            "not a SOLIDWORKS macro: .{ext} (want .swp, .swb or .dll)"
                        ),
                    ));
                }
                // A macro whose module or procedure is not in the project is
                // swRunMacroError 1 — a FALSE return with the reason only in
                // the out parameter, which is the shape most easily got wrong.
                let known = module.eq_ignore_ascii_case("main")
                    && (procedure.eq_ignore_ascii_case("main")
                        || procedure.eq_ignore_ascii_case("adomHello"));
                let run_error = if known { 0 } else { 1 };
                Ok(json!({
                    "ran": run_error == 0,
                    "path": path,
                    "module": module,
                    "procedure": procedure,
                    "runError": run_error,
                    "runErrorText": if run_error == 0 {
                        "ok"
                    } else {
                        "the macro or its module/procedure could not be found"
                    },
                    "unloadedAfterRun": true,
                    "provenLive": false,
                }))
            }

            "get_preferences" => {
                self.require_running()?;
                let mut current = Map::new();
                for (name, id) in MOCK_PREFERENCES {
                    let value = self
                        .preferences
                        .get(*name)
                        .and_then(|v| v.as_i64())
                        .unwrap_or(0);
                    current.insert(
                        (*name).to_string(),
                        json!({"id": id, "value": value, "scope": "document"}),
                    );
                }
                current.insert("lengthUnit".into(), json!("mm"));
                Ok(json!({
                    "current": Value::Object(current),
                    "documentScoped": !self.docs.is_empty(),
                    "app": {"visible": self.running, "licenseType": 8, "version": mock_version()},
                }))
            }

            "set_preference" => {
                self.require_running()?;
                let name = args
                    .get("name")
                    .and_then(|v| v.as_str())
                    .ok_or_else(|| HostError::msg("name is required"))?
                    .to_string();
                let id = MOCK_PREFERENCES
                    .iter()
                    .find(|(n, _)| *n == name)
                    .map(|(_, id)| *id)
                    .ok_or_else(|| {
                        HostError::msg(format!(
                            "unknown preference '{name}' — this bridge only exposes preferences \
                             it can round-trip: {}",
                            MOCK_PREFERENCES.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
                        ))
                    })?;
                let value = args
                    .get("value")
                    .and_then(|v| v.as_i64())
                    .ok_or_else(|| HostError::msg("value (an integer) is required"))?;
                let before = self.preferences.get(&name).and_then(|v| v.as_i64()).unwrap_or(0);
                self.preferences.insert(name.clone(), json!(value));
                Ok(json!({
                    "applied": {name.clone(): value},
                    "name": name,
                    "id": id,
                    "requested": value,
                    "before": before,
                    "value": value,
                    "verified": true,
                }))
            }

            "add_component" => {
                self.require_running()?;
                // AN UNCOUNTABLE ASSEMBLY. The real backend refuses to run the
                // insert ladder when it cannot tell whether the last insert
                // worked, because the next method would add the component a
                // SECOND time.
                if fault("mock-component-count-unknown") {
                    return Err(HostError::coded(
                        "component_count_failed",
                        "SOLIDWORKS would not report how many components the assembly has, so \
                         this bridge cannot tell whether the insert succeeded — and trying the \
                         next insert method could add it TWICE. Nothing further was attempted.",
                    ));
                }
                let path = self.resolve(args)?;
                self.require_assembly(
                    &path,
                    "add_component needs an assembly (.sldasm) as the target document",
                )?;
                // The SAME parser the real backend uses — a request the mock
                // accepts is one SOLIDWORKS will be asked to honour.
                let reqs = crate::script::parse_components(args).map_err(HostError::msg)?;
                let mut inserted = Vec::new();
                let mut failed = Vec::new();
                for r in &reqs {
                    // The mock's filesystem IS the filesystem: a component file
                    // that is not there fails here exactly as it would live.
                    if !std::path::Path::new(&r.file).exists() {
                        failed.push(json!({
                            "filePath": r.file,
                            "error": "file not found on the DESKTOP filesystem",
                        }));
                        continue;
                    }
                    let n = self
                        .docs
                        .iter()
                        .find(|d| d.path == path)
                        .map(|d| d.components.len())
                        .unwrap_or(0)
                        + 1;
                    let name = format!("{}-{n}", file_stem(&r.file));
                    if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {
                        d.components.push((r.file.clone(), r.config.clone()));
                        d.dirty = true;
                    }
                    inserted.push(json!({
                        "filePath": r.file,
                        "name": name,
                        "method": "AddComponent5/1",
                        "preloaded": false,
                        "positionMm": [
                            r.x / crate::script::MM,
                            r.y / crate::script::MM,
                            r.z / crate::script::MM,
                        ],
                        "rotationDeg": [
                            r.rx.to_degrees(),
                            r.ry.to_degrees(),
                            r.rz.to_degrees(),
                        ],
                    }));
                }
                let count =
                    self.docs.iter().find(|d| d.path == path).map(|d| d.components.len()).unwrap_or(0);
                Ok(json!({
                    "assembly": self.doc_info(&path),
                    "requested": reqs.len(),
                    "insertedCount": inserted.len(),
                    "inserted": inserted,
                    "failedCount": failed.len(),
                    "failed": failed,
                    "componentCount": count,
                    "method": "AddComponent5/1",
                    "fixedCount": 0,
                    "placedCount": inserted.len(),
                    "rotationsRequested": reqs.iter().filter(|r| r.rotated()).count(),
                    "placementNotes": [],
                    "activateErrors": 0,
                }))
            }

            "run_script" => {
                use crate::script::Op;
                let script = args.get("script").cloned().unwrap_or(Value::Null);
                // The SAME parser and the SAME validation the real backend runs,
                // so a script the mock accepts is a script SOLIDWORKS will be
                // asked to run — that is what makes the mock worth having.
                let ops = crate::script::parse(&script).map_err(HostError::msg)?;
                if let Some(m) = crate::script::hidden_conflict(&ops) {
                    return Err(HostError::coded("document_hidden", m));
                }
                crate::script::validate(&ops, !self.docs.is_empty()).map_err(HostError::msg)?;
                // The SAME pinning the real backend does: every step targets the
                // document the server named (or the one a new_part step made),
                // never "whatever is active now".
                let mut pinned: Option<String> = args
                    .get("targetPath")
                    .and_then(|v| v.as_str())
                    .filter(|s| !s.is_empty())
                    .or_else(|| args.get("targetDocument").and_then(|v| v.as_str()).filter(|s| !s.is_empty()))
                    .map(String::from);
                let pin_arg = |p: &Option<String>| match p {
                    Some(x) if x.contains('\\') || x.contains('/') => json!({"path": x}),
                    Some(n) => json!({"name": n}),
                    None => json!({}),
                };
                if pinned.is_some() {
                    let key = self.resolve(&pin_arg(&pinned))?;
                    let hidden = self.find(&key).map(|d| !d.visible).unwrap_or(false);
                    if hidden && ops.iter().any(|o| o.needs_selection()) {
                        let title = self.find(&key).map(|d| d.title.clone()).unwrap_or_default();
                        return Err(HostError::coded(
                            "document_hidden",
                            format!("'{title}' was created invisible: {}", crate::script::HIDDEN_DOCUMENT),
                        ));
                    }
                }
                // ACTIVATE THE PINNED DOCUMENT (0.10.2, review #8): selection works
                // only in SOLIDWORKS' active document. The user's active document
                // is put back at the end.
                let was_active = self.active.clone();
                if pinned.is_some() && !fault("mock-no-activation") {
                    let p = self.resolve(&pin_arg(&pinned))?;
                    self.active = Some(p);
                }
                let mut steps = Vec::new();
                let mut created = Value::Null;
                let mut run = fresh_run();
                let mut failure: Option<HostError> = None;
                for (i, op) in ops.iter().enumerate() {
                    let target = pin_arg(&pinned);
                    let detail: Result<Value, HostError> = (|| -> Result<Value, HostError> {
                        Ok(match op {
                            Op::NewPart { visible } | Op::NewAssembly { visible } => {
                                let kind = if matches!(op, Op::NewPart { .. }) { "part" } else { "assembly" };
                                let r = self.call("new_document", &json!({"type": kind, "visible": visible}))?;
                                created = r.get("created").cloned().unwrap_or(Value::Null);
                                pinned = created.get("title").and_then(|t| t.as_str()).map(String::from);
                                // The real backend activates its target before a
                                // selection (ensure_active) — a HIDDEN new part is
                                // not made active by NewDocument, so model that here.
                                if !fault("mock-no-activation") {
                                    if let Ok(p) = self.resolve(&pin_arg(&pinned)) {
                                        // A HIDDEN document cannot be activated (measured).
                                        if self.find(&p).map(|d| d.visible).unwrap_or(true) {
                                            self.active = Some(p);
                                        }
                                    }
                                }
                                run = fresh_run();
                                r
                            }
                            Op::ClearPart => {
                                // Only in a part the BRIDGE created — the host's own
                                // record decides (or the part this script made).
                                let path = self.resolve(&target)?;
                                let made_here = created.get("title").and_then(|t| t.as_str())
                                    .map(|t| self.find(&path).map(|d| d.title == t).unwrap_or(false))
                                    .unwrap_or(false);
                                if !made_here && !self.host_owns(&path) {
                                    let title = self.find(&path).map(|d| d.title.clone()).unwrap_or_default();
                                    return Err(HostError::coded(
                                        "clear_part_not_owned",
                                        format!("clear_part deletes features only in a part this bridge created; '{title}' is not one"),
                                    ));
                                }
                                let d = self.docs.iter_mut().find(|d| d.path == path).ok_or_else(|| HostError::msg("document vanished"))?;
                                if d.doc_type != "part" {
                                    return Err(HostError::msg(format!("clear_part works on a part; '{}' is not one", d.title)));
                                }
                                let model: crate::comhost::mock_model::Model =
                                    serde_json::from_value(Value::Object(d.model.clone())).unwrap_or_default();
                                // THE SAME PASSES the real backend runs. As measured, the
                                // FIRST delete takes the features and leaves an
                                // unconsumed sketch; the next pass, selecting it as a
                                // SKETCH, takes it (`mock-clear-part-stuck`: never).
                                let model_cell = std::cell::RefCell::new(model);
                                let cleared = crate::modeling::clear_in_passes(
                                    || Ok::<_, HostError>(model_cell.borrow().tree_after_origin()),
                                    |_names, pass| {
                                        let mut m = model_cell.borrow_mut();
                                        if pass == 1 {
                                            m.clear();
                                        } else if !fault("mock-clear-part-stuck") {
                                            m.loose_sketches.clear();
                                        }
                                        Ok(())
                                    },
                                    5,
                                );
                                let model = model_cell.into_inner();
                                d.bodies = model.bodies_json();
                                d.dimensions = Map::new();
                                if let Value::Object(m) = serde_json::to_value(&model).unwrap_or(Value::Null) {
                                    d.model = m;
                                }
                                d.dirty = true;
                                run = fresh_run();
                                match cleared {
                                    Ok(c) => json!({"cleared": d.title, "featuresDeleted": c.deleted.len(), "deleted": c.deleted,
                                                    "passes": c.passes, "bodiesLeft": 0,
                                                    "route": "DeleteSelection2(swDelete_Children | swDelete_Absorbed), repeated until the tree is empty"}),
                                    Err(crate::modeling::ClearError::Stuck { passes, left, .. }) => {
                                        return Err(HostError::coded(
                                            "clear_part_incomplete",
                                            format!(
                                                "clear_part could not delete every feature after {passes} passes (DeleteSelection2 last returned true; still there: {})",
                                                left.join(", ")
                                            ),
                                        ))
                                    }
                                    Err(crate::modeling::ClearError::Host(e)) => return Err(e),
                                }
                            }
                            Op::SetProp { name, value } => {
                                let path = self.resolve(&target)?;
                                if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {
                                    d.props.insert(name.clone(), prop(value));
                                    d.dirty = true;
                                }
                                json!({"property": name, "value": value, "code": 1})
                            }
                            Op::SaveAs { path } => {
                                if let crate::writes::Destination::Unverifiable(io) =
                                    crate::writes::destination_state(Some(path))
                                {
                                    return Err(HostError::coded(
                                        "destination_unverifiable",
                                        crate::writes::unverifiable_message(path, &io),
                                    ));
                                }
                                if std::path::Path::new(path).exists() {
                                    return Err(HostError::msg(format!("refusing to overwrite an existing file: {path}")));
                                }
                                self.call("save_as", &json!({"outputPath": path, "copy": true}))?
                            }
                            Op::Rebuild => json!({"rebuilt": true}),
                            Op::ZoomFit => json!({"zoomedToFit": true}),
                            _ => {
                                if matches!(op, Op::Sketch { .. }) && fault("mock-sketch-fails") {
                                    return Err(HostError::msg("SOLIDWORKS drew nothing"));
                                }
                                if matches!(op, Op::Extrude(_) | Op::Cut(_) | Op::Revolve(_) | Op::RevolveCut(_) | Op::Mirror { .. } | Op::Hole { .. })
                                    && fault(&format!("mock-feature-null-{}", op.name()))
                                {
                                    return Err(HostError::msg(
                                        "SOLIDWORKS returned no feature (nothing was cut — check the direction: a cut goes along the \
                                         plane's −normal unless reverse:true)",
                                    ));
                                }
                                // RE-SELECT the pending sketch by name before it is
                                // consumed (review #7): an op in between cleared it.
                                if matches!(op, Op::Extrude(_) | Op::Cut(_) | Op::Revolve(_) | Op::RevolveCut(_)) && run.pending.is_some() && !fault("mock-no-reselect") {
                                    run.sketch_selected = true;
                                }
                                let path = self.resolve(&target)?;
                                let active = self.active.as_deref() == Some(path.as_str());
                                let d = self.docs.iter_mut().find(|d| d.path == path).ok_or_else(|| HostError::msg("document vanished"))?;
                                let mut model: crate::comhost::mock_model::Model =
                                    serde_json::from_value(Value::Object(d.model.clone())).unwrap_or_default();
                                let applied = crate::comhost::mock_model::apply(&mut model, &mut run, op, active);
                                // What a FAILED step left in the tree (an unconsumed
                                // sketch) stays there, as it does in SOLIDWORKS.
                                if applied.is_err() {
                                    if let Value::Object(m) = serde_json::to_value(&model).unwrap_or(Value::Null) {
                                        d.model = m;
                                    }
                                }
                                let r = applied.map_err(HostError::msg)?;
                                if let Op::SetMaterial { name, .. } = op {
                                    if !name.is_empty() {
                                        d.material = Some(name.clone());
                                    }
                                }
                                if let (Op::Extrude(e), Some(f)) = (op, r.get("feature").and_then(|f| f.as_str())) {
                                    d.dimensions.insert(format!("D1@{f}"), json!(e.depth));
                                }
                                if !model.bodies.is_empty() {
                                    d.bodies = model.bodies_json();
                                }
                                if let Value::Object(m) = serde_json::to_value(&model).unwrap_or(Value::Null) {
                                    d.model = m;
                                }
                                d.dirty = true;
                                r
                            }
                        })
                    })();
                    match detail {
                        Ok(d) => steps.push(json!({"step": i + 1, "op": op.name(), "result": d})),
                        Err(e) => {
                            let mut err = HostError::msg(format!("step {} ({}) failed: {}", i + 1, op.name(), e.message));
                            err.data = e.data.clone();
                            err.code = e.code.clone();
                            // A part made by this script's new_part before the
                            // failing step still exists, and is the bridge's.
                            failure = Some(if created.is_object() { err.with_created(created.clone()) } else { err });
                            break;
                        }
                    }
                }
                // A SKETCH DRAWN AND NEVER CONSUMED stays in the tree, unabsorbed
                // (0.10.8) — the shape that survived a clear_part live.
                if let Some(p) = run.pending.take() {
                    if let Ok(path) = self.resolve(&pin_arg(&pinned)) {
                        if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {
                            let mut model: crate::comhost::mock_model::Model =
                                serde_json::from_value(Value::Object(d.model.clone())).unwrap_or_default();
                            if !model.loose_sketches.contains(&p.name) {
                                model.loose_sketches.push(p.name.clone());
                            }
                            if let Value::Object(m) = serde_json::to_value(&model).unwrap_or(Value::Null) {
                                d.model = m;
                            }
                        }
                    }
                }
                // Put the user's active document back.
                if let Some(prev) = was_active {
                    if self.find(&prev).is_some() && pinned.is_some() && !created.is_object() {
                        self.active = Some(prev);
                    }
                }
                if let Some(f) = failure {
                    return Err(f);
                }
                let final_target = pin_arg(&pinned);
                let doc = self.resolve(&final_target).ok().map(|p| self.doc_info(&p));
                Ok(json!({
                    "steps": steps,
                    "stepCount": steps.len(),
                    "created": created,
                    "solidworksRevision": mock_version(),
                    "targetDocument": doc.as_ref().and_then(|d| d.get("title").cloned()),
                    "documentName": doc.as_ref().and_then(|d| d.get("title").cloned()),
                    "doc": doc.unwrap_or(Value::Null),
                }))
            }

            other => Err(HostError::msg(format!("unknown command: {other}"))),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn path_helpers_handle_both_separators() {
        assert_eq!(base_name("C:\\mock\\gearbox.sldasm"), "gearbox.sldasm");
        assert_eq!(base_name("/home/a/b.sldprt"), "b.sldprt");
        assert_eq!(file_stem("C:\\mock\\gearbox.sldasm"), "gearbox");
        assert_eq!(file_stem("noext"), "noext");
        assert_eq!(dir_name("C:\\mock\\gearbox.sldasm"), "C:\\mock");
        assert_eq!(dir_name("bare.sldprt"), "");
    }

    #[test]
    fn doc_type_detection_is_case_insensitive() {
        assert_eq!(doc_type_of("A.SLDASM"), Some("assembly"));
        assert_eq!(doc_type_of("a.sldprt"), Some("part"));
        assert_eq!(doc_type_of("a.slddrw"), Some("drawing"));
        assert_eq!(doc_type_of("a.txt"), None);
    }

    #[test]
    fn open_then_resolve_active_then_close_falls_back() {
        let mut b = MockBackend::fresh();
        // launch:true because opening no longer starts SOLIDWORKS on its own —
        // the SERVER decides that, per edition (see swx_open / may_launch).
        b.call("open", &json!({"path": "C:\\m\\a.sldasm", "launch": true})).unwrap();
        b.call("open", &json!({"path": "C:\\m\\b.sldprt", "launch": true})).unwrap();
        // active is the most recently opened
        let r = b.call("docs", &json!({})).unwrap();
        assert_eq!(r["active"]["path"], "C:\\m\\b.sldprt");
        b.call("close_doc", &json!({})).unwrap();
        let r = b.call("docs", &json!({})).unwrap();
        assert_eq!(r["active"]["path"], "C:\\m\\a.sldasm");
    }

    #[test]
    fn opening_a_document_does_not_start_solidworks_by_itself() {
        // swx_open used to pass launch:true unconditionally, which made it a
        // second road to CoCreateInstance that skipped the edition check.
        let mut b = MockBackend::fresh();
        let e = b.call("open", &json!({"path": "C:\\m\\a.sldprt"})).unwrap_err();
        assert!(e.message.contains("not running"), "{}", e.message);
    }

    #[test]
    fn bom_requires_an_assembly() {
        let mut b = MockBackend::fresh();
        b.call("open", &json!({"path": "C:\\m\\a.sldprt", "launch": true})).unwrap();
        let e = b.call("bom", &json!({})).unwrap_err();
        assert!(e.message.contains("assembly"), "{}", e.message);
    }
}