Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/rules/scratch-gui/smalruby-prettier-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down
18 changes: 10 additions & 8 deletions docs/tutorial/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 枚 |

Expand Down Expand Up @@ -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 着手前に必要 (外部要因)

Expand Down
2 changes: 2 additions & 0 deletions packages/scratch-gui/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions packages/scratch-gui/src/containers/tips-library.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,7 +40,7 @@ class TipsLibrary extends React.PureComponent {
'handleItemSelect'
]);
}
handleItemSelect (item) {
async handleItemSelect (item) {
analytics.event({
category: 'library',
action: 'Select How-to',
Expand All @@ -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 () {
Expand Down Expand Up @@ -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())
});

Expand Down
95 changes: 95 additions & 0 deletions packages/scratch-gui/src/lib/deck-setup.js
Original file line number Diff line number Diff line change
@@ -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<void>} 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 */ }
};
164 changes: 164 additions & 0 deletions packages/scratch-gui/test/unit/lib/deck-setup.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading