steveyehtw commited on
Commit
12e244e
·
1 Parent(s): 768ace6

Make classroom panels resizable

Browse files
classroom_static/classroom.css CHANGED
@@ -141,27 +141,36 @@ button:disabled { cursor: not-allowed; opacity: .48; }
141
  .project-context select,.project-context input { min-height:36px;max-width:280px;padding:0 var(--space-md);border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--ink);font-weight:700; }
142
  .submission-summary { padding:var(--space-sm) var(--space-md);border-radius:999px;background:var(--success-soft);color:oklch(.37 .12 150);font-size:.8rem;font-weight:800; }
143
  .student-layout {
 
 
144
  min-height: 0;
145
  flex: 1;
146
  overflow: hidden;
147
  display: grid;
148
- grid-template-columns: minmax(230px, 280px) minmax(420px, 1fr) minmax(280px, 360px);
149
- grid-template-areas: "reference editor console";
150
  }
151
 
152
  .panel { min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); }
153
- .reference-panel { grid-area: reference; display:grid;grid-template-rows:44px minmax(0,1fr);border-right:1px solid var(--line); }
154
- .editor-panel { grid-area: editor; display:grid;grid-template-rows:52px minmax(0,1fr);background:var(--editor); }
155
- .console-panel { grid-area: console; display:grid;grid-template-rows:52px minmax(0,1fr);border-left:1px solid var(--line); }
156
- .editor-toolbar { display:flex;align-items:center;gap:var(--space-sm); }
 
 
 
 
 
 
 
157
  .editor-toggle { min-height:32px;padding:0 var(--space-md);border:1px solid oklch(.42 .035 258);border-radius:var(--radius-sm);background:transparent;color:oklch(.82 .025 255);font-size:.78rem;font-weight:750; }
158
  .editor-toggle:hover,.editor-toggle[aria-pressed="true"] { border-color:oklch(.66 .12 255);background:oklch(.3 .065 258);color:white; }
159
 
160
  @media (min-width: 981px) {
161
- .student-layout.reference-hidden { grid-template-columns:minmax(520px,1fr) minmax(280px,360px);grid-template-areas:"editor console"; }
162
- .student-layout.console-hidden { grid-template-columns:minmax(230px,280px) minmax(520px,1fr);grid-template-areas:"reference editor"; }
163
  .student-layout.reference-hidden.console-hidden { grid-template-columns:minmax(0,1fr);grid-template-areas:"editor"; }
164
- .student-layout.reference-hidden .reference-panel,.student-layout.console-hidden .console-panel { display:none; }
165
  }
166
 
167
  .panel-heading {
@@ -174,7 +183,7 @@ button:disabled { cursor: not-allowed; opacity: .48; }
174
  border-bottom: 1px solid var(--line);
175
  }
176
  .panel-heading h1, .panel-heading h2 { margin: 0; font-size: 1rem; }
177
- .panel-heading.dark { border-color: var(--editor-line); background: var(--editor-raised); color: var(--editor-ink); }
178
  .side-tabs { height:44px;display:flex;border-bottom:1px solid var(--line); }
179
  .side-tab { flex:1;border:0;background:var(--surface);color:var(--muted);font-size:.8rem;font-weight:800; }
180
  .side-tab.active { color:var(--navy);box-shadow:inset 0 -3px 0 var(--action); }
@@ -247,8 +256,7 @@ button:disabled { cursor: not-allowed; opacity: .48; }
247
  .checkpoint-action { border-color: oklch(0.72 0.1 150); color: oklch(0.38 0.13 150); }
248
  .checkpoint-action:hover { background: var(--success-soft); }
249
 
250
- .editor-toolbar { display: flex; align-items: center; gap: var(--space-sm); }
251
- .file-name { font: 700 .875rem/1 "SFMono-Regular", Consolas, monospace; color: oklch(0.82 0.025 255); }
252
  .editor-host { min-height:0; }
253
  .CodeMirror { height: 100%; font: 500 1rem/1.7 "SFMono-Regular", Consolas, monospace; background: var(--editor); }
254
  .CodeMirror-gutters { background: var(--editor); border-right: 1px solid var(--editor-line); }
@@ -343,6 +351,7 @@ dialog::backdrop { background: oklch(0.16 0.04 255 / .72); }
343
  @media (max-width: 980px) {
344
  .project-bar{align-items:stretch;flex-direction:column;padding:var(--space-md)}.project-actions{overflow-x:auto;flex-wrap:nowrap}.project-actions>*{flex:none}
345
  .editor-toggle { display:none; }
 
346
  .student-layout { display:block;min-height:0; }
347
  .mobile-tabs { display:flex;flex:none;background:var(--surface);border-bottom:1px solid var(--line); }
348
  .mobile-tab { flex: 1; min-height: 46px; border: 0; background: var(--surface); color: var(--muted); font-weight: 800; }
 
141
  .project-context select,.project-context input { min-height:36px;max-width:280px;padding:0 var(--space-md);border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--ink);font-weight:700; }
142
  .submission-summary { padding:var(--space-sm) var(--space-md);border-radius:999px;background:var(--success-soft);color:oklch(.37 .12 150);font-size:.8rem;font-weight:800; }
143
  .student-layout {
144
+ --reference-width: 280px;
145
+ --console-width: 360px;
146
  min-height: 0;
147
  flex: 1;
148
  overflow: hidden;
149
  display: grid;
150
+ grid-template-columns: minmax(220px, var(--reference-width)) 9px minmax(320px, 1fr) 9px minmax(260px, var(--console-width));
151
+ grid-template-areas: "reference reference-resizer editor console-resizer console";
152
  }
153
 
154
  .panel { min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); }
