@@ -222,22 +222,27 @@ function installObjModStateDom(persistedState) {
222222 const els = { tree : new FakeElement ( ) , details : new FakeElement ( ) , search : new FakeElement ( ) } ;
223223 global . document = { getElementById : ( id ) => els [ id ] || null } ;
224224 let state = persistedState || { } ;
225+ const messages = [ ] ;
225226 global . acquireVsCodeApi = ( ) => ( {
226- postMessage : ( ) => { } ,
227+ postMessage : ( message ) => { messages . push ( message ) ; } ,
227228 getState : ( ) => state ,
228229 setState : ( next ) => { state = next ; } ,
229230 } ) ;
230- return { getState : ( ) => state } ;
231+ return { getState : ( ) => state , getMessages : ( ) => messages } ;
231232}
232233
233234// state.ts is meant to make a reopened editor (a webview reload after our external-change auto-reload
234235// or revert, or a fresh VS Code session) pick back up where the user left off, instead of resetting to
235236// a blank slate — see the persistUi effect and the restoredSelectedKey logic there.
236237function testObjModStateRestoresAndPersistsUiState ( ) {
237238 moduleCache . clear ( ) ;
238- const objects = [ { key : 'Custom:0' } , { key : 'Custom:1' } ] ;
239+ const objects = [
240+ { key : 'Custom:0' , identity : 'Custom:hfoo|h001' } ,
241+ { key : 'Custom:1' , identity : 'Custom:hrif|h002' } ,
242+ ] ;
239243 const dom = installObjModStateDom ( {
240- selectedKey : 'Custom:1' ,
244+ selectedKey : 'Custom:0' , // deliberately stale after an external reorder
245+ selectedIdentity : 'Custom:hrif|h002' ,
241246 query : 'foo' ,
242247 fieldQuery : 'dmg' ,
243248 showTechnical : true ,
@@ -270,13 +275,19 @@ function testObjModStateRestoresAndPersistsUiState() {
270275 assert . equal ( persistedAfter . treeScrollTop , 240 , 'persisting one field must not drop the others' ) ;
271276 assert . equal ( persistedAfter . detailsScrollTop , 150 , 'persisting one field must not drop the others' ) ;
272277 assert . equal ( persistedAfter . selectedKey , 'Custom:1' , 'unrelated restored fields must survive a later persist' ) ;
278+ assert . equal ( persistedAfter . selectedIdentity , 'Custom:hrif|h002' , 'selection persistence must use stable rawcodes, not an array index' ) ;
279+ assert . ok ( dom . getMessages ( ) . some ( message => message . type === 'selectionChanged' && message . identity === 'Custom:hrif|h002' ) ,
280+ 'the stable selection identity must be sent to the host for workspace-relative persistence' ) ;
273281 assert . equal ( persistedAfter . listW , 321 , 'fields unrelated to reactive ui state (e.g. splitter width) must not be clobbered' ) ;
274282}
275283
276284function testObjModStatePendingJumpOverridesRestoredSelection ( ) {
277285 moduleCache . clear ( ) ;
278- const objects = [ { key : 'Custom:0' } , { key : 'Custom:1' } ] ;
279- installObjModStateDom ( { selectedKey : 'Custom:1' } ) ;
286+ const objects = [
287+ { key : 'Custom:0' , identity : 'Custom:hfoo|h001' } ,
288+ { key : 'Custom:1' , identity : 'Custom:hrif|h002' } ,
289+ ] ;
290+ installObjModStateDom ( { selectedKey : 'Custom:1' , selectedIdentity : 'Custom:hrif|h002' } ) ;
280291 global . window . __OBJMOD_INITIAL__ = { objects, selectedKey : 'Custom:0' , isPendingJump : true , extended : false } ;
281292
282293 const state = loadTsModule ( 'src/webview/objModEditor/state.ts' ) ;
@@ -285,8 +296,8 @@ function testObjModStatePendingJumpOverridesRestoredSelection() {
285296
286297function testObjModStateIgnoresStaleRestoredSelection ( ) {
287298 moduleCache . clear ( ) ;
288- const objects = [ { key : 'Custom:0' } ] ; // 'Custom:99' below no longer exists in this file
289- installObjModStateDom ( { selectedKey : 'Custom:99' } ) ;
299+ const objects = [ { key : 'Custom:0' , identity : 'Custom:hfoo|h001' } ] ;
300+ installObjModStateDom ( { selectedKey : 'Custom:99' , selectedIdentity : 'Custom:old0|old1' } ) ;
290301 global . window . __OBJMOD_INITIAL__ = { objects, selectedKey : 'Custom:0' , isPendingJump : false , extended : false } ;
291302
292303 const state = loadTsModule ( 'src/webview/objModEditor/state.ts' ) ;
@@ -300,8 +311,8 @@ function testObjModStateIgnoresStaleRestoredSelection() {
300311function testObjModTreeRenderPreservesScrollPosition ( ) {
301312 moduleCache . clear ( ) ;
302313 const objects = [
303- { key : 'Custom:0' , group : 'Custom' , race : 'human' , displayName : 'Alpha' , baseId : 'a000' } ,
304- { key : 'Custom:1' , group : 'Custom' , race : 'human' , displayName : 'Beta' , baseId : 'b000' } ,
314+ { key : 'Custom:0' , identity : 'Custom:a000|a001' , group : 'Custom' , race : 'human' , displayName : 'Alpha' , baseId : 'a000' } ,
315+ { key : 'Custom:1' , identity : 'Custom:b000|b001' , group : 'Custom' , race : 'human' , displayName : 'Beta' , baseId : 'b000' } ,
305316 ] ;
306317 installObjModStateDom ( { treeScrollTop : 240 } ) ;
307318 global . window . __OBJMOD_INITIAL__ = { objects, selectedKey : '' , isPendingJump : false , extended : false } ;
@@ -628,7 +639,7 @@ function testNonBlockingStartupAndForcedReinstallWiring() {
628639 assert . ok ( ! extension . includes ( 'ensureInstalledOrOfferMigration(true)' ) , 'manual install/update must not use the no-op ensure path' ) ;
629640 assert . ok ( ! languageServer . includes ( 'await maybeOfferUpdate(context)' ) , 'update checks must not delay language-client startup' ) ;
630641 assert . ok ( languageServer . includes ( 'void maybeOfferUpdate((update) =>' ) , 'update checks should still run in the background and update the status item' ) ;
631- assert . ok ( languageServer . includes ( "'$(cloud-download ) WurstScript Update'" ) , 'the status item must indicate when an update is available' ) ;
642+ assert . ok ( languageServer . includes ( "'$(circle-filled ) WurstScript Update'" ) , 'the status item must indicate when an update is available' ) ;
632643 assert . ok ( ! installer . includes ( "{ modal: true, detail }, 'Update', 'Later'" ) , 'the automatic update notification must not be modal' ) ;
633644 assert . ok ( installer . includes ( "'Update', 'Later'" ) , 'the non-modal update notification must retain its actions' ) ;
634645 assert . ok ( installer . includes ( "execFile(java, ['-jar', COMPILER_JAR, '--version']" ) , 'version detection must use an asynchronous child process' ) ;
@@ -752,7 +763,7 @@ function testAssetBrowserForwardsModelTextures() {
752763 'asset browser must not silently drop model texture requests'
753764 ) ;
754765 assert . ok (
755- script . includes ( 'assetSearchScore(query, item.label, item.value, item.detail)' ) ,
766+ script . includes ( 'assetSearchScore(query, item.label, item.value, item.detail, fuzzyMatch )' ) ,
756767 'code and object-data asset pickers should share the relevance scorer'
757768 ) ;
758769 assert . ok (
@@ -794,7 +805,7 @@ function testThumbnailLifecycleGuards() {
794805 assert . ok ( objmod . includes ( 'fetch(initial.thumbnailWorkerUri' ) , 'the worker bundle must be fetched before creating its Blob URL' ) ;
795806 assert . ok ( ! objmod . includes ( 'new Worker(initial.thumbnailWorkerUri)' ) , 'VS Code resource URLs cannot be passed directly to the Worker constructor' ) ;
796807 assert . ok ( assetBrowser . includes ( 'modelThumbEnsureInit()' ) , 'opening or selecting the model asset browser should prewarm the thumbnail worker' ) ;
797- assert . ok ( assetBrowser . includes ( "import { assetSearchScore } from '../../features/preview/fuzzy'" ) , 'objmod asset search should use the shared relevance scorer' ) ;
808+ assert . ok ( assetBrowser . includes ( "import { assetSearchScore, fuzzyMatch } from '../../features/preview/fuzzy'" ) , 'objmod asset search should use the shared relevance scorer' ) ;
798809 const ensureInit = / e x p o r t f u n c t i o n m o d e l T h u m b E n s u r e I n i t \( \) \{ ( [ \s \S ] * ?) \n \} / . exec ( objmod ) ?. [ 1 ] || '' ;
799810 assert . ok ( ! ensureInit . includes ( 'mpvViewer()' ) , 'worker startup failure must not fall back to rendering on the objmod UI thread' ) ;
800811 assert . ok ( webpack . includes ( "mdxThumbnailWorker: './src/webview/mdxThumbnailWorker.ts'" ) , 'the isolated thumbnail worker must be bundled' ) ;
@@ -1017,6 +1028,27 @@ function testObjModEditorTypeAndRecoveryGuards() {
10171028 assert . ok ( host . includes ( 'wtsEdits: Array.from(doc.wtsEdits)' ) , 'objmod backups must include staged WTS edits' ) ;
10181029 assert . ok ( host . includes ( 'currentRevision = beforeRevision' ) , 'undo must restore a history identity, not decrement a depth' ) ;
10191030 assert . ok ( ! host . includes ( 'doc.editDepth' ) , 'branch-unsafe edit depth tracking must not return' ) ;
1031+ assert . ok ( host . includes ( 'watcher.onDidDelete(onEvent)' ) , 'external-change detection must cover Git-style file replacement' ) ;
1032+ assert . ok ( host . includes ( 'id="refresh-editor"' ) , 'the object editor must expose a manual refresh action' ) ;
1033+ assert . ok ( host . includes ( 'preferredSelectionIdentity' ) , 'reloading must resolve selection by stable object identity' ) ;
1034+ assert . ok ( host . includes ( 'objModSelectionPathKey(doc.uri)' ) , 'selection must be stored per workspace-relative document path' ) ;
1035+ }
1036+
1037+ function testWpmEditorInlineScriptAndRecoveryGuards ( ) {
1038+ const host = fs . readFileSync ( path . join ( root , 'src/features/wpmPreview.ts' ) , 'utf8' ) ;
1039+ const match = host . match ( / < s c r i p t > \r ? \n ( [ \s \S ] * ?) \r ? \n { 2 } < \/ s c r i p t > / ) ;
1040+ assert . ok ( match , 'WPM editor inline script should be present' ) ;
1041+ const script = match [ 1 ]
1042+ . replace ( '${wpm.width}' , '4' )
1043+ . replace ( '${wpm.height}' , '4' )
1044+ . replace ( '${dataBase64}' , 'AAAAAAAAAAAAAAAAAAAAAA==' )
1045+ . replace ( / \\ ` / g, '`' )
1046+ . replace ( / \\ \$ \{ / g, '${' ) ;
1047+ // eslint-disable-next-line sonarjs/constructor-for-side-effects -- parsing the real inline script is the assertion.
1048+ new vm . Script ( script ) ;
1049+ assert . ok ( host . includes ( 'openContext.backupId' ) , 'WPM documents must restore VS Code hot-exit backups' ) ;
1050+ assert . ok ( host . includes ( 'currentRevision !== doc.savedRevision' ) , 'WPM dirty tracking must distinguish edit-history branches' ) ;
1051+ assert . ok ( ! host . includes ( 'doc.editDepth' ) , 'WPM dirty tracking must not use branch-unsafe edit depth' ) ;
10201052}
10211053
10221054async function main ( ) {
@@ -1033,6 +1065,7 @@ async function main() {
10331065 testBc5DdsDecode ( ) ;
10341066 testInstallerVersionShaParsing ( ) ;
10351067 testObjModEditorTypeAndRecoveryGuards ( ) ;
1068+ testWpmEditorInlineScriptAndRecoveryGuards ( ) ;
10361069 testNonBlockingStartupAndForcedReinstallWiring ( ) ;
10371070 testWurstProcessMatching ( ) ;
10381071 await testModelThumbnailRequestsTexturesByDefault ( ) ;
0 commit comments