Skip to content

Commit c4e08f7

Browse files
tntetsuclaude
andcommitted
feat: URLクエリ(view)による初期表示ビューの指定(ADR-036)
BhvVisualizer連携のセッション内pre/post設問で、操作して確認する内容を 学生間で揃える必要があるが、ViewSwitcherが前回アクティブだったタブを localStorageから復元するため、学生ごとに無関係な過去セッションの続きから 始まってしまう問題があった。 exercise-source.jsのparseQuery()にview、ViewSwitcherにsetInitialView()を 追加し、URLクエリでの初期ビュー指定を最初の実行1回だけ有効にする (localStorageの保存値自体は書き換えない)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 9f72f4e commit c4e08f7

7 files changed

Lines changed: 154 additions & 10 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# ADR-036: URLクエリ(`view`)による初期表示ビューの指定
2+
3+
## ステータス
4+
5+
採択済み(2026-08-27)
6+
7+
## コンテキスト
8+
9+
BhvVisualizer(別リポジトリ)は、演習内で「ツールに触れる前に予想 → 操作して確認 → 再回答」という
10+
セッション内pre/post設問を評価手法として採用している
11+
(BhvVisualizer/its/course-redesign-plan.md 4.2節、BhvVisualizer/docs/evaluation-plan.md 3.4節)。
12+
13+
「操作して確認」の体験が学生ごとにバラバラだと、pre/post測定の「介入」自体が学生間で統制されず
14+
内的妥当性が下がる。特に`ViewSwitcher`は前回アクティブだったタブを`localStorage`
15+
`jsv-active-tab`)に保存し、次回起動時に復元する設計になっている
16+
`onReady()``src/components/view-switcher.js`)。これは通常の利用(同じ学生が継続して
17+
同じツールを使う)では望ましい挙動だが、pre/post設問の文脈では、学生ごとに全く無関係な
18+
過去のセッションで最後に使ったタブが復元されてしまい、「操作して確認」した内容が学生間で
19+
揃わないという問題になる。
20+
21+
## 決定
22+
23+
ADR-029/031と同じ「JSVisualizer単体でも汎用的に使える機能」として、URLクエリに`view`
24+
パラメータを追加する。BhvVisualizerとの連携を前提としないため`# BHV:`タグは付けない。
25+
26+
```
27+
?view=<登録済みビューID>
28+
```
29+
30+
- 値は`ViewSwitcher.register()`で登録されているビューID(`state`/`trace`/`exectrace`/`subst`/
31+
`exprtrace`/`colorbox`/`heatmap`/`calltree`/`lifetime`/`controlflow`/`memory`/`objgraph`
32+
`src/app.js`参照)と一致させる
33+
- **`code`/`exercise`のコード読み込みとは独立**に処理する(コードの取得元とは無関係な指定のため)
34+
- 効いてよいのは**そのページの最初の実行(Run)1回だけ**。以後の再実行では通常通り
35+
「前回アクティブだったタブ」の挙動に戻る。`localStorage`の保存値自体は書き換えない
36+
(pre/post設問という一時的な用途のために、その学生の以後の通常利用の体験を変えたくないため)
37+
- 未登録のID・指定なしの場合は何もせず、従来通りの優先順位(前回保存したタブ → 最初のビュー)
38+
で決まる(回帰なし)
39+
40+
### 変更ファイル
41+
42+
- **`src/core/exercise-source.js`**: `parseQuery()``view`も読むよう変更(`viewId`を返り値に追加)。
43+
コード読み込みとは独立な指定のため、`loadExerciseFromQuery()`自体は変更しない
44+
- **`src/components/view-switcher.js`**: `#presetId`フィールドと公開メソッド`setInitialView(id)`を追加。
45+
`onReady()`の「アクティブビュー未確定時」の分岐で、優先順位を「`setInitialView`指定 → 前回保存した
46+
タブ → 最初のビュー」に変更し、使用後は`#presetId`を消費(null化)する
47+
- **`src/app.js`**: `parseQuery()`から取り出した`viewId`があれば`switcher.setInitialView(viewId)`を呼ぶ
48+
`loadExerciseFromQuery(editor)`とは別に、独立した呼び出しとして追加)
49+
50+
### 安全性の担保
51+
52+
- `tests/core/exercise-source.test.js`: `parseQuery()``view`解析を追加(Jest、86件中の一部)
53+
- `verify-exercise-query.mjs`(Playwright、実ブラウザ)にテストF・Gを追加(計20件合格)
54+
- F: `?code=...&view=memory`で実行後、Memoryタブが最初からアクティブになる
55+
- G: `view`未指定なら従来通り最初のビュー(コールスタック)が開く(回帰確認)
56+
57+
## 結果
58+
59+
- 既存Jestテストスイート(86件、全て合格)
60+
- `verify-exercise-query.mjs`(20件、全て合格)
61+
- `verify-bhv-hook.mjs`(既存、ADR-028)に影響なし(`ViewSwitcher`の変更は`onReady()`
62+
ビュー選択ロジックのみで、ログ送信経路には触れていない)
63+
64+
## 代替案
65+
66+
- **`localStorage`の保存値自体を`view`クエリの値で上書きする**: 不採用。pre/post設問という
67+
一時的な用途のために、その学生が次にJSVisualizerを開いたとき(この設問とは無関係な場面)の
68+
体験まで変えてしまうため
69+
- **毎回の実行(Run)で`view`を効かせ続ける**: 不採用。ページの最初の実行だけを揃えれば
70+
pre/post設問の目的(「操作して確認」の内容を学生間で揃える)には十分で、2回目以降まで
71+
固定するとタブ切り替えという通常の操作性を損なう
72+
73+
## 今後の方針
74+
75+
BhvVisualizer側では`codes.initialView`(コード単位、教員が任意指定)から、iframe埋め込み時の
76+
URLに`view`を付与する(BhvVisualizer/docs/design.md 2.4.6節)。

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,4 @@ git 履歴(2026-05-25〜2026-06-08)と設計ドキュメントをもとに 2
4444
| [033](ADR-033-hide-builtin-samples-when-remote.md) | `exercise`/`code`指定時は組み込みサンプルをサンプル選択から取り除く | 2026-08-12 |
4545
| [034](ADR-034-code-only-placeholder-and-disable.md) | `code`単体指定時もプレースホルダをコードタイトルにし、サンプル選択を選択不可にする | 2026-08-12 |
4646
| [035](ADR-035-editor-line-wrapping.md) | エディタの折り返し表示を常時有効にする | 2026-08-12 |
47+
| [036](ADR-036-url-query-initial-view.md) | URLクエリ(`view`)による初期表示ビューの指定 | 2026-08-27 |