155
+ .reference-panel { grid-area: reference; display:grid;grid-template-rows:44px minmax(0,1fr); }
156
+ .editor-panel { grid-area: editor; display:grid;grid-template-rows:auto minmax(0,1fr);background:var(--editor); }
157
+ .console-panel { grid-area: console; display:grid;grid-template-rows:52px minmax(0,1fr); }
158
+ .panel-resizer { position:relative;min-width:9px;min-height:0;background:var(--shell);cursor:col-resize;touch-action:none; }
159
+ .panel-resizer::after { content:"";position:absolute;inset:0 auto 0 4px;width:1px;background:var(--line);transition:background-color 160ms ease,width 160ms ease; }
160
+ .panel-resizer:hover::after,.panel-resizer:focus-visible::after,.panel-resizer.dragging::after { width:2px;background:var(--action); }
161
+ .panel-resizer:focus-visible { outline:3px solid var(--help);outline-offset:-3px; }
162
+ .reference-resizer { grid-area:reference-resizer; }
163
+ .console-resizer { grid-area:console-resizer; }
164
+ body.panel-resizing { cursor:col-resize;user-select:none; }
165
+ .editor-toolbar { display:flex;align-items:center;justify-content:flex-end;gap:var(--space-sm);flex:0 1 auto;flex-wrap:wrap;min-width:0; }
166
  .editor-toggle { min-height:32px;padding:0 var(--space-md);border:1px solid oklch(.42 .035 258);border-radius:var(--radius-sm);background:transparent;color:oklch(.82 .025 255);font-size:.78rem;font-weight:750; }
167
  .editor-toggle:hover,.editor-toggle[aria-pressed="true"] { border-color:oklch(.66 .12 255);background:oklch(.3 .065 258);color:white; }
168
 
169
  @media (min-width: 981px) {
170
+ .student-layout.reference-hidden { grid-template-columns:minmax(320px,1fr) 9px minmax(260px,var(--console-width));grid-template-areas:"editor console-resizer console"; }
171
+ .student-layout.console-hidden { grid-template-columns:minmax(220px,var(--reference-width)) 9px minmax(320px,1fr);grid-template-areas:"reference reference-resizer editor"; }
172
  .student-layout.reference-hidden.console-hidden { grid-template-columns:minmax(0,1fr);grid-template-areas:"editor"; }
173
+ .student-layout.reference-hidden .reference-panel,.student-layout.reference-hidden .reference-resizer,.student-layout.console-hidden .console-panel,.student-layout.console-hidden .console-resizer { display:none; }
174
  }
175
 
176
  .panel-heading {
 
183
  border-bottom: 1px solid var(--line);
184
  }
185
  .panel-heading h1, .panel-heading h2 { margin: 0; font-size: 1rem; }
186
+ .panel-heading.dark { min-width:0;padding-block:6px;flex-wrap:wrap;border-color:var(--editor-line);background:var(--editor-raised);color:var(--editor-ink); }
187
  .side-tabs { height:44px;display:flex;border-bottom:1px solid var(--line); }
188
  .side-tab { flex:1;border:0;background:var(--surface);color:var(--muted);font-size:.8rem;font-weight:800; }
189
  .side-tab.active { color:var(--navy);box-shadow:inset 0 -3px 0 var(--action); }
 
256
  .checkpoint-action { border-color: oklch(0.72 0.1 150); color: oklch(0.38 0.13 150); }
257
  .checkpoint-action:hover { background: var(--success-soft); }
258
 
259
+ .file-name { min-width:0;flex:1 1 100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:700 .875rem/1 "SFMono-Regular",Consolas,monospace;color:oklch(.82 .025 255); }
 
260
  .editor-host { min-height:0; }
261
  .CodeMirror { height: 100%; font: 500 1rem/1.7 "SFMono-Regular", Consolas, monospace; background: var(--editor); }
262
  .CodeMirror-gutters { background: var(--editor); border-right: 1px solid var(--editor-line); }
 
