diff --git a/packages/scratch-gui/src/lib/ruby-generator/index.js b/packages/scratch-gui/src/lib/ruby-generator/index.js index 8609871d30..b51ca1bfc9 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/index.js +++ b/packages/scratch-gui/src/lib/ruby-generator/index.js @@ -208,10 +208,24 @@ RubyGenerator._wrapWithClass = function (code, classComment, forFileOutput) { // Support name=ClassName format for preserving class names let allowedAttributes = []; let explicitClassName = null; + let superclassPath = null; if (classComment.startsWith('@ruby:class:')) { const attrPart = classComment.slice('@ruby:class:'.length); allowedAttributes = attrPart.split(','); + // Check for <=superclass in the attributes + const superAttrIndex = allowedAttributes.findIndex(a => a.startsWith('<=')); + if (superAttrIndex >= 0) { + const encoded = allowedAttributes[superAttrIndex].slice(2); + // Decode: leading // → ::, then / → :: + if (encoded.startsWith('//')) { + superclassPath = `::${encoded.slice(2).replace(/\//g, '::')}`; + } else { + superclassPath = encoded.replace(/\//g, '::'); + } + allowedAttributes.splice(superAttrIndex, 1); + } + // Check for name=ClassName in the attributes const nameAttrIndex = allowedAttributes.findIndex(a => a.startsWith('name=')); if (nameAttrIndex >= 0) { @@ -324,7 +338,12 @@ RubyGenerator._wrapWithClass = function (code, classComment, forFileOutput) { code = this.prefixLines(code, this.INDENT); } const separator = setCode.length > 0 && code.length > 0 ? '\n' : ''; - const inheritance = forFileOutput ? ' < ::Smalruby3::Sprite' : ''; + let inheritance = ''; + if (superclassPath) { + inheritance = ` < ${superclassPath}`; + } else if (forFileOutput) { + inheritance = ' < ::Smalruby3::Sprite'; + } code = `class ${className}${inheritance}\n${setCode}${separator}${code}end\n`; if (outsideCode.length > 0) { diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/index.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/index.js index bc88b52053..8c4296111f 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/index.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/index.js @@ -100,6 +100,11 @@ const messages = defineMessages({ '\nPlease switch to Ruby version 2 from the settings menu.', description: 'Error message when class syntax is used in Ruby version 1', id: 'gui.smalruby3.rubyToBlocksConverter.classNotSupportedInV1' + }, + invalidStageSuperclass: { + defaultMessage: 'Stage class can only inherit from ::Smalruby3::Stage or Smalruby3::Stage.', + description: 'Error message when Stage class has invalid superclass', + id: 'gui.smalruby3.rubyToBlocksConverter.invalidStageSuperclass' } }); @@ -383,6 +388,28 @@ class RubyToBlocksConverter extends Visitor { return handlerName ? handlerName.slice('visit'.length) : null; } + /** + * Convert a ConstantReadNode or ConstantPathNode to its full path string. + * e.g. ConstantReadNode "Foo" -> "Foo" + * e.g. ConstantPathNode(ConstantPathNode(null, "Smalruby3"), "Sprite") -> "::Smalruby3::Sprite" + * @param {object} node - A prism constant node + * @returns {string} The full constant path + */ + _constantNodeToPath (node) { + const typeName = this._getNodeTypeName(node); + if (typeName === 'ConstantReadNode') { + return node.name; + } + if (typeName === 'ConstantPathNode') { + if (node.parent) { + return `${this._constantNodeToPath(node.parent)}::${node.name}`; + } + // Leading :: (root scope) + return `::${node.name}`; + } + return String(node.name || ''); + } + visitProgramNode (node) { return this.visit(node.statements); } @@ -400,7 +427,23 @@ class RubyToBlocksConverter extends Visitor { const isSpriteIndexName = /^Sprite\d+$/.test(className); const isStageClass = className === 'Stage'; - // Accept optional superclass `< ::Smalruby3::Sprite` (ignored, purely for readability) + // Extract superclass path (e.g. "::Smalruby3::Sprite", "Foo") + let superclassPath = null; + if (node.superclass) { + superclassPath = this._constantNodeToPath(node.superclass); + } + + // Stage only accepts no superclass, ::Smalruby3::Stage, or Smalruby3::Stage + if (isStageClass && superclassPath !== null) { + if (superclassPath !== '::Smalruby3::Stage' && superclassPath !== 'Smalruby3::Stage') { + throw new RubyToBlocksConverterError( + node.superclass, + this._translator(messages.invalidStageSuperclass) + ); + } + // Accepted Stage superclass — don't store it (Stage is always Stage) + superclassPath = null; + } // Set of recognized set_xxx class methods (sprite-specific) const SPRITE_SET_METHODS = { @@ -546,17 +589,30 @@ class RubyToBlocksConverter extends Visitor { // Generate comment text // For non-Sprite\d+ class names (excluding Stage), use name=ClassName format to preserve the class name + // Superclass is encoded as <=path with :: replaced by / let commentText; + const commentParts = []; + if (superclassPath) { + let encodedSuperclass; + if (superclassPath.startsWith('::')) { + encodedSuperclass = `//${superclassPath.slice(2).replace(/::/g, '/')}`; + } else { + encodedSuperclass = superclassPath.replace(/::/g, '/'); + } + commentParts.push(`<=${encodedSuperclass}`); + } if (attributeNames.length > 0) { - const commentParts = attributeNames.map(attr => { + attributeNames.forEach(attr => { if (attr === 'name' && !isSpriteIndexName && !isStageClass) { - return `name=${className}`; - } - if (attr === 'sprite') { - return `sprite=${classInfo.sprite}`; + commentParts.push(`name=${className}`); + } else if (attr === 'sprite') { + commentParts.push(`sprite=${classInfo.sprite}`); + } else { + commentParts.push(attr); } - return attr; }); + } + if (commentParts.length > 0) { commentText = `@ruby:class:${commentParts.join(',')}`; } else { commentText = '@ruby:class'; diff --git a/packages/scratch-gui/test/unit/lib/ruby-generator/class.test.js b/packages/scratch-gui/test/unit/lib/ruby-generator/class.test.js index 065f312391..fb2c353163 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-generator/class.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-generator/class.test.js @@ -906,4 +906,161 @@ describe('RubyGenerator/Class', () => { expect(result).not.toContain('Sprite.new'); }); }); + + describe('superclass inheritance from <=', () => { + test('<=//Smalruby3/Sprite outputs < ::Smalruby3::Sprite (non-file output)', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=//Smalruby3/Sprite']; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {}); + + expect(result).toContain('class Sprite1 < ::Smalruby3::Sprite'); + }); + + test('<=Smalruby3/Sprite outputs < Smalruby3::Sprite (non-file output)', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=Smalruby3/Sprite']; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {}); + + expect(result).toContain('class Sprite1 < Smalruby3::Sprite'); + }); + + test('<=Foo outputs < Foo (non-file output)', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=Foo']; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {}); + + expect(result).toContain('class Sprite1 < Foo'); + }); + + test('<=Sprite outputs < Sprite (non-file output)', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=Sprite']; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {}); + + expect(result).toContain('class Sprite1 < Sprite'); + }); + + test('no <= omits superclass (non-file output)', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class']; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {}); + + expect(result).toContain('class Sprite1\n'); + expect(result).not.toContain(' < '); + }); + + test('<=//Smalruby3/Sprite in file output uses specified superclass', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + target.x = 0; + target.y = 0; + target.direction = 90; + target.visible = true; + target.size = 100; + target.currentCostume = 0; + target.rotationStyle = 'all around'; + target.isStage = false; + target.sprite.costumes = []; + target.variables = {}; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=//Smalruby3/Sprite']; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {withSpriteNew: true, version: '2'}); + + expect(result).toContain('class Sprite1 < ::Smalruby3::Sprite'); + }); + + test('<=Foo in file output uses Foo as superclass', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + target.x = 0; + target.y = 0; + target.direction = 90; + target.visible = true; + target.size = 100; + target.currentCostume = 0; + target.rotationStyle = 'all around'; + target.isStage = false; + target.sprite.costumes = []; + target.variables = {}; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=Foo']; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {withSpriteNew: true, version: '2'}); + + expect(result).toContain('class Sprite1 < Foo'); + }); + + test('no <= in file output defaults to ::Smalruby3::Sprite', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + target.x = 0; + target.y = 0; + target.direction = 90; + target.visible = true; + target.size = 100; + target.currentCostume = 0; + target.rotationStyle = 'all around'; + target.isStage = false; + target.sprite.costumes = []; + target.variables = {}; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class']; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {withSpriteNew: true, version: '2'}); + + expect(result).toContain('class Sprite1 < ::Smalruby3::Sprite'); + }); + + test('<=Foo with other attributes preserves both', () => { + const {target, runtime} = makeMockTarget('Sprite1', 1); + target.runtime = runtime; + target.x = 10; + RubyGenerator.currentTarget_ = target; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class:<=Foo,x']; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {}); + + expect(result).toContain('class Sprite1 < Foo'); + expect(result).toContain('set_x 10'); + }); + + test('Stage class does not use <= (always no superclass in non-file output)', () => { + const stage = {isStage: true, sprite: {name: 'Stage'}}; + const runtime = {targets: [stage]}; + stage.runtime = runtime; + RubyGenerator.currentTarget_ = stage; + RubyGenerator.cache_.targetCommentTexts = ['@ruby:class']; + + const code = 'self.when(:flag_clicked) do\n move(10)\nend\n'; + const result = RubyGenerator.finish(code, {}); + + expect(result).toContain('class Stage\n'); + expect(result).not.toContain(' < '); + }); + }); }); diff --git a/packages/scratch-gui/test/unit/lib/ruby-roundtrip-class-superclass.test.js b/packages/scratch-gui/test/unit/lib/ruby-roundtrip-class-superclass.test.js new file mode 100644 index 0000000000..548be76a6b --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/ruby-roundtrip-class-superclass.test.js @@ -0,0 +1,215 @@ +import RubyGenerator from '../../../src/lib/ruby-generator'; +import { + makeSpriteTarget, + makeStageTarget, + makeConverter, + setupRubyGenerator +} from '../helpers/ruby-roundtrip-helper'; + +/** + * Round trip: Ruby → Blocks → apply → Ruby (version 2, class syntax) + */ +const classRoundTrip = async (converter, target, code, options = {}) => { + const result = await converter.targetCodeToBlocks(target, code); + if (!result) { + throw new Error( + `Failed to convert Ruby to blocks.\nErrors: ${JSON.stringify(converter.errors)}\nCode:\n${code}` + ); + } + await converter.applyTargetBlocks(target); + RubyGenerator.currentTarget = target; + return RubyGenerator.targetToCode(target, {version: '2', ...options}).trim(); +}; + +describe('Ruby Roundtrip: class superclass preservation', () => { + describe('sprite classes', () => { + let target, runtime, converter; + + beforeEach(() => { + ({target, runtime} = makeSpriteTarget()); + target.sprite = {name: 'Sprite1', costumes: [], sounds: []}; + runtime.targets = [runtime.getTargetForStage(), target]; + setupRubyGenerator(); + converter = makeConverter(target, runtime, {version: '2'}); + }); + + test('class Sprite1 < ::Smalruby3::Sprite round trip', async () => { + const input = [ + 'class Sprite1 < ::Smalruby3::Sprite', + ' self.when(:flag_clicked) do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + const expected = [ + 'class Sprite1 < ::Smalruby3::Sprite', + ' when_flag_clicked do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + const result = await classRoundTrip(converter, target, input); + expect(result).toBe(expected); + }); + + test('class Sprite1 < Smalruby3::Sprite round trip', async () => { + const input = [ + 'class Sprite1 < Smalruby3::Sprite', + ' self.when(:flag_clicked) do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + const expected = [ + 'class Sprite1 < Smalruby3::Sprite', + ' when_flag_clicked do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + const result = await classRoundTrip(converter, target, input); + expect(result).toBe(expected); + }); + + test('class Sprite1 < Foo round trip', async () => { + const input = [ + 'class Sprite1 < Foo', + ' self.when(:flag_clicked) do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + const expected = [ + 'class Sprite1 < Foo', + ' when_flag_clicked do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + const result = await classRoundTrip(converter, target, input); + expect(result).toBe(expected); + }); + + test('class Sprite1 < Sprite round trip', async () => { + const input = [ + 'class Sprite1 < Sprite', + ' self.when(:flag_clicked) do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + const expected = [ + 'class Sprite1 < Sprite', + ' when_flag_clicked do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + const result = await classRoundTrip(converter, target, input); + expect(result).toBe(expected); + }); + + test('class Sprite1 (no superclass) round trip', async () => { + const input = [ + 'class Sprite1', + ' self.when(:flag_clicked) do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + const expected = [ + 'class Sprite1', + ' when_flag_clicked do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + const result = await classRoundTrip(converter, target, input); + expect(result).toBe(expected); + }); + + test('class Cat < Foo with name= round trip', async () => { + const input = [ + 'class Cat < Foo', + ' self.when(:flag_clicked) do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + target.sprite.name = 'Cat'; + const expected = [ + 'class Cat < Foo', + ' when_flag_clicked do', + ' move(10)', + ' end', + 'end' + ].join('\n'); + const result = await classRoundTrip(converter, target, input); + expect(result).toBe(expected); + }); + }); + + describe('stage classes', () => { + let target, runtime, converter; + + beforeEach(() => { + ({target, runtime} = makeStageTarget()); + target.sprite = {name: 'Stage', costumes: [], sounds: []}; + runtime.targets = [target]; + setupRubyGenerator(); + converter = makeConverter(target, runtime, {version: '2'}); + }); + + test('class Stage < ::Smalruby3::Stage round trip (superclass stripped)', async () => { + const input = [ + 'class Stage < ::Smalruby3::Stage', + ' self.when(:flag_clicked) do', + ' switch_backdrop("Arctic")', + ' end', + 'end' + ].join('\n'); + // Stage superclass is not preserved in comment — always outputs without it + const expected = [ + 'class Stage', + ' when_flag_clicked do', + ' switch_backdrop("Arctic")', + ' end', + 'end' + ].join('\n'); + const result = await classRoundTrip(converter, target, input); + expect(result).toBe(expected); + }); + + test('class Stage < Smalruby3::Stage round trip (superclass stripped)', async () => { + const input = [ + 'class Stage < Smalruby3::Stage', + ' self.when(:flag_clicked) do', + ' switch_backdrop("Arctic")', + ' end', + 'end' + ].join('\n'); + const expected = [ + 'class Stage', + ' when_flag_clicked do', + ' switch_backdrop("Arctic")', + ' end', + 'end' + ].join('\n'); + const result = await classRoundTrip(converter, target, input); + expect(result).toBe(expected); + }); + + test('class Stage < Foo is rejected', async () => { + const input = [ + 'class Stage < Foo', + ' self.when(:flag_clicked) do', + ' switch_backdrop("Arctic")', + ' end', + 'end' + ].join('\n'); + const result = await converter.targetCodeToBlocks(target, input); + expect(result).toBe(false); + expect(converter.errors.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/class.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/class.test.js index 4dfb22db04..accd3393c2 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/class.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/class.test.js @@ -114,7 +114,7 @@ describe('RubyToBlocksConverter/Class', () => { expect(targetComments[0].text).toEqual('@ruby:class'); }); - test('class with superclass < ::Smalruby3::Sprite is accepted', async () => { + test('class with superclass < ::Smalruby3::Sprite preserves superclass in comment', async () => { code = ` class Sprite1 < ::Smalruby3::Sprite self.when(:flag_clicked) do @@ -129,6 +129,90 @@ describe('RubyToBlocksConverter/Class', () => { `); await convertAndExpectToEqualBlocks(converter, target, code, expected); + const comments = converter._context.comments; + const targetComments = Object.values(comments).filter(c => c.blockId === null); + expect(targetComments).toHaveLength(1); + expect(targetComments[0].text).toEqual('@ruby:class:<=//Smalruby3/Sprite'); + }); + + test('class with superclass < Smalruby3::Sprite preserves superclass in comment', async () => { + code = ` + class Sprite1 < Smalruby3::Sprite + self.when(:flag_clicked) do + move(10) + end + end + `; + expected = await rubyToExpected(converter, target, ` + self.when(:flag_clicked) do + move(10) + end + `); + await convertAndExpectToEqualBlocks(converter, target, code, expected); + + const comments = converter._context.comments; + const targetComments = Object.values(comments).filter(c => c.blockId === null); + expect(targetComments).toHaveLength(1); + expect(targetComments[0].text).toEqual('@ruby:class:<=Smalruby3/Sprite'); + }); + + test('class with superclass < Sprite preserves superclass in comment', async () => { + code = ` + class Sprite1 < Sprite + self.when(:flag_clicked) do + move(10) + end + end + `; + expected = await rubyToExpected(converter, target, ` + self.when(:flag_clicked) do + move(10) + end + `); + await convertAndExpectToEqualBlocks(converter, target, code, expected); + + const comments = converter._context.comments; + const targetComments = Object.values(comments).filter(c => c.blockId === null); + expect(targetComments).toHaveLength(1); + expect(targetComments[0].text).toEqual('@ruby:class:<=Sprite'); + }); + + test('class with superclass < Foo preserves superclass in comment', async () => { + code = ` + class Sprite1 < Foo + self.when(:flag_clicked) do + move(10) + end + end + `; + expected = await rubyToExpected(converter, target, ` + self.when(:flag_clicked) do + move(10) + end + `); + await convertAndExpectToEqualBlocks(converter, target, code, expected); + + const comments = converter._context.comments; + const targetComments = Object.values(comments).filter(c => c.blockId === null); + expect(targetComments).toHaveLength(1); + expect(targetComments[0].text).toEqual('@ruby:class:<=Foo'); + }); + + test('class without superclass has no <= in comment', async () => { + code = ` + class Sprite1 + self.when(:flag_clicked) do + move(10) + end + end + `; + expected = await rubyToExpected(converter, target, ` + self.when(:flag_clicked) do + move(10) + end + `); + await convertAndExpectToEqualBlocks(converter, target, code, expected); + const comments = converter._context.comments; const targetComments = Object.values(comments).filter(c => c.blockId === null); expect(targetComments).toHaveLength(1); @@ -1236,6 +1320,63 @@ describe('RubyToBlocksConverter/Class', () => { }); describe('class Stage', () => { + describe('stage superclass handling', () => { + test('class Stage < ::Smalruby3::Stage is accepted (no <= in comment)', async () => { + code = ` + class Stage < ::Smalruby3::Stage + self.when(:flag_clicked) do + switch_backdrop("Arctic") + end + end + `; + expected = await rubyToExpected(converter, target, ` + self.when(:flag_clicked) do + switch_backdrop("Arctic") + end + `); + await convertAndExpectToEqualBlocks(converter, target, code, expected); + + const comments = converter._context.comments; + const targetComments = Object.values(comments).filter(c => c.blockId === null); + expect(targetComments).toHaveLength(1); + expect(targetComments[0].text).toEqual('@ruby:class'); + }); + + test('class Stage < Smalruby3::Stage is accepted (no <= in comment)', async () => { + code = ` + class Stage < Smalruby3::Stage + self.when(:flag_clicked) do + switch_backdrop("Arctic") + end + end + `; + expected = await rubyToExpected(converter, target, ` + self.when(:flag_clicked) do + switch_backdrop("Arctic") + end + `); + await convertAndExpectToEqualBlocks(converter, target, code, expected); + + const comments = converter._context.comments; + const targetComments = Object.values(comments).filter(c => c.blockId === null); + expect(targetComments).toHaveLength(1); + expect(targetComments[0].text).toEqual('@ruby:class'); + }); + + test('class Stage < Foo is rejected', async () => { + code = ` + class Stage < Foo + self.when(:flag_clicked) do + switch_backdrop("Arctic") + end + end + `; + const res = await converter.targetCodeToBlocks(target, code); + expect(res).toBeFalsy(); + expect(converter.errors).toHaveLength(1); + }); + }); + describe('basic stage class syntax', () => { test('class Stage with when_flag_clicked', async () => { code = `