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
6 changes: 3 additions & 3 deletions apps/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useProgress } from '@/context/ProgressContext';
import Home from '@/screens/Home';
import ChapterMap from '@/screens/ChapterMap';
import Quiz from '@/screens/Quiz';
import { chapters, getLevel, MIXED_SIZE, sampleQuestions, shuffle, type Question } from '@easylearn/core';
import { chapters, getLevel, LEVEL_SIZE, MIXED_SIZE, sampleFixedQuestions, type Question } from '@easylearn/core';

type ViewState =
| { name: 'home' }
Expand Down Expand Up @@ -36,12 +36,12 @@ export default function HomeScreen() {
const startLevel = (levelId: string) => {
const level = getLevel(levelId);
if (!level) return;
setView({ name: 'quiz', levelId, questions: sampleQuestions(level.questions) });
setView({ name: 'quiz', levelId, questions: sampleFixedQuestions(level.questions, LEVEL_SIZE) });
};

const startMixedPractice = () => {
const pool = chapters.flatMap((ch) => ch.levels.flatMap((l) => l.questions));
const picked = shuffle(pool).slice(0, MIXED_SIZE).sort((a, b) => a.difficulty - b.difficulty);
const picked = sampleFixedQuestions(pool, MIXED_SIZE);
setView({ name: 'mixed', questions: picked });
};

Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/app/(tabs)/notes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import {
getWrongEntries,
getWrongQuestions,
REVIEW_SIZE,
sampleFixedQuestions,
sampleQuestions,
shuffle,
type Question,
} from '@easylearn/core';

Expand All @@ -41,7 +41,7 @@ export default function NotesScreen() {
}

const startReview = () => {
const picked = shuffle(getWrongQuestions(progress.wrongIds)).slice(0, REVIEW_SIZE);
const picked = sampleFixedQuestions(getWrongQuestions(progress.wrongIds), REVIEW_SIZE);
if (picked.length === 0) return;
setView({ name: 'review', questions: picked });
};
Expand Down
19 changes: 5 additions & 14 deletions apps/mobile/screens/ChapterMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { Text } from '@/components/Themed';
import Icon from '@/components/Icon';
import { colors, fonts } from '@/constants/theme';
import { chapters, type IconName, type Progress } from '@easylearn/core';
import { chapters, LEVEL_SIZE, type Progress } from '@easylearn/core';

type StatusIcon = 'lock' | 'check-circle' | 'play';
type StatusIcon = 'check-circle' | 'play';

const STATUS_COLOR: Record<StatusIcon, string> = {
lock: colors.locked,
'check-circle': colors.cyan,
play: colors.primary,
};
Expand Down Expand Up @@ -37,15 +36,12 @@ export default function ChapterMap({ chapterId, progress, onStartLevel, onBack }

{chapter.levels.map((level, i) => {
const record = progress.completedLevels[level.id];
const prevDone = i === 0 || progress.completedLevels[chapter.levels[i - 1].id];
const locked = !prevDone;
const statusIcon: StatusIcon = locked ? 'lock' : record ? 'check-circle' : 'play';
const statusIcon: StatusIcon = record ? 'check-circle' : 'play';
return (
<Pressable
key={level.id}
disabled={locked}
onPress={() => onStartLevel(level.id)}
style={[styles.levelRow, locked && styles.levelLocked]}
style={styles.levelRow}
>
<View style={styles.levelIcon}>
<Icon name={statusIcon} size={20} color={STATUS_COLOR[statusIcon]} />
Expand All @@ -56,9 +52,7 @@ export default function ChapterMap({ chapterId, progress, onStartLevel, onBack }
<Text style={styles.levelRecord}>
{record
? `最佳 ${record.best}/${record.total}`
: locked
? '完成上一關解鎖'
: `共 ${level.questions.length} 題`}
: `共 ${Math.min(LEVEL_SIZE, level.questions.length)} 題`}
</Text>
</Pressable>
);
Expand Down Expand Up @@ -104,9 +98,6 @@ const styles = StyleSheet.create({
paddingVertical: 16,
paddingHorizontal: 18,
},
levelLocked: {
opacity: 0.55,
},
levelIcon: {
width: 28,
height: 28,
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import {
getWrongEntries,
getSavedQuestions,
chapters,
shuffle,
sampleQuestions,
sampleFixedQuestions,
REVIEW_SIZE,
MIXED_SIZE,
LEVEL_SIZE,
} from '@easylearn/core'
import Navbar from '@/components/Navbar'
import Home from '@/screens/Home'
Expand Down Expand Up @@ -51,21 +52,21 @@ const App = () => {
const [view, setView] = useState<View>({ name: 'home' })

const startLevel = (levelId: string) => {
// 整個題池隨機排序作答(同難度內順序每次不同)
// 固定抽 LEVEL_SIZE 題作答,題池不足就整包抽完(同難度內順序每次不同)
const level = getLevel(levelId)
if (!level) return
setView({ name: 'quiz', levelId, questions: sampleQuestions(level.questions) })
setView({ name: 'quiz', levelId, questions: sampleFixedQuestions(level.questions, LEVEL_SIZE) })
}

const startReview = () => {
const picked = shuffle(getWrongQuestions(progress.wrongIds)).slice(0, REVIEW_SIZE)
const picked = sampleFixedQuestions(getWrongQuestions(progress.wrongIds), REVIEW_SIZE)
if (picked.length === 0) return
setView({ name: 'review', questions: picked })
}

const startMixedPractice = () => {
const pool = chapters.flatMap((ch) => ch.levels.flatMap((l) => l.questions))
const picked = shuffle(pool).slice(0, MIXED_SIZE).sort((a, b) => a.difficulty - b.difficulty)
const picked = sampleFixedQuestions(pool, MIXED_SIZE)
setView({ name: 'mixed', questions: picked })
}

Expand Down
13 changes: 4 additions & 9 deletions apps/web/src/screens/ChapterMap.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Icon from '@/components/Icons'
import { chapters, type IconName, type Progress } from '@easylearn/core'
import { chapters, LEVEL_SIZE, type IconName, type Progress } from '@easylearn/core'

interface ChapterMapProps {
chapterId: string | null
Expand All @@ -25,14 +25,11 @@ const ChapterMap = ({ chapterId, progress, onStartLevel, onBack }: ChapterMapPro

{chapter.levels.map((level, i) => {
const record = progress.completedLevels[level.id]
const prevDone = i === 0 || progress.completedLevels[chapter.levels[i - 1].id]
const locked = !prevDone
const statusIcon: IconName = locked ? 'lock' : record ? 'check-circle' : 'play'
const statusIcon: IconName = record ? 'check-circle' : 'play'
return (
<button
key={level.id}
className={`level-row ${locked ? 'level-locked' : ''} ${record ? 'level-done' : ''}`}
disabled={locked}
className={`level-row ${record ? 'level-done' : ''}`}
onClick={() => onStartLevel(level.id)}
>
<span className={`level-icon status-${statusIcon}`}>
Expand All @@ -44,9 +41,7 @@ const ChapterMap = ({ chapterId, progress, onStartLevel, onBack }: ChapterMapPro
<span className="level-record">
{record
? `最佳 ${record.best}/${record.total}`
: locked
? '完成上一關解鎖'
: `共 ${level.questions.length} 題`}
: `共 ${Math.min(LEVEL_SIZE, level.questions.length)} 題`}
</span>
</button>
)
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/data/chapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,36 @@ import fp16 from './questions/fp-16-sharing-resources.json'
import fp17 from './questions/fp-17-coordinating-timelines.json'
import fp18 from './questions/fp-18-reactive-onion.json'
import fp19 from './questions/fp-19-road-ahead.json'
import ri1 from './questions/ri-1-router.json'
import ri2 from './questions/ri-2-i18n.json'
import ri3 from './questions/ri-3-testing.json'
import ri4 from './questions/ri-4-redux.json'
import ri5 from './questions/ri-5-native.json'
import ri6 from './questions/ri-6-ecosystem.json'
import ri7 from './questions/ri-7-misc.json'
import ri8 from './questions/ri-8-modern.json'
import ri9 from './questions/ri-9-core-basics.json'
import ri10 from './questions/ri-10-core-props-events.json'
import ri11 from './questions/ri-11-core-vdom.json'
import ri12 from './questions/ri-12-core-hoc-composition.json'
import ri13 from './questions/ri-13-core-ecosystem-position.json'
import ri14 from './questions/ri-14-core-rendering-patterns.json'
import ri15 from './questions/ri-15-core-jsx-details.json'
import ri16 from './questions/ri-16-core-styling-tools.json'
import ri17 from './questions/ri-17-core-project-conventions.json'
import ri18 from './questions/ri-18-misc-early.json'
import ri19 from './questions/ri-19-misc-hooks-foundations.json'
import ri20 from './questions/ri-20-misc-rendering-internals.json'
import ri21 from './questions/ri-21-misc-reducer-context-deep.json'
import ri22 from './questions/ri-22-misc-useeffect-deep.json'
import ri23 from './questions/ri-23-misc-layouteffect-ref-imperative.json'
import ri24 from './questions/ri-24-misc-memo-callback-custom-hooks.json'
import ri25 from './questions/ri-25-misc-advanced-hooks.json'
import ri26 from './questions/ri-26-misc-code-splitting-perf.json'
import ri27 from './questions/ri-27-misc-error-boundaries.json'
import ri28 from './questions/ri-28-misc-component-conventions.json'
import ri29 from './questions/ri-29-misc-forms-composition.json'
import ri30 from './questions/ri-30-misc-state-alternatives-internals.json'
import type { Chapter, Level, Question, WrongEntry, WrongEntryMeta } from '../types'

// 題目 JSON 的 type 欄位在匯入時只會被推斷成 string,用 as 收斂回字面量聯合型別
Expand Down Expand Up @@ -82,6 +112,16 @@ export const chapters: Chapter[] = [
fp11, fp12, fp13, fp14, fp15, fp16, fp17, fp18, fp19,
].map(asLevel),
},
{
id: 'react-interview',
title: 'React 面試題',
icon: 'lightbulb',
levels: [
ri9, ri10, ri11, ri12, ri13, ri14, ri15, ri16, ri17,
ri1, ri2, ri3, ri4, ri5, ri6, ri7, ri8,
ri18, ri19, ri20, ri21, ri22, ri23, ri24, ri25, ri26, ri27, ri28, ri29, ri30,
].map(asLevel),
Comment on lines +119 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Add the 31st requested level.

This chapter maps only 30 datasets (ri-1 through ri-30), while the PR objective specifies 31 levels. Add the missing dataset and level entry, or revise the objective before release.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/data/chapters.ts` around lines 119 - 123, Update the
chapter’s levels array to include the missing 31st dataset (ri31) and ensure it
is passed through asLevel alongside ri1–ri30, preserving the existing level
ordering conventions.

},
]

// 抽象屏障:所有跟「題目 × 所屬章節」相關的查詢都透過這份攤平清單計算,
Expand Down
57 changes: 57 additions & 0 deletions packages/core/src/data/questions/fp-1-welcome.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,63 @@
"answer": "a",
"explanation": "「相同輸入永遠得到相同輸出」是純函式最基本的判準——不管什麼時候呼叫、呼叫幾次,只要參數相同,結果就該相同。B、C、D 描述的都是依賴外部狀態或時機的 Action 行為,不是純函式。",
"verify": { "manual": "概念題:以具體呼叫情境取代抽象特性列舉,無可執行程式碼。" }
},
{
"id": "fp-1-q4",
"type": "concept",
"difficulty": 1,
"topic": "純函式讓測試不需要 mock 任何東西",
"docs": "",
"story": "",
"prompt": "幫 calcTax(price) 寫測試,跟幫另一個會發送推播通知的函式 notifyMember(userId) 寫測試比起來,最大的差異是什麼?",
"code": "function calcTax(price) { return price * 1.05; }\nfunction notifyMember(userId) {\n sendPushNotification(userId, \"...\");\n saveNotifyLog(userId);\n}",
"options": [
{ "id": "a", "text": "兩者測試起來難度差不多,只要換一套合適的 mock 工具,notifyMember 也能像 calcTax 一樣直接斷言結果,副作用不會增加多少額外工作" },
{ "id": "b", "text": "notifyMember 反而比較好測試,因為它牽涉的動作比較多,測試案例寫起來自然比較完整、比較看得出程式邏輯" },
{ "id": "c", "text": "測試 calcTax 只需要呼叫它、斷言回傳值即可;測試 notifyMember 得先 mock 掉推播服務、假造資料庫紀錄,才能在不真的推播、不真的寫資料庫的前提下驗證邏輯——這正是純函式帶來的測試便利性" },
{ "id": "d", "text": "只有 calcTax 能被單元測試,notifyMember 依賴外部服務,技術上完全沒辦法寫出任何測試" }
],
"answer": "c",
"explanation": "純函式的測試只需要「給輸入、斷言輸出」;牽涉副作用的函式(如 notifyMember),測試前得先隔離、模擬它依賴的外部世界,這份額外的準備工作量正是副作用帶來的代價,如選項 c 所述。",
"verify": { "manual": "概念題:以測試撰寫難度的具體對比取代抽象敘述,無可執行程式碼。" }
},
{
"id": "fp-1-q5",
"type": "concept",
"difficulty": 2,
"topic": "相同輸入不同輸出,代表問題通常不在這個函式本身",
"docs": "",
"story": "",
"prompt": "使用者回報「同樣按下結帳按鈕,有時候算出來的金額不一樣」。如果 calcTotal(cart) 嚴格遵守「相同輸入永遠同樣輸出」這個純函式規則,這個 bug 有沒有可能出在 calcTotal 內部?",
"code": "",
"options": [
{ "id": "a", "text": "很有可能,純函式一樣會受到執行當下的系統時間、網路延遲、使用者時區等外部環境影響,就算輸入的 cart 完全相同也可能算出不同的金額" },
{ "id": "b", "text": "不太可能:如果 calcTotal 真的是純函式,同樣的 cart 輸入必然算出同樣結果,「金額不一樣」代表兩次傳入的 cart 內容本身就不同;問題應該往 cart 是怎麼組出來的方向查,而不是懷疑 calcTotal 本身" },
{ "id": "c", "text": "無法判斷,因為金額算得一不一致跟這個函式到底是不是純函式完全沒有關係,純不純只影響程式碼好不好維護、好不好測試" },
{ "id": "d", "text": "一定是 calcTotal 內部偷用了亂數產生器或讀取了目前系統時間,這是唯一可能造成金額浮動的原因,不用再往其他地方查" }
],
"answer": "b",
"explanation": "純函式的「相同輸入同樣輸出」保證,反過來也是一個很好用的除錯線索(如選項 b):確認一個函式真的是純函式之後,看到「同樣操作、不同結果」就能直接排除它,把注意力放回輸入資料本身是怎麼變出來的。",
"verify": { "manual": "概念題:以除錯情境取代抽象特性說明,無可執行程式碼。" }
},
{
"id": "fp-1-q6",
"type": "concept",
"difficulty": 1,
"topic": "FP 降低心智負擔:只看輸入輸出,不用追蹤全部呼叫路徑",
"docs": "",
"story": "",
"prompt": "除錯一個純函式 calcDiscount(price, rate),跟除錯一個到處讀寫全域變數的函式,何者需要在腦中同時記住的東西比較少?",
"code": "",
"options": [
{ "id": "a", "text": "讀寫全域變數的函式比較好除錯,因為隨時能看到目前的全域狀態,不像純函式那樣得另外準備輸入值才能觀察行為" },
{ "id": "b", "text": "兩者需要在腦中記住的東西一樣多,跟這個函式純不純完全無關,差別只在於個人除錯習慣好不好" },
{ "id": "c", "text": "純函式反而更難除錯,因為看不到任何中間狀態,得靠額外的 log 才能知道內部發生了什麼事" },
{ "id": "d", "text": "純函式:結果只取決於傳進來的參數,除錯時只需要盯著這幾個輸入值和函式邏輯,不必在腦中追蹤「全域變數在其他地方分別被誰改過、現在值到底是多少」——這正是 FP 想降低的心智負擔" }
],
"answer": "d",
"explanation": "全域狀態的問題在於「任何地方都可能是兇手」,除錯時得在腦中同時追蹤一大堆分散的修改點;純函式把所有相關資訊都收斂到參數列表裡,除錯時只需要盯著眼前這一小段,如選項 d 所述,這是 FP 對「人類工作記憶有限」這個現實的務實回應。",
"verify": { "manual": "概念題:以除錯所需心智負擔的具體對比取代抽象敘述,無可執行程式碼。" }
}
]
}
Loading