351
  @media (max-width: 980px) {
352
  .project-bar{align-items:stretch;flex-direction:column;padding:var(--space-md)}.project-actions{overflow-x:auto;flex-wrap:nowrap}.project-actions>*{flex:none}
353
  .editor-toggle { display:none; }
354
+ .panel-resizer { display:none; }
355
  .student-layout { display:block;min-height:0; }
356
  .mobile-tabs { display:flex;flex:none;background:var(--surface);border-bottom:1px solid var(--line); }
357
  .mobile-tab { flex: 1; min-height: 46px; border: 0; background: var(--surface); color: var(--muted); font-weight: 800; }
classroom_static/student.html CHANGED
@@ -1,10 +1,12 @@
1
- <!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>Python 專案教室|學生工作區</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-8"></head><body>
2
  <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>Python 專案教室</strong><span id="studentIdentity">尚未進入教室</span></div></div><div class="header-actions"><div class="connection-state"><i class="status-dot" id="connectionDot"></i><span id="connectionText">未連線</span></div><button class="btn help compact" id="helpButton">請老師協助</button><button class="btn secondary compact" id="identityButton">更換身分</button></div></header>
3
  <section class="project-bar"><div class="project-context"><label for="weekSelect">Lesson</label><select id="weekSelect"></select><label for="projectName">專案</label><input id="projectName" maxlength="36"></div><div class="project-actions"><button class="copy-button" id="templateButton">匯入專案起始套版</button><button class="copy-button" id="progressButton">選擇舊進度</button><button class="copy-button" id="importButton">匯入 .py / ZIP</button><button class="copy-button" id="exportButton">匯出專案</button><button class="copy-button checkpoint-action" id="checkpointButton">暫存本週進度</button><button class="btn compact" id="submitButton">提交正式版本</button><input id="fileImport" type="file" accept=".py,.zip" hidden></div></section>
4
  <nav class="mobile-tabs" role="tablist"><button class="mobile-tab" data-panel="referencePanel">專案/語法</button><button class="mobile-tab" data-panel="editorPanel" aria-selected="true">程式</button><button class="mobile-tab" data-panel="consolePanel">執行結果</button></nav>
5
- <main class="student-layout"><aside class="panel reference-panel" id="referencePanel"><div class="side-tabs"><button class="side-tab active" data-side="filesView">專案檔案</button><button class="side-tab" data-side="syntaxView">語法查詢</button></div><section class="side-view" id="filesView"><div class="panel-heading"><h1>檔案夾</h1><button class="copy-button" id="newFileButton">+新增</button></div><div class="file-list" id="fileList"></div><p class="side-note">入口固定為 <code>main.py</code>,可用 <code>import</code> 引入其他 Python 檔。</p></section><section class="side-view" id="syntaxView" hidden><div class="panel-heading"><h1>Python 語法查詢</h1><span id="snippetCount">0 項</span></div><div class="search-wrap"><label class="field-label" for="syntaxSearch">輸入語法或用途</label><input class="text-field" id="syntaxSearch" type="search"><div class="chip-row" id="categoryFilters"></div></div><div class="snippet-list" id="snippetList"></div></section></aside>
 
6
  <section class="panel editor-panel mobile-active" id="editorPanel"><div class="panel-heading dark"><span class="file-name" id="activeFileLabel">main.py</span><div class="editor-toolbar"><button class="editor-toggle" id="toggleReferenceButton" aria-pressed="false">隱藏側欄</button><button class="editor-toggle" id="toggleConsoleButton" aria-pressed="false">隱藏 Console</button><button class="btn stop compact" id="stopButton" disabled>停止</button><button class="btn compact" id="runButton" disabled>載入 Python…</button></div></div><div class="editor-host"><textarea id="codeEditor" aria-label="程式碼"></textarea></div></section>
 
7
  <aside class="panel console-panel" id="consolePanel"><div class="panel-heading"><h2>互動式 Console</h2><button class="copy-button" id="clearOutputButton">清除</button></div><div class="console-body"><div class="console-stream"><pre class="console-output" id="consoleOutput">Python 執行環境載入中…</pre><form class="console-input-row" id="consoleInputForm" hidden><span aria-hidden="true">›</span><label class="sr-only" for="consoleInput">Python 程式要求輸入</label><input id="consoleInput" autocomplete="off"><button class="btn compact" type="submit">送出</button></form></div><p class="console-hint">執行 <code>main.py</code>;遇到 <code>input()</code> 時會在這裡詢問。</p><div class="console-status"><i class="status-dot" id="runStatusDot"></i><span id="runStatusText">準備環境</span><span>·</span><span id="saveStatus">尚未同步</span><span>·</span><span id="submitStatus">尚未暫存或提交</span></div></div></aside></main>
8
  <dialog id="identityDialog"><form class="dialog-content form-stack" id="identityForm"><div><h1>進入程式教室</h1><p>選擇班級並輸入自己在名單上的姓名。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div><label class="field-label" for="displayName">姓名</label><input class="text-field" id="displayName" autocomplete="name" required><p class="field-help">姓名必須與老師建立的名單一致。</p></div><p class="form-error" id="identityError" role="alert"></p><button class="btn" type="submit">進入教室</button></form></dialog>
9
  <dialog id="progressDialog"><div class="dialog-content"><h2>選擇已儲存版本</h2><p>每次暫存與正式提交都會保留;載入只會建立工作副本,不會刪除其他版本。</p><div class="progress-list" id="progressList"></div><button class="btn secondary dialog-close">關閉</button></div></dialog><div class="toast" id="toast"></div>
10
- <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script><script src="/classroom-assets/student.js?v=2026fall-10"></script></body></html>
 
1
+ <!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>Python 專案教室|學生工作區</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-9"></head><body>
2
  <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>Python 專案教室</strong><span id="studentIdentity">尚未進入教室</span></div></div><div class="header-actions"><div class="connection-state"><i class="status-dot" id="connectionDot"></i><span id="connectionText">未連線</span></div><button class="btn help compact" id="helpButton">請老師協助</button><button class="btn secondary compact" id="identityButton">更換身分</button></div></header>
3
  <section class="project-bar"><div class="project-context"><label for="weekSelect">Lesson</label><select id="weekSelect"></select><label for="projectName">專案</label><input id="projectName" maxlength="36"></div><div class="project-actions"><button class="copy-button" id="templateButton">匯入專案起始套版</button><button class="copy-button" id="progressButton">選擇舊進度</button><button class="copy-button" id="importButton">匯入 .py / ZIP</button><button class="copy-button" id="exportButton">匯出專案</button><button class="copy-button checkpoint-action" id="checkpointButton">暫存本週進度</button><button class="btn compact" id="submitButton">提交正式版本</button><input id="fileImport" type="file" accept=".py,.zip" hidden></div></section>
4
  <nav class="mobile-tabs" role="tablist"><button class="mobile-tab" data-panel="referencePanel">專案/語法</button><button class="mobile-tab" data-panel="editorPanel" aria-selected="true">程式</button><button class="mobile-tab" data-panel="consolePanel">執行結果</button></nav>
5
+ <main class="student-layout" id="studentLayout"><aside class="panel reference-panel" id="referencePanel"><div class="side-tabs"><button class="side-tab active" data-side="filesView">專案檔案</button><button class="side-tab" data-side="syntaxView">語法查詢</button></div><section class="side-view" id="filesView"><div class="panel-heading"><h1>檔案夾</h1><button class="copy-button" id="newFileButton">+新增</button></div><div class="file-list" id="fileList"></div><p class="side-note">入口固定為 <code>main.py</code>,可用 <code>import</code> 引入其他 Python 檔。</p></section><section class="side-view" id="syntaxView" hidden><div class="panel-heading"><h1>Python 語法查詢</h1><span id="snippetCount">0 項</span></div><div class="search-wrap"><label class="field-label" for="syntaxSearch">輸入語法或用途</label><input class="text-field" id="syntaxSearch" type="search"><div class="chip-row" id="categoryFilters"></div></div><div class="snippet-list" id="snippetList"></div></section></aside>
6
+ <div class="panel-resizer reference-resizer" id="referenceResizer" role="separator" aria-label="調整專案側欄寬度" aria-orientation="vertical" tabindex="0" title="拖曳調整寬度;雙擊恢復預設"></div>
7
  <section class="panel editor-panel mobile-active" id="editorPanel"><div class="panel-heading dark"><span class="file-name" id="activeFileLabel">main.py</span><div class="editor-toolbar"><button class="editor-toggle" id="toggleReferenceButton" aria-pressed="false">隱藏側欄</button><button class="editor-toggle" id="toggleConsoleButton" aria-pressed="false">隱藏 Console</button><button class="btn stop compact" id="stopButton" disabled>停止</button><button class="btn compact" id="runButton" disabled>載入 Python…</button></div></div><div class="editor-host"><textarea id="codeEditor" aria-label="程式碼"></textarea></div></section>
8
+ <div class="panel-resizer console-resizer" id="consoleResizer" role="separator" aria-label="調整 Console 寬度" aria-orientation="vertical" tabindex="0" title="拖曳調整寬度;雙擊恢復預設"></div>
9
  <aside class="panel console-panel" id="consolePanel"><div class="panel-heading"><h2>互動式 Console</h2><button class="copy-button" id="clearOutputButton">清除</button></div><div class="console-body"><div class="console-stream"><pre class="console-output" id="consoleOutput">Python 執行環境載入中…</pre><form class="console-input-row" id="consoleInputForm" hidden><span aria-hidden="true">›</span><label class="sr-only" for="consoleInput">Python 程式要求輸入</label><input id="consoleInput" autocomplete="off"><button class="btn compact" type="submit">送出</button></form></div><p class="console-hint">執行 <code>main.py</code>;遇到 <code>input()</code> 時會在這裡詢問。</p><div class="console-status"><i class="status-dot" id="runStatusDot"></i><span id="runStatusText">準備環境</span><span>·</span><span id="saveStatus">尚未同步</span><span>·</span><span id="submitStatus">尚未暫存或提交</span></div></div></aside></main>
10
  <dialog id="identityDialog"><form class="dialog-content form-stack" id="identityForm"><div><h1>進入程式教室</h1><p>選擇班級並輸入自己在名單上的姓名。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div><label class="field-label" for="displayName">姓名</label><input class="text-field" id="displayName" autocomplete="name" required><p class="field-help">姓名必須與老師建立的名單一致。</p></div><p class="form-error" id="identityError" role="alert"></p><button class="btn" type="submit">進入教室</button></form></dialog>
11
  <dialog id="progressDialog"><div class="dialog-content"><h2>選擇已儲存版本</h2><p>每次暫存與正式提交都會保留;載入只會建立工作副本,不會刪除其他版本。</p><div class="progress-list" id="progressList"></div><button class="btn secondary dialog-close">關閉</button></div></dialog><div class="toast" id="toast"></div>
12
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script><script src="/classroom-assets/student.js?v=2026fall-11"></script></body></html>
classroom_static/student.js CHANGED
@@ -1,5 +1,5 @@
1
  const SNIPPETS=[["基礎","輸入與輸出",'name = input("名字:")\nprint("你好", name)'],["判斷","if / elif / else",'if score >= 90:\n print("A")\nelse:\n print("繼續加油")'],["迴圈","for 與 range",'for number in range(1, 6):\n print(number)'],["迴圈","while",'while True:\n choice = input("選擇:")\n if choice == "q":\n break'],["資料","list",'items = []\nitems.append("線索")\nfor item in items:\n print(item)'],["資料","dict",'student = {"name": "Alex"}\nprint(student.get("team", "未分組"))'],["函式","def / return",'def add(a, b):\n return a + b'],["檔案","引入自己的模組",'from clues import find_clue'],["檔案","讀取 JSON",'import json\nwith open("data.json", encoding="utf-8") as file:\n data = json.load(file)'],["錯誤","try / except",'try:\n age = int(input("年齡:"))\nexcept ValueError:\n print("請輸入整數")']].map(([category,title,code])=>({category,title,code}));
2
- const $=id=>document.getElementById(id),E={};["identityDialog","identityForm","identityError","classCode","displayName","studentIdentity","identityButton","connectionDot","connectionText","helpButton","weekSelect","projectName","templateButton","progressButton","importButton","exportButton","checkpointButton","submitButton","fileImport","fileList","newFileButton","activeFileLabel","syntaxSearch","categoryFilters","snippetList","snippetCount","runButton","stopButton","toggleReferenceButton","toggleConsoleButton","consoleInputForm","consoleInput","consoleOutput","clearOutputButton","runStatusDot","runStatusText","saveStatus","submitStatus","toast","progressDialog","progressList"].forEach(id=>E[id]=$(id));
3
  const editor=CodeMirror.fromTextArea($("codeEditor"),{mode:"python",theme:"material-darker",lineNumbers:true,indentUnit:4,tabSize:4,extraKeys:{Tab:cm=>cm.replaceSelection(" ")}});
4
  let studentId=localStorage.getItem("classroom_student_id")||"",allowLegacyDraft=!!studentId,catalog={courses:[]};
5
  let course,files={"main.py":"# 在這裡寫 Python\n"},activeFile="main.py",socket,worker,workerReady=false,running=false,needsHelp=false,checkpointAt=null,submittedAt=null,output="",suppress=false,timer,reconnect,category="全部",inputBuffer,inputControl,inputBytes,stdoutDecoder=new TextDecoder(),stderrDecoder=new TextDecoder();
@@ -38,7 +38,41 @@ function connect(){
38
  current.onclose=async e=>{if(socket!==current)return;E.connectionDot.className="status-dot error";E.connectionText.textContent="已離線";E.saveStatus.textContent="保存在此裝置";if(e.code===4004||e.code===4404){localStorage.removeItem("classroom_student_id");localStorage.removeItem("classroom_name");studentId="";await loadCatalog();E.identityError.textContent=e.code===4004?"老師已將你從名單移除。":"姓名與班級名單不符,請重新輸入。";E.identityDialog.showModal();return}reconnect=setTimeout(connect,2500)};
39
  }
40
  function renderState(){E.helpButton.classList.toggle("active",needsHelp);E.helpButton.textContent=needsHelp?"取消請求協助":"請老師協助";const time=value=>new Date(value).toLocaleTimeString("zh-TW",{hour:"2-digit",minute:"2-digit"}),labels=[];if(checkpointAt)labels.push(`本週暫存 ${time(checkpointAt)}`);if(submittedAt)labels.push(`正式提交 ${time(submittedAt)}`);E.submitStatus.textContent=labels.join(" · ")||"尚未暫存或提交"}
41
- function setLayout(referenceHidden,consoleHidden){const layout=document.querySelector(".student-layout");layout.classList.toggle("reference-hidden",referenceHidden);layout.classList.toggle("console-hidden",consoleHidden);E.toggleReferenceButton.setAttribute("aria-pressed",String(referenceHidden));E.toggleConsoleButton.setAttribute("aria-pressed",String(consoleHidden));E.toggleReferenceButton.textContent=referenceHidden?"顯示側欄":"隱藏側欄";E.toggleConsoleButton.textContent=consoleHidden?"顯示 Console":"隱藏 Console";localStorage.setItem("classroom_hide_reference",String(referenceHidden));localStorage.setItem("classroom_hide_console",String(consoleHidden));setTimeout(()=>editor.refresh(),0)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  function renderLesson(){const item=lessonInfo(),shared=item?.workspace_id&&item.workspace_id!==item.id;E.templateButton.disabled=!item?.template;E.templateButton.textContent=item?.template?"匯入專案起始套版":shared?"沿用目前專案":"尚未提供套版"}
43
  function initWorker(){worker?.terminate();workerReady=false;running=false;E.consoleInputForm.hidden=true;E.runButton.disabled=true;E.runButton.textContent="載入 Python…";if(!crossOriginIsolated||typeof SharedArrayBuffer==="undefined"){append("瀏覽器未啟用互動式 Python。請重新整理��並使用最新版 Chrome、Edge 或 Safari。\n");runState("error","環境不支援");return}inputBuffer=new SharedArrayBuffer(16392);inputControl=new Int32Array(inputBuffer,0,2);inputBytes=new Uint8Array(inputBuffer,8);worker=new Worker("/classroom-assets/py-worker.js?v=2026fall-4");worker.onmessage=e=>{const m=e.data;if(m.type==="ready"){workerReady=true;E.runButton.disabled=false;E.runButton.textContent="執行";runState("online","準備完成")}else if(m.type==="stream"){const decoder=m.channel==="stderr"?stderrDecoder:stdoutDecoder;append(decoder.decode(m.bytes,{stream:true}))}else if(m.type==="input_request"){E.consoleInputForm.hidden=false;E.consoleInput.value="";E.runStatusText.textContent="等待輸入";E.consoleInput.focus()}else if(m.type==="done"){E.consoleInputForm.hidden=true;runState("success","執行成功")}else if(m.type==="python_error"){E.consoleInputForm.hidden=true;if(m.text)append(`${m.text}\n`);runState("error","程式錯誤")}else if(m.type==="load_error"){append(`Python 載入失敗:${m.text}\n`);runState("error","載入失敗")}};worker.onerror=e=>{append(`Python 執行器錯誤:${e.message}\n`);runState("error","執行器錯誤")}}
44
  function runState(s,t){running=s==="running";E.runStatusDot.className=`status-dot ${s}`;E.runStatusText.textContent=t;E.runButton.disabled=!workerReady||running;E.stopButton.disabled=!running;send({type:"status",status:s==="online"?"idle":s})}
@@ -54,5 +88,5 @@ E.exportButton.onclick=async()=>{files[activeFile]=editor.getValue();const z=new
54
  E.checkpointButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");saveLocal();if(!send({type:"checkpoint",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再暫存");checkpointAt=Date.now();renderState();toast("已新增一個暫存版本,不會發布到 Demo")};
55
  E.submitButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");if(!confirm("新增一個正式提交版本?之前的暫存與提交都會保留。"))return;saveLocal();if(!send({type:"submit",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再提交");submittedAt=Date.now();renderState();toast("已新增一個正式提交版本")};E.helpButton.onclick=()=>{needsHelp=!needsHelp;renderState();send({type:"help",needs_help:needsHelp})};