src/app.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { MemoryView } from './views/memory-view/index.js';
3737
import { ObjectGraph } from './views/object-graph/index.js';
3838
import { SubstTrace } from './views/subst-trace/index.js';
3939
import { ExprTrace } from './views/expr-trace/index.js';
40-
import { loadExerciseFromQuery } from './core/exercise-source.js';
40+
import { loadExerciseFromQuery, parseQuery } from './core/exercise-source.js';
4141

4242
// ── DOM 参照 ───────────────────────────────────────────────────────────────
4343

@@ -223,6 +223,11 @@ const editor = new CodeEditor({
223223
// クエリが無ければ何もせず、既定のFibonacciサンプルのまま起動する。
224224
loadExerciseFromQuery(editor).catch((err) => console.error('[exercise-source]', err));
225225

226+
// view クエリがあれば、最初の実行時に開くビューを指定する(ADR-036)。
227+
// コード読み込みとは独立な指定のため、loadExerciseFromQuery とは別に処理する。
228+
const { viewId } = parseQuery();
229+
if (viewId) switcher.setInitialView(viewId);
230+
226231
const stepControls = new StepControls({
227232
controller: controller,
228233
btnStart: $('btn-start'),

src/components/view-switcher.js

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export class ViewSwitcher {
3939
/** @type {string|null} 現在アクティブなビュー ID */
4040
#activeId = null;
4141

42+
/** @type {string|null} 次の初回 onReady() でのみ使う、外部から指定された初期ビュー ID */
43+
#presetId = null;
44+
4245
/** @type {import('../core/debugger-adapter.js').AppState|null} 最後の state */
4346
#lastState = null;
4447

@@ -79,6 +82,16 @@ export class ViewSwitcher {
7982
this.#addTab(id, label);
8083
}
8184

85+
/**
86+
* 次の初回 onReady() で開くビューを指定する(URLクエリ`view`等、外部からの初期ビュー指定用。
87+
* ADR-036)。登録されていない ID は無視する。既にアクティブビューがある場合(2回目以降の
88+
* onReady())には影響しない。localStorage の「前回使ったタブ」は書き換えない。
89+
* @param {string} id
90+
*/
91+
setInitialView(id) {
92+
if (this.#registry.has(id)) this.#presetId = id;
93+
}
94+
8295
/**
8396
* 実行開始時に呼ぶ。builder をセットしてアクティブビューを再初期化する。
8497
* @param {import('../core/debugger-adapter.js').AppState} state
@@ -103,11 +116,11 @@ export class ViewSwitcher {
103116
}
104117
this.#mountView(this.#activeId);
105118
} else {
106-
// 前回保存したタブ → なければ最初のビュー
119+
// 優先順位: 外部からの初期ビュー指定(setInitialView) > 前回保存したタブ > 最初のビュー
107120
const savedId = localStorage.getItem(STORAGE_KEY_TAB);
108-
const targetId = (savedId && this.#registry.has(savedId))
109-
? savedId
110-
: this.#registry.keys().next().value;
121+
const targetId = this.#presetId
122+
?? (savedId && this.#registry.has(savedId) ? savedId : this.#registry.keys().next().value);
123+
this.#presetId = null; // 一度使ったら消費する(以後の起動は通常の優先順位に戻す)
111124
if (targetId) this.#activate(targetId);
112125
}
113126
}

src/core/exercise-source.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,22 @@
66
* JSVisualizerはコードの取得元のパス規約・スキーマを一切知らない。クエリなし時は何もせず、
77
* 既定のFibonacciサンプル・21種の組み込みサンプルのまま起動する。クエリがある場合は組み込み
88
* サンプルをサンプル選択から取り除き、`exercise`/`code`が指すコードだけを表示する(ADR-033)。
9+
*
10+
* `view`(推奨初期ビューID)はコード読み込みとは独立な指定のため、`loadExerciseFromQuery()`
11+
* では扱わない。`parseQuery()`が返す`viewId`を呼び出し元(`app.js`)がそのまま
12+
* `ViewSwitcher.setInitialView()`に渡す(ADR-036)。
913
*/
1014

1115
/**
1216
* @param {string} [search]
13-
* @returns {{ codeUrl: string|null, exerciseUrl: string|null }}
17+
* @returns {{ codeUrl: string|null, exerciseUrl: string|null, viewId: string|null }}
1418
*/
1519
export function parseQuery(search = location.search) {
1620
const params = new URLSearchParams(search);
1721
return {
1822
codeUrl: params.get('code'),
1923
exerciseUrl: params.get('exercise'),
24+
viewId: params.get('view'),
2025
};
2126
}
2227

tests/core/exercise-source.test.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,24 +27,36 @@ afterEach(() => {
2727

2828
describe('parseQuery', () => {
2929
test('クエリなしなら全てnull', () => {
30-
expect(parseQuery('')).toEqual({ codeUrl: null, exerciseUrl: null });
30+
expect(parseQuery('')).toEqual({ codeUrl: null, exerciseUrl: null, viewId: null });
3131
});
3232

3333
test('codeのみ', () => {
3434
expect(parseQuery('?code=https%3A%2F%2Fexample.com%2Fcodes%2Fabc')).toEqual({
35-
codeUrl: 'https://example.com/codes/abc', exerciseUrl: null,
35+
codeUrl: 'https://example.com/codes/abc', exerciseUrl: null, viewId: null,
3636
});
3737
});
3838

3939
test('exerciseのみ', () => {
4040
expect(parseQuery('?exercise=https%3A%2F%2Fexample.com%2Fexercises%2Fex1')).toEqual({
41-
codeUrl: null, exerciseUrl: 'https://example.com/exercises/ex1',
41+
codeUrl: null, exerciseUrl: 'https://example.com/exercises/ex1', viewId: null,
4242
});
4343
});
4444

4545
test('code+exercise', () => {
4646
expect(parseQuery('?exercise=https://example.com/exercises/ex1&code=https://example.com/codes/co2')).toEqual({
47-
codeUrl: 'https://example.com/codes/co2', exerciseUrl: 'https://example.com/exercises/ex1',
47+
codeUrl: 'https://example.com/codes/co2', exerciseUrl: 'https://example.com/exercises/ex1', viewId: null,
48+
});
49+
});
50+
51+
test('viewのみ', () => {
52+
expect(parseQuery('?view=memory')).toEqual({
53+
codeUrl: null, exerciseUrl: null, viewId: 'memory',
54+
});
55+
});
56+
57+
test('code+view', () => {
58+
expect(parseQuery('?code=https://example.com/codes/co2&view=objgraph')).toEqual({
59+
codeUrl: 'https://example.com/codes/co2', exerciseUrl: null, viewId: 'objgraph',
4860
});
4961
});
5062
});

verify-exercise-query.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,38 @@ async function run() {
170170
check('[E] 存在しないcode URLでエラーメッセージが表示される', errorVisible);
171171
await page.close();
172172
}
173+
174+
// ── テストF: code+view → 実行後、指定したビューが最初から開いている(ADR-036) ──
175+
{
176+
const page = await browser.newPage();
177+
const codeUrl = encodeURIComponent(`${API_BASE}/codes/co3`);
178+
await page.goto(`http://localhost:${JSV_PORT}/index.html?code=${codeUrl}&view=memory`, { waitUntil: 'networkidle' });
179+
await page.waitForTimeout(300);
180+
await page.click('#btn-run');
181+
await page.waitForTimeout(300);
182+
183+
const isActive = await page.locator('.view-tab[data-view="memory"]').evaluate(
184+
(el) => el.classList.contains('view-tab--active'),
185+
);
186+
check('[F] view=memory指定で実行後にMemoryタブが最初からアクティブになる', isActive);
187+
await page.close();
188+
}
189+
190+
// ── テストG: view未指定 → 従来通り最初のビュー(コールスタック)が開く(回帰確認) ──
191+
{
192+
const page = await browser.newPage();
193+
const codeUrl = encodeURIComponent(`${API_BASE}/codes/co3`);
194+
await page.goto(`http://localhost:${JSV_PORT}/index.html?code=${codeUrl}`, { waitUntil: 'networkidle' });
195+
await page.waitForTimeout(300);
196+
await page.click('#btn-run');
197+
await page.waitForTimeout(300);
198+
199+
const isActive = await page.locator('.view-tab[data-view="state"]').evaluate(
200+
(el) => el.classList.contains('view-tab--active'),
201+
);
202+
check('[G] view未指定なら従来通り最初のビュー(コールスタック)が開く(回帰確認)', isActive);
203+
await page.close();
204+
}
173205
} finally {
174206
await browser.close();
175207
jsvServer.close();

0 commit comments

Comments
 (0)