diff --git a/docs/smalruby-language-spec-v1-diff.ja.md b/docs/smalruby-language-spec-v1-diff.ja.md index 384494b31bb..07c737334ce 100644 --- a/docs/smalruby-language-spec-v1-diff.ja.md +++ b/docs/smalruby-language-spec-v1-diff.ja.md @@ -15,6 +15,16 @@ - `super` — モジュールのメソッドをオーバーライドしたクラスメソッドから呼び出す - ファイル保存時の `class Stage` 自動補完 +## リスト(データ) + +| 機能 | Version 2 | Version 1 | +|---|---|---| +| リスト参照 | `@items.push(...)` (配列を直接操作) | `list("@items").push(...)` (ラッパー関数) | +| インデックス | 0起点: `@items[0]` | 1起点: `list("@items")[1]` | +| 初期化 | `@items = [1, 2, 3]` (配列リテラル) | 使用不可 | +| ハッシュ | `$a[:key]`, `$a["key"]` | 使用不可 | +| 空チェック | `@items.empty?` | 使用不可 | + ## 調べる(Sensing) | 機能 | Version 2 | Version 1 | diff --git a/docs/smalruby-language-spec-v1-diff.md b/docs/smalruby-language-spec-v1-diff.md index de86cad9b35..1ed7198d4ba 100644 --- a/docs/smalruby-language-spec-v1-diff.md +++ b/docs/smalruby-language-spec-v1-diff.md @@ -15,6 +15,16 @@ The following features are **only available in Version 2**: - `super` — calling module methods from overriding class methods - Automatic `class Stage` completion on file save +## Lists (Data) + +| Feature | Version 2 | Version 1 | +|---|---|---| +| List reference | `@items.push(...)` (direct array) | `list("@items").push(...)` (wrapper) | +| Index | 0-indexed: `@items[0]` | 1-indexed: `list("@items")[1]` | +| Initialization | `@items = [1, 2, 3]` (array literal) | Not available | +| Hash | `$a[:key]`, `$a["key"]` | Not available | +| Empty check | `@items.empty?` | Not available | + ## Sensing | Feature | Version 2 | Version 1 | diff --git a/docs/smalruby-language-spec.ja.md b/docs/smalruby-language-spec.ja.md index 3895060aa9d..4500a79aada 100644 --- a/docs/smalruby-language-spec.ja.md +++ b/docs/smalruby-language-spec.ja.md @@ -661,20 +661,26 @@ show_variable("@score") hide_variable("@score") ``` -#### リストの使用 +#### リスト(配列)の使用 ```ruby -list("@items").push("りんご") # 追加 -list("@items").delete_at(1) # 削除 -list("@items").clear # 全削除 -list("@items").insert(1, "バナナ") # 挿入 -list("@items")[1] = "みかん" # 置換 -list("@items")[1] # 取得 -list("@items").index("りんご") # 検索 -list("@items").length # 長さ -list("@items").include?("りんご") # 含むか -show_list("@items") # リストの表示 -hide_list("@items") # リストの非表示 +# 配列リテラルで初期化 +@items = ["りんご", "バナナ", "さくらんぼ"] + +# 操作(0起点インデックス) +@items.push("りんご") # 末尾に追加 +@items.delete_at(0) # インデックス指定で削除(0起点) +@items.delete_at(-1) # 末尾を削除 +@items.clear # 全削除 +@items.insert(0, "バナナ") # インデックス指定で挿入(0起点) +@items[0] = "みかん" # インデックス指定で置換(0起点) +@items[0] # インデックス指定で取得(0起点) +@items.index("りんご") # 検索(0起点のインデックスを返す、見つからない場合は0) +@items.length # 長さ +@items.include?("りんご") # 含むか +@items.empty? # 空か +show_list("@items") # リストの表示 +hide_list("@items") # リストの非表示 ``` ## 5. サポートされていないRuby構文 diff --git a/docs/smalruby-language-spec.md b/docs/smalruby-language-spec.md index 1724c53c486..0dec2f10f64 100644 --- a/docs/smalruby-language-spec.md +++ b/docs/smalruby-language-spec.md @@ -624,20 +624,26 @@ show_variable("@score") hide_variable("@score") ``` -#### Lists +#### Lists (Arrays) ```ruby -list("@items").push("apple") # Add -list("@items").delete_at(1) # Delete -list("@items").clear # Delete all -list("@items").insert(1, "banana") # Insert -list("@items")[1] = "orange" # Replace -list("@items")[1] # Get -list("@items").index("apple") # Search -list("@items").length # Length -list("@items").include?("apple") # Contains? -show_list("@items") # Show list -hide_list("@items") # Hide list +# Initialize with array literal +@items = ["apple", "banana", "cherry"] + +# Operations (0-indexed) +@items.push("apple") # Add to end +@items.delete_at(0) # Delete at index (0-indexed) +@items.delete_at(-1) # Delete last +@items.clear # Delete all +@items.insert(0, "banana") # Insert at index (0-indexed) +@items[0] = "orange" # Replace at index (0-indexed) +@items[0] # Get at index (0-indexed) +@items.index("apple") # Search (returns 0-based index, 0 if not found) +@items.length # Length +@items.include?("apple") # Contains? +@items.empty? # Empty? +show_list("@items") # Show list +hide_list("@items") # Hide list ``` ## 5. Unsupported Ruby Syntax diff --git a/infra/smalruby-rubytee-relay/lambda/handler.ts b/infra/smalruby-rubytee-relay/lambda/handler.ts index f06857e9df1..47cfb356600 100644 --- a/infra/smalruby-rubytee-relay/lambda/handler.ts +++ b/infra/smalruby-rubytee-relay/lambda/handler.ts @@ -316,9 +316,9 @@ Smalruby is a Ruby subset with methods corresponding to MIT Scratch 3.0 visual p - \`touching_color?("#rrggbb")\` - \`distance("_mouse_")\` - \`ask("question")\` / \`answer\` -- \`Keyboard.pressed?("key")\` -- \`Mouse.down?\` / \`Mouse.x\` / \`Mouse.y\` -- \`Timer.value\` / \`Timer.reset\` +- \`keyboard.pressed?("key")\` +- \`mouse.down?\` / \`mouse.x\` / \`mouse.y\` +- \`timer.value\` / \`timer.reset\` - \`loudness\` ### Operators @@ -334,6 +334,21 @@ Smalruby is a Ruby subset with methods corresponding to MIT Scratch 3.0 visual p - Local variables: \`count = 0\` - \`show_variable("@score")\` / \`hide_variable("@score")\` +### Lists (Arrays) +- Initialize with array literal: \`@items = ["apple", "banana"]\` +- \`@items.push("cherry")\` — add to end +- \`@items.delete_at(0)\` — delete at index (0-indexed) +- \`@items.delete_at(-1)\` — delete last +- \`@items.clear\` — delete all +- \`@items.insert(0, "grape")\` — insert at index (0-indexed) +- \`@items[0] = "orange"\` — replace at index (0-indexed) +- \`@items[0]\` — get at index (0-indexed) +- \`@items.index("apple")\` — search (returns 0-based index, 0 if not found) +- \`@items.length\` — length +- \`@items.include?("apple")\` — contains? +- \`@items.empty?\` — empty? +- \`show_list("@items")\` / \`hide_list("@items")\` — show/hide list monitor + ### Module / Include (Version 2 only) - Define reusable methods in a \`module\`, then \`include\` in a class to share across sprites - \`module ModuleName ... end\` — define a module with \`def\` methods only @@ -395,10 +410,12 @@ Do NOT use these — they do not exist: - ❌ \`set_x()\`, \`set_y()\`, \`change_x()\`, \`change_y()\` → ✅ \`self.x =\`, \`self.y =\`, \`self.x +=\`, \`self.y +=\` - ❌ \`set_direction()\` → ✅ \`self.direction =\` - ❌ \`set_size()\`, \`change_size()\` → ✅ \`self.size =\`, \`self.size +=\` -- ❌ \`mouse_x\`, \`mouse_y\` → ✅ \`Mouse.x\`, \`Mouse.y\` -- ❌ \`mouse_down?\` → ✅ \`Mouse.down?\` -- ❌ \`key_pressed?()\` → ✅ \`Keyboard.pressed?()\` -- ❌ \`timer\`, \`reset_timer\` → ✅ \`Timer.value\`, \`Timer.reset\` +- ❌ \`mouse_x\`, \`mouse_y\` → ✅ \`mouse.x\`, \`mouse.y\` +- ❌ \`mouse_down?\` → ✅ \`mouse.down?\` +- ❌ \`key_pressed?()\`, \`Keyboard.pressed?()\` → ✅ \`keyboard.pressed?()\` +- ❌ \`timer\`, \`reset_timer\`, \`Timer.value\`, \`Timer.reset\` → ✅ \`timer.value\`, \`timer.reset\` +- ❌ \`Mouse.x\`, \`Mouse.y\`, \`Mouse.down?\` → ✅ \`mouse.x\`, \`mouse.y\`, \`mouse.down?\` +- ❌ \`list("@items")\` → ✅ \`@items\` (use array directly: \`@items.push(...)\`, \`@items[0]\`) - ❌ \`touching?("_mouse_pointer_")\` → ✅ \`touching?("_mouse_")\` - ❌ \`play_sound()\`, \`stop_sounds\` → ✅ \`play()\`, \`stop_all_sounds\` - ❌ \`clear_effects\` → ✅ \`clear_graphic_effects\` @@ -586,9 +603,10 @@ When using \`set_sprite\`, only the following sprite names are available: These are the most common mistakes. **Always verify your output against these rules**: 1. **\`self.x =\` NOT \`set_x()\`**: Outside class definitions, always use \`self.x = value\`, \`self.y = value\`, \`self.size = value\`, etc. The \`set_x()\`, \`set_y()\`, \`set_size()\` methods are ONLY valid at class definition top-level. -2. **\`Keyboard.pressed?\` NOT \`key_pressed?\`**: Always use \`Keyboard.pressed?("key")\`, never \`key_pressed?\`. -3. **\`Mouse.x\` NOT \`mouse_x\`**: Always use \`Mouse.x\`, \`Mouse.y\`, \`Mouse.down?\`. -4. **\`Timer.value\` NOT \`timer\`**: Always use \`Timer.value\` and \`Timer.reset\`. +2. **\`keyboard.pressed?\` NOT \`key_pressed?\`**: Always use \`keyboard.pressed?("key")\`, never \`key_pressed?\` or \`Keyboard.pressed?\`. +3. **\`mouse.x\` NOT \`mouse_x\`**: Always use \`mouse.x\`, \`mouse.y\`, \`mouse.down?\` (lowercase). +4. **\`timer.value\` NOT \`timer\`**: Always use \`timer.value\` and \`timer.reset\` (lowercase). +13. **\`@items.push()\` NOT \`list("@items").push()\`**: Use arrays directly. \`list()\` does not exist. 5. **\`touching?("_mouse_")\` NOT \`touching?("_mouse_pointer_")\`**: The target name is \`"_mouse_"\`, not \`"_mouse_pointer_"\`. 6. **\`glide([x, y], secs: n)\` NOT \`glide(n, x, y)\`**: Coordinates in array, seconds as keyword argument. 7. **\`go_to([x, y])\` NOT \`go_to(x, y)\`**: Coordinates must be in an array. diff --git a/packages/scratch-gui/src/lib/ruby-generator/data-list.js b/packages/scratch-gui/src/lib/ruby-generator/data-list.js index 089ac08f68d..02452e8bc4f 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/data-list.js +++ b/packages/scratch-gui/src/lib/ruby-generator/data-list.js @@ -161,8 +161,24 @@ export default function (Generator) { return `${hashVarName}["${rawKey}"] = ${Generator.nosToCode(value)}\n`; } - const index = getListIndex(block); const list = getListName(block); + + // Check for round-trip comments (stored as block comments, not inline) + if (comment && comment.includes('@ruby:array:delete_at:last')) { + return `${list}.delete_at(-1)\n`; + } + if (comment && comment.includes('@ruby:array:delete_at:random')) { + return `${list}.delete_at(rand(0...${list}.length))\n`; + } + + const rawIndex = Generator.valueToCode(block, 'INDEX', Generator.ORDER_NONE) || 1; + if (rawIndex === 'last') { + return `${list}.delete_at(-1)\n`; + } + if (rawIndex === 'random') { + return `${list}.delete_at(rand(0...${list}.length))\n`; + } + const index = getListIndex(block); return `${list}.delete_at(${Generator.nosToCode(index)})\n`; }; @@ -233,9 +249,30 @@ export default function (Generator) { }; Generator.data_insertatlist = function (block) { - const index = getListIndex(block); - const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0'; const list = getListName(block); + const comment = Generator.getCommentText(block); + + // Check for round-trip comments (stored as block comments, not inline) + if (comment && comment.includes('@ruby:array:insert:last')) { + const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0'; + return `${list}.push(${Generator.nosToCode(item)})\n`; + } + if (comment && comment.includes('@ruby:array:insert:random')) { + const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0'; + const randExpr = `rand(0..${list}.length)`; + return `${list}.insert(${randExpr}, ${Generator.nosToCode(item)})\n`; + } + + const rawIndex = Generator.valueToCode(block, 'INDEX', Generator.ORDER_NONE) || 1; + const item = Generator.valueToCode(block, 'ITEM', Generator.ORDER_NONE) || '0'; + if (rawIndex === 'last') { + return `${list}.push(${Generator.nosToCode(item)})\n`; + } + if (rawIndex === 'random') { + const randExpr = `rand(0..${list}.length)`; + return `${list}.insert(${randExpr}, ${Generator.nosToCode(item)})\n`; + } + const index = getListIndex(block); return `${list}.insert(${index}, ${Generator.nosToCode(item)})\n`; }; diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-list-ops.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-list-ops.js index 154e0e7bac6..aa58e7606b9 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-list-ops.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/variable-list-ops.js @@ -158,6 +158,17 @@ export const registerListOperations = function ( const {block: listBlock, converted} = convertToListBlock(converter, messages, receiver); const recv = converted ? listBlock : receiver; if (!recv) return null; + + // Detect delete_at(-1) as "last" special value + if (converter._isNumber(args[0]) && Number(args[0]) === -1) { + const block = converter._changeBlock(recv, 'data_deleteoflist', 'statement'); + converter._addNumberInput(block, 'INDEX', 'math_integer', 1, 1); + block.comment = converter._createComment( + '@ruby:array:delete_at:last', block.id + ); + return block; + } + const index = adjustIndex(converter, args[0], converted); const block = converter._changeBlock(recv, 'data_deleteoflist', 'statement'); diff --git a/packages/scratch-gui/test/unit/lib/ruby-generator/data.test.js b/packages/scratch-gui/test/unit/lib/ruby-generator/data.test.js index 840de3d5a5a..2da90bc7957 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-generator/data.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-generator/data.test.js @@ -351,4 +351,72 @@ describe('RubyGenerator/Data', () => { .toEqual(''); }); }); + + describe('delete_at / insert special values', () => { + beforeEach(() => { + RubyGenerator.listName = jest.fn().mockReturnValue('@my_list'); + RubyGenerator.getFieldId = jest.fn().mockReturnValue('list-id'); + RubyGenerator.nosToCode = jest.fn(v => v); + }); + + test('data_deleteoflist with INDEX "last" generates delete_at(-1)', () => { + const block = { + id: 'block-id', + opcode: 'data_deleteoflist', + fields: {LIST: {id: 'list-id', value: 'my list'}}, + inputs: {INDEX: {block: 'index-block-id'}} + }; + RubyGenerator.valueToCode = jest.fn().mockReturnValue('last'); + RubyGenerator.getBlock = jest.fn().mockReturnValue(null); + expect(RubyGenerator.data_deleteoflist(block)) + .toEqual('@my_list.delete_at(-1)\n'); + }); + + test('data_deleteoflist with INDEX "random" generates delete_at(rand(...))', () => { + const block = { + id: 'block-id', + opcode: 'data_deleteoflist', + fields: {LIST: {id: 'list-id', value: 'my list'}}, + inputs: {INDEX: {block: 'index-block-id'}} + }; + RubyGenerator.valueToCode = jest.fn().mockReturnValue('random'); + RubyGenerator.getBlock = jest.fn().mockReturnValue(null); + expect(RubyGenerator.data_deleteoflist(block)) + .toEqual('@my_list.delete_at(rand(0...@my_list.length))\n'); + }); + + test('data_insertatlist with INDEX "last" generates push', () => { + const block = { + id: 'block-id', + opcode: 'data_insertatlist', + fields: {LIST: {id: 'list-id', value: 'my list'}}, + inputs: { + INDEX: {block: 'index-block-id'}, + ITEM: {block: 'item-block-id'} + } + }; + RubyGenerator.valueToCode = jest.fn() + .mockReturnValueOnce('last') + .mockReturnValueOnce('"thing"'); + expect(RubyGenerator.data_insertatlist(block)) + .toEqual('@my_list.push("thing")\n'); + }); + + test('data_insertatlist with INDEX "random" generates insert(rand(...))', () => { + const block = { + id: 'block-id', + opcode: 'data_insertatlist', + fields: {LIST: {id: 'list-id', value: 'my list'}}, + inputs: { + INDEX: {block: 'index-block-id'}, + ITEM: {block: 'item-block-id'} + } + }; + RubyGenerator.valueToCode = jest.fn() + .mockReturnValueOnce('random') + .mockReturnValueOnce('"thing"'); + expect(RubyGenerator.data_insertatlist(block)) + .toEqual('@my_list.insert(rand(0..@my_list.length), "thing")\n'); + }); + }); }); diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js index 0826c747422..e6a6aa995fb 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/round-trip.test.js @@ -507,6 +507,19 @@ end await expectRoundTrip('@items[0]'); await expectRoundTrip('@items.length'); }); + + test('delete_at(-1) round-trips as last', async () => { + await expectRoundTrip('$a.delete_at(-1)'); + }); + + test('empty? method', async () => { + await expectRoundTrip('$a.empty?'); + }); + + test('show_list and hide_list', async () => { + await expectRoundTrip('show_list("@items")'); + await expectRoundTrip('hide_list("@items")'); + }); }); test('symbol .to_s round-trip', async () => { diff --git a/ruby/smalruby3/lib/smalruby3/list.rb b/ruby/smalruby3/lib/smalruby3/list.rb index ec95bf11f1c..d8bb7549ef3 100644 --- a/ruby/smalruby3/lib/smalruby3/list.rb +++ b/ruby/smalruby3/lib/smalruby3/list.rb @@ -11,16 +11,7 @@ def push(value) end def delete_at(index) - # Scratch uses 1-based index; "all" deletes everything; "last" deletes last - case index - when "all" - @items.clear - when "last" - @items.pop - else - i = index.to_i - 1 - @items.delete_at(i) if i >= 0 && i < @items.size - end + @items.delete_at(index) end def clear @@ -28,34 +19,19 @@ def clear end def insert(index, value) - # 1-based; "last" = append; "random" = random position - case index - when "last" - @items.push(value) - when "random" - pos = @items.empty? ? 0 : rand(0..@items.size) - @items.insert(pos, value) - else - i = index.to_i - 1 - i = i.clamp(0, @items.size) - @items.insert(i, value) - end + @items.insert(index, value) end def [](index) - i = index.to_i - 1 # 1-based - return "" if i < 0 || i >= @items.size - @items[i] + @items[index] end def []=(index, value) - i = index.to_i - 1 # 1-based - @items[i] = value if i >= 0 && i < @items.size + @items[index] = value end def index(value) - idx = @items.index(value) - idx ? idx + 1 : 0 # 1-based, 0 = not found + @items.index(value) end def length diff --git a/ruby/smalruby3/lib/smalruby3/render/monitor_renderer.rb b/ruby/smalruby3/lib/smalruby3/render/monitor_renderer.rb index 267b446d8b0..86d2e3772cc 100644 --- a/ruby/smalruby3/lib/smalruby3/render/monitor_renderer.rb +++ b/ruby/smalruby3/lib/smalruby3/render/monitor_renderer.rb @@ -44,7 +44,7 @@ def draw(targets) if key.start_with?("list:") name = key.delete_prefix("list:") - list_val = target.list(name) + list_val = target.instance_variable_get(name.to_s.start_with?("@") ? name.to_sym : :"@#{name}") y_off = draw_list(target, name, list_val, y_off) else value = target.variable(key) diff --git a/ruby/smalruby3/lib/smalruby3/target.rb b/ruby/smalruby3/lib/smalruby3/target.rb index 631554bddd4..6b82997002c 100644 --- a/ruby/smalruby3/lib/smalruby3/target.rb +++ b/ruby/smalruby3/lib/smalruby3/target.rb @@ -162,15 +162,6 @@ def clear_sound_effects # --- Data --- - def list(name) - var = instance_variable_get(name.to_s.start_with?("@") ? name.to_sym : :"@#{name}") - case var - when List then var - when Array then var - else List.new - end - end - def show_variable(name) @monitors[name] = :visible end diff --git a/ruby/smalruby3/test/list_test.rb b/ruby/smalruby3/test/list_test.rb index 2feeca93240..25ac4e8ca36 100644 --- a/ruby/smalruby3/test/list_test.rb +++ b/ruby/smalruby3/test/list_test.rb @@ -10,64 +10,64 @@ def test_push_and_length assert_equal 2, list.length end - def test_get_1_based + def test_get_0_based list = Smalruby3::List.new(["a", "b", "c"]) - assert_equal "a", list[1] - assert_equal "b", list[2] - assert_equal "c", list[3] + assert_equal "a", list[0] + assert_equal "b", list[1] + assert_equal "c", list[2] + end + + def test_get_negative_index + list = Smalruby3::List.new(["a", "b", "c"]) + assert_equal "c", list[-1] + assert_equal "b", list[-2] end def test_get_out_of_range list = Smalruby3::List.new(["a"]) - assert_equal "", list[0] - assert_equal "", list[2] + assert_nil list[3] + assert_nil list[-3] end - def test_set_1_based + def test_set_0_based list = Smalruby3::List.new(["a", "b", "c"]) - list[2] = "x" - assert_equal "x", list[2] + list[1] = "x" + assert_equal "x", list[1] end - def test_delete_at_1_based + def test_set_negative_index list = Smalruby3::List.new(["a", "b", "c"]) - list.delete_at(2) - assert_equal 2, list.length - assert_equal "a", list[1] - assert_equal "c", list[2] + list[-1] = "z" + assert_equal "z", list[2] end - def test_delete_at_all + def test_delete_at_0_based list = Smalruby3::List.new(["a", "b", "c"]) - list.delete_at("all") - assert_equal 0, list.length + list.delete_at(1) + assert_equal 2, list.length + assert_equal "a", list[0] + assert_equal "c", list[1] end - def test_delete_at_last + def test_delete_at_negative list = Smalruby3::List.new(["a", "b", "c"]) - list.delete_at("last") + list.delete_at(-1) assert_equal 2, list.length - assert_equal "b", list[2] + assert_equal "b", list[1] end - def test_insert_1_based + def test_insert_0_based list = Smalruby3::List.new(["a", "c"]) - list.insert(2, "b") - assert_equal "a", list[1] - assert_equal "b", list[2] - assert_equal "c", list[3] - end - - def test_insert_last - list = Smalruby3::List.new(["a"]) - list.insert("last", "b") - assert_equal "b", list[2] + list.insert(1, "b") + assert_equal "a", list[0] + assert_equal "b", list[1] + assert_equal "c", list[2] end - def test_index_1_based + def test_index_0_based list = Smalruby3::List.new(["apple", "banana", "cherry"]) - assert_equal 2, list.index("banana") - assert_equal 0, list.index("grape") # 0 = not found + assert_equal 1, list.index("banana") + assert_nil list.index("grape") end def test_include_case_insensitive @@ -87,4 +87,9 @@ def test_to_s list = Smalruby3::List.new(["hello", "world"]) assert_equal "hello world", list.to_s end + + def test_to_a + list = Smalruby3::List.new(["a", "b"]) + assert_equal ["a", "b"], list.to_a + end end