56
  E.runButton.onclick=()=>{if(!workerReady||running)return;files[activeFile]=editor.getValue();output="";E.consoleOutput.textContent="";E.consoleInputForm.hidden=true;stdoutDecoder=new TextDecoder();stderrDecoder=new TextDecoder();Atomics.store(inputControl,0,0);runState("running","執行中");worker.postMessage({type:"run",files,entry:"main.py",inputBuffer})};E.consoleInputForm.onsubmit=e=>{e.preventDefault();if(!running||E.consoleInputForm.hidden)return;const encoded=new TextEncoder().encode(E.consoleInput.value);if(encoded.length>inputBytes.length)return toast("單次輸入內容太長");inputBytes.fill(0);inputBytes.set(encoded);Atomics.store(inputControl,1,encoded.length);append(`${E.consoleInput.value}\n`);E.consoleInputForm.hidden=true;E.runStatusText.textContent="執行中";Atomics.store(inputControl,0,1);Atomics.notify(inputControl,0,1)};E.stopButton.onclick=()=>{append("\n[已停止]\n");runState("idle","已停止");initWorker()};E.clearOutputButton.onclick=()=>{output="";E.consoleOutput.textContent="尚無執行結果。";send({type:"output",output:""})};E.syntaxSearch.oninput=renderSyntax;editor.on("change",()=>{if(!suppress){files[activeFile]=editor.getValue();sync()}});
