diff --git a/.claude/rules/scratch-gui/smalruby-prettier-files.md b/.claude/rules/scratch-gui/smalruby-prettier-files.md index 5a8590cd819..ed071c31963 100644 --- a/.claude/rules/scratch-gui/smalruby-prettier-files.md +++ b/.claude/rules/scratch-gui/smalruby-prettier-files.md @@ -99,6 +99,7 @@ upstream (Scratch) ファイルは対象外。 - `src/lib/auto-correct.js` - `src/lib/backpack-mesh-v1-migration.js` - `src/lib/classroom-api.js` +- `src/lib/deck-setup.js` - `src/lib/google-classroom-auth.js` - `src/lib/block-utils.js` - `src/lib/blocks-gesture-recovery.js` @@ -232,6 +233,7 @@ upstream (Scratch) ファイルは対象外。 - `test/unit/lib/block-display-initialization.test.js` - `test/unit/lib/blockly-private-api.test.js` - `test/unit/lib/blocks-gesture-recovery.test.js` +- `test/unit/lib/deck-setup.test.js` - `test/unit/lib/blocks-screenshot.test.js` - `test/unit/lib/calculate-popup-position.test.js` - `test/unit/lib/calculate-popup-position-regression.test.js` diff --git a/docs/tutorial/progress.md b/docs/tutorial/progress.md index 1d2784ed0ec..77fdf57834e 100644 --- a/docs/tutorial/progress.md +++ b/docs/tutorial/progress.md @@ -8,9 +8,9 @@ | Phase | Issue | 状態 | 規模 | 画像 | |---|---|---|---|---| -| Phase 1 — Mesh 再分類 | [#678](https://github.com/smalruby/smalruby3-editor/issues/678) | 🟢 実装完了 (レビュー待ち) | 1 PR | 不要 | -| 基盤 — `setup` プロパティ | (Phase 2 sub-issue 内) | ⚪️ 未着手 | 1 PR | 不要 | -| Phase 2 — Ruby 拡充 | [#679](https://github.com/smalruby/smalruby3-editor/issues/679) | ⚪️ 未着手 | 2〜3 PR | ~50 枚 | +| Phase 1 — Mesh 再分類 | [#678](https://github.com/smalruby/smalruby3-editor/issues/678) | ✅ マージ済み (PR #683) | 1 PR | 不要 | +| 基盤 — `setup` プロパティ | (Phase 2 sub-issue 内) | 🟢 実装完了 (レビュー待ち) | 1 PR | 不要 | +| Phase 2 — Ruby 拡充 | [#679](https://github.com/smalruby/smalruby3-editor/issues/679) | ⚪️ deck 着手前 (基盤マージ後に開始) | 2〜3 PR | ~50 枚 | | Phase 3 — Block 4 シリーズ | [#680](https://github.com/smalruby/smalruby3-editor/issues/680) | ⚪️ 未着手 (書誌情報待ち) | 4 PR | ~76 枚 | | Phase 4 — DNCL | [#681](https://github.com/smalruby/smalruby3-editor/issues/681) | ⚪️ 未着手 | 3〜4 PR | ~70 枚 | @@ -52,11 +52,13 @@ ### 基盤 (Phase 2 前半): `setup` プロパティ -- [ ] deck 定義の type 拡張 (`{ tab, rubyMode, extensions, rubyVersion }`) -- [ ] `tips-library.jsx` または `cards` reducer で deck 起動時に setup を適用 -- [ ] `activateTab` / `setDnclMode` / `vm.extensionManager.loadExtensionURL` の冪等な呼び出し -- [ ] ロード失敗時のグレースフルデグレード -- [ ] ふりがなフラグも考慮した rubyMode の動作 (`smalruby:furiganaEnabled` との同期) +- [x] `src/lib/deck-setup.js` 新規追加 (`applyDeckSetup` ヘルパー) +- [x] deck 定義の type 拡張 (`{ tab, rubyMode, extensions, rubyVersion }`) — `rubyVersion` は将来用フックのみ +- [x] `tips-library.jsx` で deck 起動時に setup を適用 (vm prop 接続含む) +- [x] `activateTab` / `setDnclMode` / `vm.extensionManager.loadExtensionURL` の冪等な呼び出し +- [x] ロード失敗時のグレースフルデグレード (`console.warn` のみ、deck は開く) +- [x] ふりがなフラグも考慮した rubyMode の動作 (`smalruby:furiganaEnabled` localStorage の同期) +- [x] `test/unit/lib/deck-setup.test.js` で 10 ケースの単体テスト (全 pass) ### Phase 3 着手前に必要 (外部要因) diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index 6a4de1d0e20..478b37bc496 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -121,6 +121,7 @@ src/lib/* # Individual Smalruby files !src/lib/auto-correct.js !src/lib/backpack-mesh-v1-migration.js +!src/lib/deck-setup.js !src/lib/classroom-api.js !src/lib/google-classroom-auth.js !src/lib/block-utils.js @@ -309,6 +310,7 @@ test/unit/lib/* !test/unit/lib/block-display-initialization.test.js !test/unit/lib/blockly-private-api.test.js !test/unit/lib/blocks-gesture-recovery.test.js +!test/unit/lib/deck-setup.test.js !test/unit/lib/blocks-screenshot.test.js !test/unit/lib/calculate-popup-position.test.js !test/unit/lib/calculate-popup-position-regression.test.js diff --git a/packages/scratch-gui/src/containers/tips-library.jsx b/packages/scratch-gui/src/containers/tips-library.jsx index ab238b649b7..a87c547391c 100644 --- a/packages/scratch-gui/src/containers/tips-library.jsx +++ b/packages/scratch-gui/src/containers/tips-library.jsx @@ -9,11 +9,14 @@ import tutorialTags from '../lib/libraries/tutorial-tags'; import analytics from '../lib/analytics'; import {PLATFORM} from '../lib/platform.js'; +import {applyDeckSetup} from '../lib/deck-setup'; import LibraryComponent from '../components/library/library.jsx'; import {connect} from 'react-redux'; +import VM from '@smalruby/scratch-vm'; + import { closeTipsLibrary } from '../reducers/modals'; @@ -37,7 +40,7 @@ class TipsLibrary extends React.PureComponent { 'handleItemSelect' ]); } - handleItemSelect (item) { + async handleItemSelect (item) { analytics.event({ category: 'library', action: 'Select How-to', @@ -62,6 +65,15 @@ class TipsLibrary extends React.PureComponent { if (this.props.onTutorialSelect) { this.props.onTutorialSelect(); } + + // === Smalruby: apply deck setup (tab / Ruby mode / extensions) before + // activating the deck so that the tutorial opens with the right + // environment already in place. === + const deck = decksLibraryContent[item.id]; + if (deck && deck.setup) { + await applyDeckSetup(deck.setup, this.props.onApplyDeckSetup, this.props.vm); + } + this.props.onActivateDeck(item.id); } render () { @@ -125,21 +137,25 @@ TipsLibrary.propTypes = { onTutorialSelect: PropTypes.func, intl: intlShape.isRequired, onActivateDeck: PropTypes.func.isRequired, + onApplyDeckSetup: PropTypes.func.isRequired, onRequestClose: PropTypes.func, projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), platform: PropTypes.oneOf(Object.keys(PLATFORM)), visible: PropTypes.bool, - hideTutorialProjects: PropTypes.bool + hideTutorialProjects: PropTypes.bool, + vm: PropTypes.instanceOf(VM) }; const mapStateToProps = state => ({ visible: state.scratchGui.modals.tipsLibrary, projectId: state.scratchGui.projectState.projectId, - platform: state.scratchGui.platform.platform + platform: state.scratchGui.platform.platform, + vm: state.scratchGui.vm }); const mapDispatchToProps = dispatch => ({ onActivateDeck: id => dispatch(activateDeck(id)), + onApplyDeckSetup: dispatch, onRequestClose: () => dispatch(closeTipsLibrary()) }); diff --git a/packages/scratch-gui/src/lib/deck-setup.js b/packages/scratch-gui/src/lib/deck-setup.js new file mode 100644 index 00000000000..8b2b39e2607 --- /dev/null +++ b/packages/scratch-gui/src/lib/deck-setup.js @@ -0,0 +1,95 @@ +// === Smalruby: This file is Smalruby-specific (tutorial deck setup helper) === +import { setDnclMode } from '../reducers/dncl-mode'; +import { + activateTab, + BLOCKS_TAB_INDEX, + COSTUMES_TAB_INDEX, + RUBY_TAB_INDEX, + SOUNDS_TAB_INDEX, +} from '../reducers/editor-tab'; + +const FURIGANA_ENABLED_KEY = 'smalruby:furiganaEnabled'; + +const TAB_INDEX_MAP = { + code: BLOCKS_TAB_INDEX, + blocks: BLOCKS_TAB_INDEX, + costumes: COSTUMES_TAB_INDEX, + sounds: SOUNDS_TAB_INDEX, + ruby: RUBY_TAB_INDEX, +}; + +const VALID_RUBY_MODES = new Set(['ruby', 'furigana', 'dncl']); + +/** + * Apply tutorial deck setup: switch editor tab, set Ruby mode, load + * required extensions. Each operation is idempotent — calling this with the + * environment already in the desired state is a no-op. + * + * The `setup` descriptor is defined per-deck in `decks/index.jsx`: + * + * 'deck-id': { + * setup: { + * tab: 'ruby', // 'code' | 'costumes' | 'sounds' | 'ruby' + * rubyMode: 'dncl', // 'ruby' | 'furigana' | 'dncl' + * extensions: ['pen', 'microbitMore'], + * }, + * // ... + * } + * + * Extension loads are awaited so that the tutorial card opens once all + * required extensions are usable. Failures are logged but do not abort the + * tutorial — the deck still opens and the user can manually load the + * extension if needed. + * @param {object} setup - The deck.setup descriptor (may be undefined). + * @param {Function} dispatch - Redux dispatch function. + * @param {object} vm - VM instance (state.scratchGui.vm). + * @returns {Promise} resolves once setup is applied + */ +export const applyDeckSetup = async (setup, dispatch, vm) => { + if (!setup || typeof setup !== 'object') return; + + // 1. Tab + if (setup.tab && Object.prototype.hasOwnProperty.call(TAB_INDEX_MAP, setup.tab)) { + dispatch(activateTab(TAB_INDEX_MAP[setup.tab])); + } + + // 2. Ruby mode. The furigana flag is stored in localStorage and read by + // ruby-tab.jsx on mount. For an already-mounted ruby-tab, the change + // takes effect on the next remount (e.g. tab switch) — acceptable for + // tutorial flows that start from a fresh tab activation. + if (setup.rubyMode && VALID_RUBY_MODES.has(setup.rubyMode)) { + const mode = setup.rubyMode; + dispatch(setDnclMode(mode === 'dncl')); + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.setItem(FURIGANA_ENABLED_KEY, mode === 'furigana' ? 'true' : 'false'); + } + } + + // 3. Extensions (await sequentially so any UI shown afterwards sees them + // all loaded). + if (Array.isArray(setup.extensions) && setup.extensions.length > 0 && vm) { + for (const extId of setup.extensions) { + try { + if ( + typeof vm.extensionManager?.isExtensionLoaded === 'function' && + vm.extensionManager.isExtensionLoaded(extId) + ) { + continue; + } + if (typeof vm.extensionManager?.loadExtensionURL === 'function') { + await vm.extensionManager.loadExtensionURL(extId); + } + } catch (err) { + // Graceful degradation — log but don't abort. The tutorial + // opens and the user can manually load the extension. + // eslint-disable-next-line no-console + console.warn(`[deck-setup] Failed to load extension '${extId}':`, err); + } + } + } + + // 4. Ruby version is intentionally not handled in Phase 2 foundation — + // no deck currently needs it. The hook is left here as a documentation + // marker for future expansion (would dispatch settings reducer action). + // if (setup.rubyVersion === 1 || setup.rubyVersion === 2) { /* TBD */ } +}; diff --git a/packages/scratch-gui/test/unit/lib/deck-setup.test.js b/packages/scratch-gui/test/unit/lib/deck-setup.test.js new file mode 100644 index 00000000000..16de0cf1150 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/deck-setup.test.js @@ -0,0 +1,164 @@ +import { applyDeckSetup } from '../../../src/lib/deck-setup'; +import { SET_DNCL_MODE } from '../../../src/reducers/dncl-mode'; + +const ACTIVATE_TAB = 'scratch-gui/navigation/ACTIVATE_TAB'; + +const BLOCKS_TAB_INDEX = 0; +const COSTUMES_TAB_INDEX = 1; +const SOUNDS_TAB_INDEX = 2; +const RUBY_TAB_INDEX = 3; + +const FURIGANA_KEY = 'smalruby:furiganaEnabled'; + +const makeDispatch = () => { + const calls = []; + const dispatch = (action) => calls.push(action); + dispatch.calls = calls; + return dispatch; +}; + +const makeVM = ({ alreadyLoaded = [], failOn = [] } = {}) => { + const loaded = new Set(alreadyLoaded); + return { + extensionManager: { + isExtensionLoaded: (id) => loaded.has(id), + loadExtensionURL: jest.fn(async (id) => { + if (failOn.includes(id)) throw new Error(`load failed: ${id}`); + loaded.add(id); + }), + }, + loaded, + }; +}; + +describe('applyDeckSetup', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + test('returns immediately for missing or non-object setup', async () => { + const dispatch = makeDispatch(); + await applyDeckSetup(undefined, dispatch, makeVM()); + await applyDeckSetup(null, dispatch, makeVM()); + await applyDeckSetup('not-an-object', dispatch, makeVM()); + expect(dispatch.calls).toEqual([]); + }); + + test('dispatches activateTab for each supported tab name', async () => { + const cases = [ + ['code', BLOCKS_TAB_INDEX], + ['blocks', BLOCKS_TAB_INDEX], + ['costumes', COSTUMES_TAB_INDEX], + ['sounds', SOUNDS_TAB_INDEX], + ['ruby', RUBY_TAB_INDEX], + ]; + for (const [tab, expectedIndex] of cases) { + const dispatch = makeDispatch(); + await applyDeckSetup({ tab }, dispatch, makeVM()); + expect(dispatch.calls).toContainEqual({ + type: ACTIVATE_TAB, + activeTabIndex: expectedIndex, + }); + } + }); + + test('ignores unknown tab names without dispatching', async () => { + const dispatch = makeDispatch(); + await applyDeckSetup({ tab: 'unknown-tab' }, dispatch, makeVM()); + expect(dispatch.calls.filter((a) => a.type === ACTIVATE_TAB)).toEqual([]); + }); + + test('rubyMode dispatches setDnclMode and updates furigana localStorage', async () => { + // dncl: SET_DNCL_MODE true, furigana=false + let dispatch = makeDispatch(); + await applyDeckSetup({ rubyMode: 'dncl' }, dispatch, makeVM()); + expect(dispatch.calls).toContainEqual({ + type: SET_DNCL_MODE, + dnclMode: true, + }); + expect(window.localStorage.getItem(FURIGANA_KEY)).toBe('false'); + + // furigana: SET_DNCL_MODE false, furigana=true + dispatch = makeDispatch(); + window.localStorage.clear(); + await applyDeckSetup({ rubyMode: 'furigana' }, dispatch, makeVM()); + expect(dispatch.calls).toContainEqual({ + type: SET_DNCL_MODE, + dnclMode: false, + }); + expect(window.localStorage.getItem(FURIGANA_KEY)).toBe('true'); + + // ruby: SET_DNCL_MODE false, furigana=false + dispatch = makeDispatch(); + window.localStorage.clear(); + await applyDeckSetup({ rubyMode: 'ruby' }, dispatch, makeVM()); + expect(dispatch.calls).toContainEqual({ + type: SET_DNCL_MODE, + dnclMode: false, + }); + expect(window.localStorage.getItem(FURIGANA_KEY)).toBe('false'); + }); + + test('invalid rubyMode value is ignored', async () => { + const dispatch = makeDispatch(); + await applyDeckSetup({ rubyMode: 'bogus' }, dispatch, makeVM()); + expect(dispatch.calls.filter((a) => a.type === SET_DNCL_MODE)).toEqual([]); + }); + + test('loads required extensions sequentially', async () => { + const dispatch = makeDispatch(); + const vm = makeVM(); + await applyDeckSetup({ extensions: ['pen', 'microbitMore'] }, dispatch, vm); + expect(vm.extensionManager.loadExtensionURL).toHaveBeenCalledWith('pen'); + expect(vm.extensionManager.loadExtensionURL).toHaveBeenCalledWith('microbitMore'); + expect(vm.extensionManager.loadExtensionURL).toHaveBeenCalledTimes(2); + }); + + test('skips already-loaded extensions (idempotent)', async () => { + const dispatch = makeDispatch(); + const vm = makeVM({ alreadyLoaded: ['pen'] }); + await applyDeckSetup({ extensions: ['pen', 'microbitMore'] }, dispatch, vm); + expect(vm.extensionManager.loadExtensionURL).toHaveBeenCalledWith('microbitMore'); + expect(vm.extensionManager.loadExtensionURL).not.toHaveBeenCalledWith('pen'); + }); + + test('extension load failure is logged but not thrown', async () => { + const dispatch = makeDispatch(); + const vm = makeVM({ failOn: ['broken'] }); + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + await expect(applyDeckSetup({ extensions: ['broken', 'pen'] }, dispatch, vm)).resolves.toBeUndefined(); + // pen should still be loaded after broken fails + expect(vm.extensionManager.loadExtensionURL).toHaveBeenCalledWith('pen'); + expect(consoleWarn).toHaveBeenCalled(); + consoleWarn.mockRestore(); + }); + + test('combines tab + rubyMode + extensions in one call', async () => { + const dispatch = makeDispatch(); + const vm = makeVM(); + await applyDeckSetup( + { + tab: 'ruby', + rubyMode: 'dncl', + extensions: ['pen'], + }, + dispatch, + vm, + ); + expect(dispatch.calls).toContainEqual({ + type: ACTIVATE_TAB, + activeTabIndex: RUBY_TAB_INDEX, + }); + expect(dispatch.calls).toContainEqual({ + type: SET_DNCL_MODE, + dnclMode: true, + }); + expect(window.localStorage.getItem(FURIGANA_KEY)).toBe('false'); + expect(vm.extensionManager.loadExtensionURL).toHaveBeenCalledWith('pen'); + }); + + test('missing vm with extensions does not throw', async () => { + const dispatch = makeDispatch(); + await expect(applyDeckSetup({ extensions: ['pen'] }, dispatch, undefined)).resolves.toBeUndefined(); + }); +});