app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
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);
}
}