57
- E.toggleReferenceButton.onclick=()=>setLayout(!document.querySelector(".student-layout").classList.contains("reference-hidden"),document.querySelector(".student-layout").classList.contains("console-hidden"));E.toggleConsoleButton.onclick=()=>setLayout(document.querySelector(".student-layout").classList.contains("reference-hidden"),!document.querySelector(".student-layout").classList.contains("console-hidden"));document.querySelectorAll(".side-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".side-tab").forEach(x=>x.classList.toggle("active",x===b));document.querySelectorAll(".side-view").forEach(v=>v.hidden=v.id!==b.dataset.side)});document.querySelectorAll(".mobile-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".mobile-tab").forEach(x=>x.setAttribute("aria-selected",String(x===b)));document.querySelectorAll(".student-layout>.panel").forEach(p=>p.classList.toggle("mobile-active",p.id===b.dataset.panel));if(b.dataset.panel==="editorPanel")setTimeout(()=>editor.refresh(),0)});setInterval(()=>send({type:"ping"}),4000);
58
- (async()=>{[course]=await Promise.all([(await fetch("/api/classroom/course")).json(),loadCatalog()]);E.weekSelect.innerHTML=course.weeks.map(w=>`<option value="${w.id}">Lesson ${w.lesson??w.week}|${esc(w.title)}${w.workspace_id&&w.workspace_id!==w.id?"(同一專案)":""}</option>`).join("");const saved=localStorage.getItem("classroom_week");E.weekSelect.value=course.weeks.some(w=>w.id===saved)?saved:"week02";renderLesson();renderFiles();renderSyntax();setLayout(localStorage.getItem("classroom_hide_reference")==="true",localStorage.getItem("classroom_hide_console")==="true");initWorker();connect()})();
 
