Skip to content

Commit 018f02d

Browse files
authored
Merge pull request #3547 from scratchfoundation/fix/tolerate-missing-mutation-attributes
fix: tolerate missing warp mutation attribute on procedure blocks
2 parents 8e37e2f + f564a85 commit 018f02d

2 files changed

Lines changed: 179 additions & 2 deletions

File tree

src/blocks/procedures.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,38 @@ function parseRequiredMutationJson<T>(
9797
return parse(parsedValue, name)
9898
}
9999

100+
/**
101+
* Parse an optional mutation attribute as JSON, returning a fallback when the
102+
* attribute is absent. Use this only for attributes that can be safely
103+
* defaulted in isolation, without invalidating structural invariants that
104+
* relate them to other attributes on the same mutation. A present-but-malformed
105+
* attribute still throws, since that indicates corruption rather than an older
106+
* schema.
107+
* @param xmlElement The mutation element that may contain the attribute.
108+
* @param name The specific mutation attribute to retrieve and parse.
109+
* @param parse Validates and narrows the parsed JSON value.
110+
* @param fallback Value to return when the attribute is absent.
111+
* @returns Parsed and validated mutation attribute value, or `fallback`.
112+
*/
113+
function parseOptionalMutationJson<T>(
114+
xmlElement: Element,
115+
name: string,
116+
parse: (value: unknown, name: string) => T,
117+
fallback: T,
118+
): T {
119+
const rawValue = xmlElement.getAttribute(name)
120+
if (rawValue === null) {
121+
return fallback
122+
}
123+
let parsedValue: unknown
124+
try {
125+
parsedValue = JSON.parse(rawValue)
126+
} catch {
127+
throw new Error(`Invalid JSON in mutation attribute: ${name}`)
128+
}
129+
return parse(parsedValue, name)
130+
}
131+
100132
/**
101133
* Validate a parsed mutation value as a boolean.
102134
* @param value Parsed mutation value.
@@ -277,7 +309,7 @@ function callerDomToMutation(this: ProcedureCallBlock, xmlElement: Element) {
277309
const generateshadows = xmlElement.getAttribute('generateshadows')
278310
this.generateShadows_ = generateshadows !== null ? JSON.parse(generateshadows) === true : false
279311
this.argumentIds_ = parseRequiredMutationJson(xmlElement, 'argumentids', parseStringArrayMutationValue)
280-
this.warp_ = parseRequiredMutationJson(xmlElement, 'warp', parseBooleanMutationValue)
312+
this.warp_ = parseOptionalMutationJson(xmlElement, 'warp', parseBooleanMutationValue, false)
281313
this.updateDisplay_()
282314
}
283315

@@ -312,7 +344,7 @@ function definitionMutationToDom(
312344
*/
313345
function definitionDomToMutation(this: ProcedurePrototypeBlock | ProcedureDeclarationBlock, xmlElement: Element) {
314346
this.procCode_ = getRequiredMutationAttribute(xmlElement, 'proccode')
315-
this.warp_ = parseRequiredMutationJson(xmlElement, 'warp', parseBooleanMutationValue)
347+
this.warp_ = parseOptionalMutationJson(xmlElement, 'warp', parseBooleanMutationValue, false)
316348

317349
const prevArgIds = this.argumentIds_
318350
const prevDisplayNames = this.displayNames_

tests/browser/procedures.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,151 @@ describe('procedure call mutation round-trip', () => {
192192
})
193193
})
194194

