From 417490837101146c7975f3b4a7ed75e77d8eaedf Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 30 Mar 2026 07:58:11 +0900 Subject: [PATCH 01/16] feat: validate Ruby code before switching to DNCL mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When switching to DNCL mode, perform a dry-run validation pipeline: Ruby → DNCL → Ruby → Blocks conversion (without apply). If the blocks conversion fails, show errors in Monaco editor with prefix "日本語モードでは対応していない記述です:" and block the mode switch. During validation, a spinner is shown on the DNCL button and all mode toggle buttons are disabled. Closes #424 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ruby-toolbar/ruby-toolbar.jsx | 11 +- .../scratch-gui/src/containers/ruby-tab.jsx | 57 ++++++- .../unit/lib/dncl/dncl-validation.test.js | 144 ++++++++++++++++++ 3 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 packages/scratch-gui/test/unit/lib/dncl/dncl-validation.test.js diff --git a/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx b/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx index 3e718dec522..05a354da7d7 100644 --- a/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx +++ b/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx @@ -15,6 +15,7 @@ import iconRedo from './icon--redo.svg'; import iconDownload from './icon--download.svg'; import iconAutoCorrect from './icon--auto-correct.svg'; import iconRubytee from './icon--rubytee.svg'; +import Spinner from '../spinner/spinner.jsx'; const RubyToolbar = props => { const intl = useIntl(); @@ -246,6 +247,7 @@ const RubyToolbar = props => { !props.dnclMode && props.furiganaEnabled ? styles.modeToggleItemActive : '' }`} + disabled={props.dnclValidating} onClick={handleSelectFuriganaMode} title={intl.formatMessage(messages.modeFurigana)} > @@ -263,6 +265,7 @@ const RubyToolbar = props => { !props.dnclMode && !props.furiganaEnabled ? styles.modeToggleItemActive : '' }`} + disabled={props.dnclValidating} onClick={handleSelectRubyMode} title={intl.formatMessage(messages.modeRuby)} > @@ -272,10 +275,15 @@ const RubyToolbar = props => { className={`${styles.modeToggleItem} ${ props.dnclMode ? styles.modeToggleItemActive : '' }`} + disabled={props.dnclValidating} onClick={handleSelectDnclMode} title={intl.formatMessage(messages.modeDncl)} > - {intl.formatMessage(messages.dnclLabel)} + {props.dnclValidating ? ( + + ) : ( + intl.formatMessage(messages.dnclLabel) + )} @@ -354,6 +362,7 @@ RubyToolbar.propTypes = { canUndo: PropTypes.bool, canRedo: PropTypes.bool, dnclMode: PropTypes.bool, + dnclValidating: PropTypes.bool, onToggleDnclMode: PropTypes.func, furiganaEnabled: PropTypes.bool, onToggleFurigana: PropTypes.func, diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index 2564a431978..5f6e33b1f3c 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -147,6 +147,7 @@ const RubyTab = props => { const [showScriptPreview, setShowScriptPreview] = useState(false); const [previewCode, setPreviewCode] = useState(''); // === Smalruby: Start of DNCL mode state === + const [dnclValidating, setDnclValidating] = useState(false); const [dnclMode, setDnclMode] = useState(() => { const urlRubyMode = getUrlParams().rubyMode; if (urlRubyMode === 'dncl') return true; @@ -558,23 +559,64 @@ const RubyTab = props => { ); // showErrors uses refs, safe in stale closure // === Smalruby: Start of DNCL mode toggle === - const handleToggleDnclMode = useCallback(() => { + const DNCL_ERROR_PREFIX = '日本語モードでは対応していない記述です: '; + + const handleToggleDnclMode = useCallback(async () => { + const enabling = !dnclModeRef.current; + + // Validate before switching TO DNCL mode: + // Dry-run Ruby → DNCL → Ruby → Blocks to check for unsupported code. + if (enabling && editorRef.current && monacoRef.current) { + setDnclValidating(true); + try { + const currentRuby = editorRef.current.getModel().getValue(); + const dnclResult = rubyToDncl(currentRuby); + const rubyResult = dnclToRuby(dnclResult.dncl); + + // Check DNCL → Ruby conversion errors (e.g. @ or $ in DNCL) + if (rubyResult.errors && rubyResult.errors.length > 0) { + const errors = rubyResult.errors.map(err => ({ + row: err.line - 1, + column: err.column - 1, + text: `${DNCL_ERROR_PREFIX}${err.message}`, + type: 'error', + })); + showErrors(errors); + return; + } + + // Dry-run blocks conversion (no apply — no side effects) + const converter = await targetCodeToBlocks(vm, rubyCode.target, rubyResult.ruby, intl, { + version: rubyVersion, + }); + if (!converter.result) { + const errors = converter.errors.map(err => ({ + ...err, + text: `${DNCL_ERROR_PREFIX}${err.text}`, + })); + showErrors(errors); + return; + } + } finally { + setDnclValidating(false); + } + } + // Suppress handleEditorChange during mode switch to prevent a // re-render race: model.setValue triggers onChange synchronously, // which dispatches to Redux, causing a re-render where dnclMode // state hasn't committed yet — overwriting the editor content. isModeSwitchRef.current = true; - const enabled = !dnclModeRef.current; - dnclModeRef.current = enabled; + dnclModeRef.current = enabling; // eslint-disable-line require-atomic-updates if (typeof window !== 'undefined' && window.localStorage) { - window.localStorage.setItem(DNCL_MODE_KEY, enabled); + window.localStorage.setItem(DNCL_MODE_KEY, enabling); } if (editorRef.current && monacoRef.current) { const model = editorRef.current.getModel(); - if (enabled) { + if (enabling) { // Switching to DNCL: convert Ruby → DNCL const currentRuby = model.getValue(); const result = rubyToDncl(currentRuby); @@ -595,8 +637,8 @@ const RubyTab = props => { } isModeSwitchRef.current = false; - setDnclMode(enabled); - }, []); + setDnclMode(enabling); + }, [vm, rubyCode.target, intl, rubyVersion]); // === Smalruby: End of DNCL mode toggle === const handleToggleFurigana = useCallback(() => { @@ -1095,6 +1137,7 @@ const RubyTab = props => { onPreviewRubyScript={handlePreviewRubyScript} onOpenRubyteeModal={onOpenRubyteeModal} dnclMode={dnclMode} + dnclValidating={dnclValidating} onToggleDnclMode={handleToggleDnclMode} />
diff --git a/packages/scratch-gui/test/unit/lib/dncl/dncl-validation.test.js b/packages/scratch-gui/test/unit/lib/dncl/dncl-validation.test.js new file mode 100644 index 00000000000..6462a51ce5f --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/dncl/dncl-validation.test.js @@ -0,0 +1,144 @@ +/** + * Tests for DNCL mode switch validation. + * + * When switching from Ruby to DNCL mode, the editor performs a dry-run: + * Ruby → DNCL → Ruby → Blocks. If the blocks conversion fails, the switch + * is blocked and errors are shown with a prefix. + */ +import { rubyToDncl } from '../../../../src/lib/dncl/ruby-to-dncl'; +import { dnclToRuby } from '../../../../src/lib/dncl/dncl-to-ruby'; +import { + makeSpriteTarget, + makeConverter, +} from '../../helpers/ruby-roundtrip-helper'; + +const DNCL_ERROR_PREFIX = '日本語モードでは対応していない記述です: '; + +/** + * Simulate the DNCL mode switch validation pipeline: + * 1. Ruby → DNCL + * 2. DNCL → Ruby + * 3. Ruby → Blocks (dry run, no apply) + * + * Returns { valid, errors } where errors have the prefix applied. + */ +const validateForDncl = async (code) => { + const dnclResult = rubyToDncl(code); + const rubyResult = dnclToRuby(dnclResult.dncl); + + if (rubyResult.errors && rubyResult.errors.length > 0) { + return { + valid: false, + errors: rubyResult.errors.map((err) => ({ + row: err.line - 1, + column: err.column - 1, + text: `${DNCL_ERROR_PREFIX}${err.message}`, + type: 'error', + })), + }; + } + + const { target, runtime } = makeSpriteTarget(); + const converter = makeConverter(target, runtime, { version: '2' }); + const result = await converter.targetCodeToBlocks(target, rubyResult.ruby); + + if (!result) { + return { + valid: false, + errors: converter.errors.map((err) => ({ + ...err, + text: `${DNCL_ERROR_PREFIX}${err.text}`, + })), + }; + } + + return { valid: true, errors: [] }; +}; + +describe('DNCL mode switch validation', () => { + describe('valid code (should allow switch)', () => { + test('empty code', async () => { + const { valid } = await validateForDncl(''); + expect(valid).toBe(true); + }); + + test('simple variable assignment and say', async () => { + const code = '@x = 10\nsay("hello", 1)\n'; + const { valid, errors } = await validateForDncl(code); + expect(errors).toHaveLength(0); + expect(valid).toBe(true); + }); + + test('variable with if and say', async () => { + const code = [ + '@x = 10', + 'if @x > 5', + ' say("big", 1)', + 'end', + '', + ].join('\n'); + const { valid } = await validateForDncl(code); + expect(valid).toBe(true); + }); + }); + + describe('invalid code (should block switch)', () => { + test('when_flag_clicked breaks in DNCL round-trip', async () => { + const code = + 'when_flag_clicked do\n say("hello", 1)\nend\n'; + const { valid, errors } = await validateForDncl(code); + expect(valid).toBe(false); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].text).toMatch(DNCL_ERROR_PREFIX); + }); + + test('when_key_pressed is not supported in DNCL', async () => { + const code = + 'when_key_pressed("a") do\n say("hello", 1)\nend\n'; + const { valid, errors } = await validateForDncl(code); + expect(valid).toBe(false); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].text).toMatch(DNCL_ERROR_PREFIX); + }); + + test('move is not supported in DNCL', async () => { + const code = 'move(10)\n'; + const { valid, errors } = await validateForDncl(code); + expect(valid).toBe(false); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].text).toMatch(DNCL_ERROR_PREFIX); + }); + + test('turn_right is not supported in DNCL', async () => { + const code = 'turn_right(90)\n'; + const { valid, errors } = await validateForDncl(code); + expect(valid).toBe(false); + expect(errors.length).toBeGreaterThan(0); + }); + + test('broadcast is not supported in DNCL', async () => { + const code = 'broadcast("message1")\n'; + const { valid, errors } = await validateForDncl(code); + expect(valid).toBe(false); + expect(errors.length).toBeGreaterThan(0); + }); + + test('when_clicked is not supported in DNCL', async () => { + const code = 'when_clicked do\n say("hello", 1)\nend\n'; + const { valid, errors } = await validateForDncl(code); + expect(valid).toBe(false); + expect(errors.length).toBeGreaterThan(0); + }); + }); + + describe('error message prefix', () => { + test('errors include the DNCL prefix', async () => { + const code = + 'when_key_pressed("a") do\n say("hello", 1)\nend\n'; + const { errors } = await validateForDncl(code); + for (const err of errors) { + expect(err.text).toContain(DNCL_ERROR_PREFIX); + } + }); + }); +}); From c8331c06f1bf55c01301e6a8e1c3d491941e9997 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 30 Mar 2026 08:25:11 +0900 Subject: [PATCH 02/16] test: add integration tests for DNCL mode validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Selenium-based integration tests verifying: - Valid code (simple variables + say) allows DNCL switch - Invalid code (move) blocks switch and shows error markers with prefix - when_flag_clicked blocks switch (round-trip breaks it) - DNCL → Ruby switch is not affected by validation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scratch-gui/smalruby-prettier-files.md | 1 + packages/scratch-gui/.prettierignore | 1 + .../integration/dncl-mode-validation.test.js | 117 ++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 packages/scratch-gui/test/integration/dncl-mode-validation.test.js diff --git a/.claude/rules/scratch-gui/smalruby-prettier-files.md b/.claude/rules/scratch-gui/smalruby-prettier-files.md index 5ca6c548731..88a3bf8b692 100644 --- a/.claude/rules/scratch-gui/smalruby-prettier-files.md +++ b/.claude/rules/scratch-gui/smalruby-prettier-files.md @@ -128,6 +128,7 @@ upstream (Scratch) ファイルは対象外。 - `test/integration/block-display-modal.test.js` - `test/integration/block-palette.test.js` - `test/integration/debug_defaults.test.js` +- `test/integration/dncl-mode-validation.test.js` - `test/integration/feedback-link.test.js` - `test/integration/ruby-editor-actions.test.js` - `test/integration/ruby-module.test.js` diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index 355f8cd0c17..fcd3de8668a 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -172,6 +172,7 @@ test/integration/* !test/integration/block-display-modal.test.js !test/integration/block-palette.test.js !test/integration/debug_defaults.test.js +!test/integration/dncl-mode-validation.test.js !test/integration/feedback-link.test.js !test/integration/ruby-editor-actions.test.js !test/integration/ruby-module.test.js diff --git a/packages/scratch-gui/test/integration/dncl-mode-validation.test.js b/packages/scratch-gui/test/integration/dncl-mode-validation.test.js new file mode 100644 index 00000000000..ca2dc4db6d0 --- /dev/null +++ b/packages/scratch-gui/test/integration/dncl-mode-validation.test.js @@ -0,0 +1,117 @@ +import path from 'path'; +import RubyHelper from '../helpers/ruby-helper'; +import SeleniumHelper from '../helpers/selenium-helper'; + +const seleniumHelper = new SeleniumHelper(); +const { + /* eslint-disable no-unused-vars */ + clickText, + clickButton, + clickXpath, + findByText, + findByXpath, + getDriver, + getLogs, + loadUri, + waitForLoadingFinished, + notExistsByXpath, + scope, + /* eslint-enable no-unused-vars */ +} = seleniumHelper; +const rubyHelper = new RubyHelper(seleniumHelper); +const { fillInRubyProgram, currentRubyProgram, getErrors, waitForErrorOnLine, waitForNoErrors } = rubyHelper; + +const uri = path.resolve(__dirname, '../../build/index.html'); + +const DNCL_BUTTON_XPATH = '//button[@title="DNCL mode"]'; + +let driver; + +describe('DNCL mode validation on switch', () => { + beforeAll(() => { + driver = getDriver(); + }); + + afterAll(async () => { + await driver.quit(); + }); + + test('valid code allows DNCL switch', async () => { + await loadUri(uri); + await clickText('Ruby', '*[@role="tab"]'); + await fillInRubyProgram('@x = 10\nsay("hello", 1)\n'); + + // Click DNCL button + await clickXpath(DNCL_BUTTON_XPATH); + + // Wait for validation to complete and mode to switch + await driver.sleep(3000); + + // DNCL button should be active (has active class) + const dnclButton = await driver.findElement(seleniumHelper.By.xpath(DNCL_BUTTON_XPATH)); + const className = await dnclButton.getAttribute('class'); + expect(className).toContain('Active'); + }); + + test('invalid code blocks DNCL switch and shows errors', async () => { + await loadUri(uri); + await clickText('Ruby', '*[@role="tab"]'); + await fillInRubyProgram('move(10)\n'); + + // Click DNCL button + await clickXpath(DNCL_BUTTON_XPATH); + + // Wait for validation to complete + await driver.sleep(3000); + + // DNCL button should NOT be active (validation failed) + const dnclButton = await driver.findElement(seleniumHelper.By.xpath(DNCL_BUTTON_XPATH)); + const className = await dnclButton.getAttribute('class'); + expect(className).not.toContain('Active'); + + // Errors should be shown in the editor + const errors = await getErrors(); + expect(errors.length).toBeGreaterThan(0); + + // Error message should have the DNCL prefix + expect(errors[0].message).toContain('日本語モードでは対応していない記述です'); + }); + + test('when_flag_clicked blocks DNCL switch', async () => { + await loadUri(uri); + await clickText('Ruby', '*[@role="tab"]'); + await fillInRubyProgram('when_flag_clicked do\n say("hello", 1)\nend\n'); + + // Click DNCL button + await clickXpath(DNCL_BUTTON_XPATH); + + // Wait for validation to complete + await driver.sleep(3000); + + // DNCL button should NOT be active + const dnclButton = await driver.findElement(seleniumHelper.By.xpath(DNCL_BUTTON_XPATH)); + const className = await dnclButton.getAttribute('class'); + expect(className).not.toContain('Active'); + + // Errors should be shown + const errors = await getErrors(); + expect(errors.length).toBeGreaterThan(0); + }); + + test('DNCL to Ruby switch is not affected', async () => { + await loadUri(`${uri}?rubyMode=dncl`); + await clickText('Ruby', '*[@role="tab"]'); + + // Should start in DNCL mode + const dnclButton = await driver.findElement(seleniumHelper.By.xpath(DNCL_BUTTON_XPATH)); + let className = await dnclButton.getAttribute('class'); + expect(className).toContain('Active'); + + // Click Ruby button to switch back — should always work + await clickXpath('//button[@title="Ruby mode"]'); + await driver.sleep(500); + + className = await dnclButton.getAttribute('class'); + expect(className).not.toContain('Active'); + }); +}); From 41e3709d6fcd3c752f2c3582ba91c8d09695a311 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 30 Mar 2026 08:32:32 +0900 Subject: [PATCH 03/16] chore: add data-testid attributes to ruby toolbar for Playwright testing Add data-testid to all buttons, inputs, and menu items in ruby-toolbar and target-selector components. Create e2e-test.md rules documenting the naming convention and usage patterns with Playwright MCP. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/rules/scratch-gui/e2e-test.md | 96 +++++++++++++++++++ .../components/ruby-toolbar/ruby-toolbar.jsx | 14 +++ .../ruby-toolbar/target-selector.jsx | 3 + 3 files changed, 113 insertions(+) create mode 100644 .claude/rules/scratch-gui/e2e-test.md diff --git a/.claude/rules/scratch-gui/e2e-test.md b/.claude/rules/scratch-gui/e2e-test.md new file mode 100644 index 00000000000..34cff5b7e13 --- /dev/null +++ b/.claude/rules/scratch-gui/e2e-test.md @@ -0,0 +1,96 @@ +# E2E Testing with Playwright MCP + +## data-testid Convention + +Playwright MCP でのブラウザ操作には `data-testid` 属性を使用する。各コンポーネントのボタンやフォーム要素に `data-testid` を設定し、Playwright の `getByTestId()` で操作する。 + +### Naming Convention + +`data-testid` は `-` の形式: +- Component prefix: コンポーネント名をケバブケース(例: `ruby-toolbar`) +- Element suffix: 要素の役割をケバブケース(例: `mode-dncl`) + +### Ruby Toolbar (`ruby-toolbar.jsx`) + +| data-testid | 要素 | 説明 | +|------------|------|------| +| `ruby-toolbar-execute` | button | 実行/停止ボタン | +| `ruby-toolbar-undo` | button | 元に戻す | +| `ruby-toolbar-redo` | button | やり直す | +| `ruby-toolbar-search` | button | 検索 | +| `ruby-toolbar-auto-correct` | button | 自動置換トグル | +| `ruby-toolbar-rubytee` | button | ルビティー(AI) | +| `ruby-toolbar-mode-furigana` | button | ふりがなモード | +| `ruby-toolbar-mode-ruby` | button | Rubyモード | +| `ruby-toolbar-mode-dncl` | button | 日本語(DNCL)モード | +| `ruby-toolbar-more-menu` | button | その他メニュー | +| `ruby-toolbar-menu-download` | div | Rubyスクリプト保存 | +| `ruby-toolbar-menu-insert-class` | div | クラス挿入 | +| `ruby-toolbar-menu-preview` | div | プレビュー | +| `ruby-toolbar-menu-auto-correct-settings` | div | 自動置換設定 | + +### Target Selector (`target-selector.jsx`) + +| data-testid | 要素 | 説明 | +|------------|------|------| +| `ruby-toolbar-prev-sprite` | button | 前のスプライト | +| `ruby-toolbar-next-sprite` | button | 次のスプライト | +| `ruby-toolbar-sprite-search` | input | スプライト検索 | + +## Playwright MCP での操作例 + +```javascript +// data-testid でボタンをクリック +// Playwright MCP の browser_click で ref を使う代わりに、 +// browser_evaluate で data-testid を使って操作する +await page.evaluate(() => { + document.querySelector('[data-testid="ruby-toolbar-mode-dncl"]').click(); +}); + +// または Playwright のロケーターを使う +await page.getByTestId('ruby-toolbar-mode-dncl').click(); +``` + +## URL Parameters for Testing + +Playwright MCP でテストする際は以下の URL パラメータを使用: + +``` +http://localhost:8601?no_beforeunload=1&tab=ruby&ruby_version=2&rubyMode=ruby +``` + +| Parameter | Values | Description | +|-----------|--------|-------------| +| `no_beforeunload` | `1` | beforeunload ダイアログを無効化(必須) | +| `tab` | `ruby` | Ruby タブを初期表示 | +| `ruby_version` | `2` | Ruby バージョン | +| `rubyMode` | `ruby`, `furigana`, `dncl` | Ruby タブの初期モード | + +## Monaco Editor の操作 + +```javascript +// エディタの内容を設定 +await page.evaluate(() => { + monaco.editor.getEditors()[0].setValue('move(10)\n'); +}); + +// エディタの内容を取得 +const content = await page.evaluate(() => { + return monaco.editor.getEditors()[0].getValue(); +}); + +// エラーマーカーを取得 +const markers = await page.evaluate(() => { + const model = monaco.editor.getEditors()[0].getModel(); + return monaco.editor.getModelMarkers({ resource: model.uri }).map(m => ({ + line: m.startLineNumber, + message: m.message, + severity: m.severity + })); +}); + +// エディタの言語を取得 +const lang = await page.evaluate(() => { + return monaco.editor.getEditors()[0].getModel().getLanguageId(); +}); +``` diff --git a/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx b/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx index 05a354da7d7..b5587597479 100644 --- a/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx +++ b/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx @@ -137,6 +137,7 @@ const RubyToolbar = props => {