1
  const SNIPPETS=[["基礎","輸入與輸出",'name = input("名字:")\nprint("你好", name)'],["判斷","if / elif / else",'if score >= 90:\n print("A")\nelse:\n print("繼續加油")'],["迴圈","for 與 range",'for number in range(1, 6):\n print(number)'],["迴圈","while",'while True:\n choice = input("選擇:")\n if choice == "q":\n break'],["資料","list",'items = []\nitems.append("線索")\nfor item in items:\n print(item)'],["資料","dict",'student = {"name": "Alex"}\nprint(student.get("team", "未分組"))'],["函式","def / return",'def add(a, b):\n return a + b'],["檔案","引入自己的模組",'from clues import find_clue'],["檔案","讀取 JSON",'import json\nwith open("data.json", encoding="utf-8") as file:\n data = json.load(file)'],["錯誤","try / except",'try:\n age = int(input("年齡:"))\nexcept ValueError:\n print("請輸入整數")']].map(([category,title,code])=>({category,title,code}));
2
+ const $=id=>document.getElementById(id),E={};["identityDialog","identityForm","identityError","classCode","displayName","studentIdentity","identityButton","connectionDot","connectionText","helpButton","weekSelect","projectName","templateButton","progressButton","importButton","exportButton","checkpointButton","submitButton","fileImport","fileList","newFileButton","activeFileLabel","syntaxSearch","categoryFilters","snippetList","snippetCount","runButton","stopButton","toggleReferenceButton","toggleConsoleButton","studentLayout","editorPanel","referencePanel","consolePanel","referenceResizer","consoleResizer","consoleInputForm","consoleInput","consoleOutput","clearOutputButton","runStatusDot","runStatusText","saveStatus","submitStatus","toast","progressDialog","progressList"].forEach(id=>E[id]=$(id));
3
  const editor=CodeMirror.fromTextArea($("codeEditor"),{mode:"python",theme:"material-darker",lineNumbers:true,indentUnit:4,tabSize:4,extraKeys:{Tab:cm=>cm.replaceSelection(" ")}});
4
  let studentId=localStorage.getItem("classroom_student_id")||"",allowLegacyDraft=!!studentId,catalog={courses:[]};
5
  let course,files={"main.py":"# 在這裡寫 Python\n"},activeFile="main.py",socket,worker,workerReady=false,running=false,needsHelp=false,checkpointAt=null,submittedAt=null,output="",suppress=false,timer,reconnect,category="全部",inputBuffer,inputControl,inputBytes,stdoutDecoder=new TextDecoder(),stderrDecoder=new TextDecoder();
 
38
  current.onclose=async e=>{if(socket!==current)return;E.connectionDot.className="status-dot error";E.connectionText.textContent="已離線";E.saveStatus.textContent="保存在此裝置";if(e.code===4004||e.code===4404){localStorage.removeItem("classroom_student_id");localStorage.removeItem("classroom_name");studentId="";await loadCatalog();E.identityError.textContent=e.code===4004?"老師已將你從名單移除。":"姓名與班級名單不符,請重新輸入。";E.identityDialog.showModal();return}reconnect=setTimeout(connect,2500)};
39
  }
40
  function renderState(){E.helpButton.classList.toggle("active",needsHelp);E.helpButton.textContent=needsHelp?"取消請求協助":"請老師協助";const time=value=>new Date(value).toLocaleTimeString("zh-TW",{hour:"2-digit",minute:"2-digit"}),labels=[];if(checkpointAt)labels.push(`本週暫存 ${time(checkpointAt)}`);if(submittedAt)labels.push(`正式提交 ${time(submittedAt)}`);E.submitStatus.textContent=labels.join(" · ")||"尚未暫存或提交"}