195+
// ---------------------------------------------------------------------------
196+
// Legacy project XML tolerance (scratchfoundation/scratch-editor#532)
197+
//
198+
// Older saved projects may omit mutation attributes that the current serializer
199+
// always writes. Treating every attribute as required breaks loading of
200+
// projects that predate (or selectively elide) those attributes. `proccode`
201+
// and the argument arrays (`argumentids`, `argumentnames`, `argumentdefaults`)
202+
// stay required: `proccode` is the block's identity, and the argument arrays'
203+
// lengths must match the proccode token count and each other, so silently
204+
// defaulting any of them to `[]` would turn an N-arg procedure into a 0-arg
205+
// block. `warp` is the one attribute recoverable with a sane default (`false`).
206+
// ---------------------------------------------------------------------------
207+
208+
describe('legacy mutation tolerance', () => {
209+
it('procedures_prototype mutation without warp loads with warp=false', () => {
210+
expect(() =>
211+
loadXml(`
212+
<xml>
213+
<block type="procedures_definition">
214+
<statement name="custom_block">
215+
<block type="procedures_prototype">
216+
<mutation
217+
proccode="test %s"
218+
argumentids='["arg1"]'
219+
argumentnames='["x"]'
220+
argumentdefaults='[""]'>
221+
</mutation>
222+
<value name="arg1">
223+
<block type="argument_reporter_string_number">
224+
<field name="VALUE">x</field>
225+
</block>
226+
</value>
227+
</block>
228+
</statement>
229+
</block>
230+
</xml>
231+
`),
232+
).not.toThrow()
233+
234+
const proto = workspace.getAllBlocks(false).find((b) => b.type === 'procedures_prototype')
235+
assert(proto, 'Expected procedures_prototype block')
236+
expect((proto as any).warp_).toBe(false)
237+
})
238+
239+
it('procedures_call mutation without warp loads with warp=false', () => {
240+
expect(() =>
241+
loadXml(`
242+
<xml>
243+
<block type="procedures_call">
244+
<mutation
245+
proccode="test %s"
246+
argumentids='["arg1"]'>
247+
</mutation>
248+
</block>
249+
</xml>
250+
`),
251+
).not.toThrow()
252+
253+
const callBlock = workspace.getAllBlocks(false).find((b) => b.type === 'procedures_call')
254+
assert(callBlock, 'Expected procedures_call block')
255+
expect((callBlock as any).warp_).toBe(false)
256+
})
257+
258+
it('procedures_prototype mutation without proccode still throws', () => {
259+
// proccode is the block's identity and cannot be defaulted.
260+
expect(() =>
261+
loadXml(`
262+
<xml>
263+
<block type="procedures_definition">
264+
<statement name="custom_block">
265+
<block type="procedures_prototype">
266+
<mutation warp="false"></mutation>
267+
</block>
268+
</statement>
269+
</block>
270+
</xml>
271+
`),
272+
).toThrow(/proccode/)
273+
})
274+
275+
// argumentids/argumentnames/argumentdefaults have a structural invariant:
276+
// their lengths must equal the %s/%n/%b token count in proccode. Silently
277+
// defaulting any of them to [] would turn an N-arg procedure into a 0-arg
278+
// block, so keep them required and surface the problem loudly.
279+
it('procedures_prototype mutation without argumentids still throws', () => {
280+
expect(() =>
281+
loadXml(`
282+
<xml>
283+
<block type="procedures_definition">
284+
<statement name="custom_block">
285+
<block type="procedures_prototype">
286+
<mutation proccode="test %s" argumentnames='["x"]' argumentdefaults='[""]' warp="false"></mutation>
287+
</block>
288+
</statement>
289+
</block>
290+
</xml>
291+
`),
292+
).toThrow(/argumentids/)
293+
})
294+
295+
it('procedures_prototype mutation without argumentnames still throws', () => {
296+
expect(() =>
297+
loadXml(`
298+
<xml>
299+
<block type="procedures_definition">
300+
<statement name="custom_block">
301+
<block type="procedures_prototype">
302+
<mutation proccode="test %s" argumentids='["arg1"]' argumentdefaults='[""]' warp="false"></mutation>
303+
</block>
304+
</statement>
305+
</block>
306+
</xml>
307+
`),
308+
).toThrow(/argumentnames/)
309+
})
310+
311+
it('procedures_prototype mutation without argumentdefaults still throws', () => {
312+
expect(() =>
313+
loadXml(`
314+
<xml>
315+
<block type="procedures_definition">
316+
<statement name="custom_block">
317+
<block type="procedures_prototype">
318+
<mutation proccode="test %s" argumentids='["arg1"]' argumentnames='["x"]' warp="false"></mutation>
319+
</block>
320+
</statement>
321+
</block>
322+
</xml>
323+
`),
324+
).toThrow(/argumentdefaults/)
325+
})
326+
327+
it('procedures_call mutation without argumentids still throws', () => {
328+
expect(() =>
329+
loadXml(`
330+
<xml>
331+
<block type="procedures_call">
332+
<mutation proccode="test %s" warp="false"></mutation>
333+
</block>
334+
</xml>
335+
`),
336+
).toThrow(/argumentids/)
337+
})
338+
})
339+
195340
// ---------------------------------------------------------------------------
196341
// PR #3492: context menu delegation
197342
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)