41
+ let editorRefreshFrame;
42
+ function refreshEditorLayout(){cancelAnimationFrame(editorRefreshFrame);editorRefreshFrame=requestAnimationFrame(()=>{editor.refresh();editorRefreshFrame=requestAnimationFrame(()=>editor.refresh())})}
43
+ const panelDefaults={reference:280,console:360},panelMinimums={reference:220,console:260},panelMaximums={reference:480,console:600};
44
+ function panelElement(side){return side==="reference"?E.referencePanel:E.consolePanel}
45
+ function resizerElement(side){return side==="reference"?E.referenceResizer:E.consoleResizer}
46
+ function panelLimits(side){
47
+ const other=side==="reference"?"console":"reference",otherHidden=E.studentLayout.classList.contains(`${other}-hidden`),otherWidth=otherHidden?0:panelElement(other).getBoundingClientRect().width;
48
+ const dividerCount=(E.studentLayout.classList.contains("reference-hidden")?0:1)+(E.studentLayout.classList.contains("console-hidden")?0:1);
49
+ const available=E.studentLayout.clientWidth-320-otherWidth-dividerCount*9;
50
+ return {min:panelMinimums[side],max:Math.max(panelMinimums[side],Math.min(panelMaximums[side],available))};
51
+ }
52
+ function setPanelWidth(side,value,persist=true){
53
+ const requested=parseFloat(value),limits=panelLimits(side),width=Math.round(Math.min(limits.max,Math.max(limits.min,Number.isFinite(requested)?requested:panelDefaults[side]))),resizer=resizerElement(side);
54
+ E.studentLayout.style.setProperty(`--${side}-width`,`${width}px`);
55
+ resizer.setAttribute("aria-valuemin",String(limits.min));resizer.setAttribute("aria-valuemax",String(limits.max));resizer.setAttribute("aria-valuenow",String(width));
56
+ if(persist)localStorage.setItem(`classroom_${side}_width`,String(width));
57
+ refreshEditorLayout();
58
+ }
59
+ function syncPanelWidths(persist=false){setPanelWidth("reference",getComputedStyle(E.studentLayout).getPropertyValue("--reference-width"),persist);setPanelWidth("console",getComputedStyle(E.studentLayout).getPropertyValue("--console-width"),persist)}
60
+ function setupResizer(side){
61
+ const resizer=resizerElement(side);let startX=0,startWidth=0;
62
+ const move=e=>{if(!resizer.hasPointerCapture(e.pointerId))return;const delta=e.clientX-startX;setPanelWidth(side,startWidth+(side==="reference"?delta:-delta))};
63
+ const finish=e=>{if(resizer.hasPointerCapture(e.pointerId))resizer.releasePointerCapture(e.pointerId);resizer.classList.remove("dragging");document.body.classList.remove("panel-resizing")};
64
+ resizer.addEventListener("pointerdown",e=>{if(matchMedia("(max-width: 980px)").matches)return;startX=e.clientX;startWidth=panelElement(side).getBoundingClientRect().width;resizer.setPointerCapture(e.pointerId);resizer.classList.add("dragging");document.body.classList.add("panel-resizing");e.preventDefault()});
65
+ resizer.addEventListener("pointermove",move);resizer.addEventListener("pointerup",finish);resizer.addEventListener("pointercancel",finish);
66
+ resizer.addEventListener("dblclick",()=>setPanelWidth(side,panelDefaults[side]));
67
+ resizer.addEventListener("keydown",e=>{if(!["ArrowLeft","ArrowRight","Home","End"].includes(e.key))return;e.preventDefault();const limits=panelLimits(side),current=panelElement(side).getBoundingClientRect().width;if(e.key==="Home")setPanelWidth(side,limits.min);else if(e.key==="End")setPanelWidth(side,limits.max);else{const dividerDirection=e.key==="ArrowRight"?1:-1;setPanelWidth(side,current+(side==="reference"?dividerDirection:-dividerDirection)*16)}});
68
+ }
69
+ function initResizablePanels(){
70
+ const savedReference=Number(localStorage.getItem("classroom_reference_width")),savedConsole=Number(localStorage.getItem("classroom_console_width"));
71
+ if(Number.isFinite(savedReference)&&savedReference>0)E.studentLayout.style.setProperty("--reference-width",`${savedReference}px`);
72
+ if(Number.isFinite(savedConsole)&&savedConsole>0)E.studentLayout.style.setProperty("--console-width",`${savedConsole}px`);
73
+ setupResizer("reference");setupResizer("console");new ResizeObserver(refreshEditorLayout).observe(E.editorPanel);window.addEventListener("resize",()=>syncPanelWidths(false));
74
+ }
75
+ function setLayout(referenceHidden,consoleHidden){E.studentLayout.classList.toggle("reference-hidden",referenceHidden);E.studentLayout.classList.toggle("console-hidden",consoleHidden);E.toggleReferenceButton.setAttribute("aria-pressed",String(referenceHidden));E.toggleConsoleButton.setAttribute("aria-pressed",String(consoleHidden));E.toggleReferenceButton.textContent=referenceHidden?"顯示側欄":"隱藏側欄";E.toggleConsoleButton.textContent=consoleHidden?"顯示 Console":"隱藏 Console";localStorage.setItem("classroom_hide_reference",String(referenceHidden));localStorage.setItem("classroom_hide_console",String(consoleHidden));requestAnimationFrame(()=>{syncPanelWidths(false);refreshEditorLayout()})}
76
  function renderLesson(){const item=lessonInfo(),shared=item?.workspace_id&&item.workspace_id!==item.id;E.templateButton.disabled=!item?.template;E.templateButton.textContent=item?.template?"匯入專案起始套版":shared?"沿用目前專案":"尚未提供套版"}
77
  function initWorker(){worker?.terminate();workerReady=false;running=false;E.consoleInputForm.hidden=true;E.runButton.disabled=true;E.runButton.textContent="載入 Python…";if(!crossOriginIsolated||typeof SharedArrayBuffer==="undefined"){append("瀏覽器未啟用互動式 Python。請重新整理��並使用最新版 Chrome、Edge 或 Safari。\n");runState("error","環境不支援");return}inputBuffer=new SharedArrayBuffer(16392);inputControl=new Int32Array(inputBuffer,0,2);inputBytes=new Uint8Array(inputBuffer,8);worker=new Worker("/classroom-assets/py-worker.js?v=2026fall-4");worker.onmessage=e=>{const m=e.data;if(m.type==="ready"){workerReady=true;E.runButton.disabled=false;E.runButton.textContent="執行";runState("online","準備完成")}else if(m.type==="stream"){const decoder=m.channel==="stderr"?stderrDecoder:stdoutDecoder;append(decoder.decode(m.bytes,{stream:true}))}else if(m.type==="input_request"){E.consoleInputForm.hidden=false;E.consoleInput.value="";E.runStatusText.textContent="等待輸入";E.consoleInput.focus()}else if(m.type==="done"){E.consoleInputForm.hidden=true;runState("success","執行成功")}else if(m.type==="python_error"){E.consoleInputForm.hidden=true;if(m.text)append(`${m.text}\n`);runState("error","程式錯誤")}else if(m.type==="load_error"){append(`Python 載入失敗:${m.text}\n`);runState("error","載入失敗")}};worker.onerror=e=>{append(`Python 執行器錯誤:${e.message}\n`);runState("error","執行器錯誤")}}
78
  function runState(s,t){running=s==="running";E.runStatusDot.className=`status-dot ${s}`;E.runStatusText.textContent=t;E.runButton.disabled=!workerReady||running;E.stopButton.disabled=!running;send({type:"status",status:s==="online"?"idle":s})}
 
88
  E.checkpointButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");saveLocal();if(!send({type:"checkpoint",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再暫存");checkpointAt=Date.now();renderState();toast("已新增一個暫存版本,不會發布到 Demo")};
89
  E.submitButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");if(!confirm("新增一個正式提交版本?之前的暫存與提交都會保留。"))return;saveLocal();if(!send({type:"submit",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再提交");submittedAt=Date.now();renderState();toast("已新增一個正式提交版本")};E.helpButton.onclick=()=>{needsHelp=!needsHelp;renderState();send({type:"help",needs_help:needsHelp})};
90
  E.runButton.onclick=()=>{if(!workerReady||running)return;files[activeFile]=editor.getValue();output="";E.consoleOutput.textContent="";E.consoleInputForm.hidden=true;stdoutDecoder=new TextDecoder();stderrDecoder=new TextDecoder();Atomics.store(inputControl,0,0);runState("running","執行中");worker.postMessage({type:"run",files,entry:"main.py",inputBuffer})};E.consoleInputForm.onsubmit=e=>{e.preventDefault();if(!running||E.consoleInputForm.hidden)return;const encoded=new TextEncoder().encode(E.consoleInput.value);if(encoded.length>inputBytes.length)return toast("單次輸入內容太長");inputBytes.fill(0);inputBytes.set(encoded);Atomics.store(inputControl,1,encoded.length);append(`${E.consoleInput.value}\n`);E.consoleInputForm.hidden=true;E.runStatusText.textContent="執行中";Atomics.store(inputControl,0,1);Atomics.notify(inputControl,0,1)};E.stopButton.onclick=()=>{append("\n[已停止]\n");runState("idle","已停止");initWorker()};E.clearOutputButton.onclick=()=>{output="";E.consoleOutput.textContent="尚無執行結果。";send({type:"output",output:""})};E.syntaxSearch.oninput=renderSyntax;editor.on("change",()=>{if(!suppress){files[activeFile]=editor.getValue();sync()}});
91
+ E.toggleReferenceButton.onclick=()=>setLayout(!E.studentLayout.classList.contains("reference-hidden"),E.studentLayout.classList.contains("console-hidden"));E.toggleConsoleButton.onclick=()=>setLayout(E.studentLayout.classList.contains("reference-hidden"),!E.studentLayout.classList.contains("console-hidden"));document.querySelectorAll(".side-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".side-tab").forEach(x=>x.classList.toggle("active",x===b));document.querySelectorAll(".side-view").forEach(v=>v.hidden=v.id!==b.dataset.side)});document.querySelectorAll(".mobile-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".mobile-tab").forEach(x=>x.setAttribute("aria-selected",String(x===b)));document.querySelectorAll(".student-layout>.panel").forEach(p=>p.classList.toggle("mobile-active",p.id===b.dataset.panel));if(b.dataset.panel==="editorPanel")refreshEditorLayout()});setInterval(()=>send({type:"ping"}),4000);
92
+ (async()=>{[course]=await Promise.all([(await fetch("/api/classroom/course")).json(),loadCatalog()]);E.weekSelect.innerHTML=course.weeks.map(w=>`<option value="${w.id}">Lesson ${w.lesson??w.week}|${esc(w.title)}${w.workspace_id&&w.workspace_id!==w.id?"(同一專案)":""}</option>`).join("");const saved=localStorage.getItem("classroom_week");E.weekSelect.value=course.weeks.some(w=>w.id===saved)?saved:"week02";renderLesson();renderFiles();renderSyntax();initResizablePanels();setLayout(localStorage.getItem("classroom_hide_reference")==="true",localStorage.getItem("classroom_hide_console")==="true");initWorker();connect()})();
tests/test_admin.py CHANGED
@@ -25,9 +25,11 @@ class AdminServiceTests(unittest.TestCase):
25
  }],
26
  }, ensure_ascii=False), encoding="utf-8")
27
  (self.data / "classes.json").write_text(json.dumps([{"code": "L6A", "name": "測試班", "students": []}], ensure_ascii=False), encoding="utf-8")
28
- self.old = (classroom.DATA, classroom.TEMPLATES, classroom.PASSWORD, classroom.RUNTIME, classroom.DB, admin.ROOT, admin.PROJECTS, admin.commit_changes)
29
  classroom.DATA = self.data
30
  classroom.TEMPLATES = self.templates
 
 
31
  classroom.PASSWORD = "teacher-test"
32
  classroom.RUNTIME = self.root / "runtime"
33
  classroom.DB = classroom.RUNTIME / "classroom.sqlite3"
@@ -43,7 +45,7 @@ class AdminServiceTests(unittest.TestCase):
43
  admin.commit_changes = fake_commit
44
 
45
  def tearDown(self):
46
- classroom.DATA, classroom.TEMPLATES, classroom.PASSWORD, classroom.RUNTIME, classroom.DB, admin.ROOT, admin.PROJECTS, admin.commit_changes = self.old
47
  self.temp_dir.cleanup()
48
 
49
  def test_teacher_updates_week_and_template_files(self):
 
25
  }],
26
  }, ensure_ascii=False), encoding="utf-8")
27
  (self.data / "classes.json").write_text(json.dumps([{"code": "L6A", "name": "測試班", "students": []}], ensure_ascii=False), encoding="utf-8")
28
+ self.old = (classroom.DATA, classroom.TEMPLATES, classroom.CHECKPOINTS, classroom.VERSIONS, classroom.PASSWORD, classroom.RUNTIME, classroom.DB, admin.ROOT, admin.PROJECTS, admin.commit_changes)
29
  classroom.DATA = self.data
30
  classroom.TEMPLATES = self.templates
31
+ classroom.CHECKPOINTS = self.data / "checkpoints"
32
+ classroom.VERSIONS = self.data / "versions"
33
  classroom.PASSWORD = "teacher-test"
34
  classroom.RUNTIME = self.root / "runtime"
35
  classroom.DB = classroom.RUNTIME / "classroom.sqlite3"
 
45
  admin.commit_changes = fake_commit
46
 
47
  def tearDown(self):
48
+ classroom.DATA, classroom.TEMPLATES, classroom.CHECKPOINTS, classroom.VERSIONS, classroom.PASSWORD, classroom.RUNTIME, classroom.DB, admin.ROOT, admin.PROJECTS, admin.commit_changes = self.old
49
  self.temp_dir.cleanup()
50
 
51
  def test_teacher_updates_week_and_template_files(self):