diff --git a/README.md b/README.md index a17707a..d27b025 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ BAM.dev는 프로그래밍 문법을 읽는 데서 멈추지 않고, 개념을 설명하고 직접 구현하며 한 명의 개발자로 성장하도록 돕는 학습 가이드입니다. -현재 구현 범위는 **0~3차 JavaScript 학습 기반과 확장 검증 게이트**입니다. JavaScript 교안 7개, 교안별 기초·적용 객관식 14개, Code Quest 5개를 탐색할 수 있으며 HTML·CSS·Java에는 교안과 객관식 샘플이 각각 1개씩 있습니다. 사이드바에서 네 언어를 전환할 수 있고, 학습 진도와 객관식 결과뿐 아니라 Quest 코드 초안·실행 기록·완료 상태도 새로고침 뒤 유지됩니다. Quest는 브라우저의 공개 테스트를 실제로 실행하고 테스트별 결과, 실패 원인과 단계별 힌트를 제공합니다. +현재 구현 범위는 **0~4차와 확장 검증 게이트**입니다. JavaScript 교안 7개, 교안별 기초·적용 객관식 14개, Code Quest 5개와 코딩테스트 6개를 탐색할 수 있으며 HTML·CSS·Java에는 교안과 객관식 샘플이 각각 1개씩 있습니다. 코딩테스트 목록은 검색, 난이도·언어·유형·풀이 여부 필터를 제공하고 문제 화면은 설명·편집기·실행 결과를 분할해 표시합니다. `테스트 실행`은 빠른 공개 테스트만 확인하고, `제출 및 채점`은 브라우저에 포함된 공개 테스트 전체를 평가해 풀이 상태를 저장합니다. ## 바로 실행하기 @@ -25,9 +25,9 @@ npm run check ## 프로젝트 구조 ```text -content/ 언어 비종속 메타데이터, 교안·로컬 fixture, 객관식·Quest 컬렉션 +content/ 언어 비종속 메타데이터, 교안·fixture, 객관식·Quest·코딩테스트 src/core/ 콘텐츠·내비게이션·평가 도메인 로직 -src/grading/ Code Quest 실행 요청 검증과 브라우저 채점 어댑터 +src/grading/ 공통 실행 요청 검증과 Quest·코딩테스트 브라우저 채점 어댑터 src/repositories/ 사용자 진도 저장소 추상화와 localStorage 구현 src/ui/ 안전한 제한형 Markdown·평가 화면 렌더러 src/workers/ 공개 JavaScript 테스트를 실행하는 일회성 Worker @@ -41,7 +41,7 @@ docs/ 아키텍처·결정·참고자료 기록 ## 저장 정책 -학습 완료, 최근 교안, 최근 객관식 시도 20개, 오답 재도전 대상, Quest 초안 20개와 최근 실행 50개·완료 ID는 현재 `bam.dev.progress.v1` 키로 `localStorage`에 저장됩니다. 사용자 소스는 실행 기록에 중복 저장하지 않고 초안 저장소에만 보관합니다. 화면 코드는 브라우저 저장소를 직접 다루지 않고 `ProgressRepository` 계약을 사용하므로, 8차에서 Supabase 저장소로 교체하거나 로컬 데이터를 마이그레이션할 수 있습니다. 브라우저 저장소가 차단되면 현재 탭의 메모리 저장소로 계속 동작하고 비영속 상태를 화면에 알립니다. +학습 완료, 최근 교안, 최근 객관식 시도 20개, 오답 재도전 대상, Quest 초안 20개와 최근 실행 50개·완료 ID, 코딩테스트 초안 20개와 최근 제출 50개·리비전별 완료 상태는 현재 `bam.dev.progress.v1` 키로 `localStorage`에 저장됩니다. 사용자 소스는 실행·제출 기록에 중복 저장하지 않고 초안 저장소에만 보관합니다. 화면 코드는 브라우저 저장소를 직접 다루지 않고 `ProgressRepository` 계약을 사용하므로, 8차에서 Supabase 저장소로 교체하거나 로컬 데이터를 마이그레이션할 수 있습니다. 브라우저 저장소가 차단되면 현재 탭의 메모리 저장소로 계속 동작하고 비영속 상태를 화면에 알립니다. ## 참고 자료 diff --git a/content/coding-tests/javascript.json b/content/coding-tests/javascript.json new file mode 100644 index 0000000..f5707b9 --- /dev/null +++ b/content/coding-tests/javascript.json @@ -0,0 +1,710 @@ +{ + "schemaVersion": 1, + "contractVersion": 1, + "languageId": "javascript", + "title": "JavaScript 코딩테스트", + "problems": [ + { + "id": "coding-test-javascript-target-words", + "slug": "count-target-words", + "revision": 1, + "order": 1, + "lessonId": "js-04-collections", + "conceptIds": ["js.arrays", "js.builtins"], + "difficulty": "beginner", + "type": "string", + "tags": ["문자열", "분리", "카운팅"], + "estimatedMinutes": 15, + "title": "목표 단어 세기", + "summary": "공백으로 구분된 문장에서 대소문자를 무시하고 목표 단어의 등장 횟수를 셉니다.", + "description": "`countTargetWords(text, target)` 함수를 작성하세요. `text`를 공백 문자로 나눈 단어 중 `target`과 대소문자를 무시하고 정확히 같은 단어의 개수를 반환합니다. 다른 단어 안에 포함된 부분 문자열은 세지 않습니다.", + "functionContract": { + "parameters": [ + { + "name": "text", + "type": "string", + "description": "영문자와 공백 문자로 이루어진 길이 0 이상 1,000 이하의 문자열입니다." + }, + { + "name": "target", + "type": "string", + "description": "공백이 없는 영문 단어이며 길이는 1 이상 30 이하입니다." + } + ], + "returns": { + "type": "integer", + "description": "대소문자를 무시했을 때 target과 정확히 같은 단어의 수입니다." + }, + "constraints": [ + "단어는 하나 이상의 공백 문자로 구분됩니다.", + "대소문자는 구분하지 않습니다.", + "부분 문자열 일치는 단어 일치로 세지 않습니다." + ], + "complexity": { + "time": "O(n)", + "space": "O(n)" + } + }, + "entryPoint": "countTargetWords", + "starterCode": "function countTargetWords(text, target) {\n // 문장을 단어 단위로 나눈 뒤 target과 비교하세요.\n return 0;\n}\n", + "examples": [ + { + "args": ["Code learn code", "code"], + "expected": 2, + "explanation": "Code와 code는 대소문자를 무시하면 모두 목표 단어와 같습니다." + } + ], + "publicTests": [ + { + "id": "words-empty-text", + "label": "빈 문장", + "args": ["", "js"], + "expected": 0 + }, + { + "id": "words-basic-repeat", + "label": "같은 단어가 두 번 등장", + "args": ["Code learn code", "code"], + "expected": 2 + }, + { + "id": "words-ignore-case", + "label": "대소문자가 다른 같은 단어", + "args": ["JS js Js", "js"], + "expected": 3 + }, + { + "id": "words-repeated-whitespace", + "label": "앞뒤와 단어 사이에 여러 공백", + "args": [" build test build ", "build"], + "expected": 2 + }, + { + "id": "words-substring-trap", + "label": "다른 단어 안의 부분 문자열", + "args": ["cat scatter cat", "cat"], + "expected": 2 + }, + { + "id": "words-target-absent", + "label": "목표 단어가 없는 문장", + "args": ["arrays and objects", "function"], + "expected": 0 + } + ], + "runTestIds": ["words-basic-repeat", "words-ignore-case", "words-substring-trap"], + "failureExplanations": [ + { + "testId": "words-empty-text", + "message": "빈 문장은 단어를 하나 가진 배열이 아니라 목표 단어가 0번 등장한 경우입니다." + }, + { + "testId": "words-basic-repeat", + "message": "문장의 모든 단어를 끝까지 순회하며 같은 단어를 누적했는지 확인하세요." + }, + { + "testId": "words-ignore-case", + "message": "문장 단어와 목표 단어를 같은 대소문자 형태로 바꾼 뒤 비교하세요." + }, + { + "testId": "words-repeated-whitespace", + "message": "하나 이상의 연속된 공백을 하나의 단어 구분으로 처리해야 합니다." + }, + { + "testId": "words-substring-trap", + "message": "문자열 내부 포함 여부가 아니라 분리된 단어 전체가 같은지 비교하세요." + }, + { + "testId": "words-target-absent", + "message": "일치하는 단어가 없으면 누적값 0을 그대로 반환해야 합니다." + } + ] + }, + { + "id": "coding-test-javascript-inventory-summary", + "slug": "summarize-inventory", + "revision": 1, + "order": 2, + "lessonId": "js-04-collections", + "conceptIds": ["js.arrays", "js.objects"], + "difficulty": "beginner", + "type": "object", + "tags": ["배열", "객체", "누적"], + "estimatedMinutes": 20, + "title": "재고 수량 요약하기", + "summary": "재고 항목을 순회해 카테고리별 수량을 하나의 객체로 누적합니다.", + "description": "`summarizeInventory(items)` 함수를 작성하세요. 각 항목의 `category`와 `quantity`를 읽어 food, book, tool 세 카테고리의 총수량을 반환합니다. 항목이 없거나 특정 카테고리가 없어도 세 키를 모두 포함해야 합니다.", + "functionContract": { + "parameters": [ + { + "name": "items", + "type": "{ category: \"food\" | \"book\" | \"tool\", quantity: integer }[]", + "description": "길이 0 이상 100 이하인 재고 항목 배열입니다." + } + ], + "returns": { + "type": "{ food: integer, book: integer, tool: integer }", + "description": "세 카테고리의 수량 합계를 담은 객체입니다." + }, + "constraints": [ + "quantity는 0 이상 100 이하인 정수입니다.", + "같은 카테고리가 여러 번 나오면 수량을 모두 더합니다.", + "반환 객체는 food, book, tool 키를 항상 포함합니다." + ], + "complexity": { + "time": "O(n)", + "space": "O(1)" + } + }, + "entryPoint": "summarizeInventory", + "starterCode": "function summarizeInventory(items) {\n const totals = { food: 0, book: 0, tool: 0 };\n\n // 각 항목의 카테고리에 수량을 누적하세요.\n\n return totals;\n}\n", + "examples": [ + { + "args": [[{"category": "food", "quantity": 2}, {"category": "tool", "quantity": 4}, {"category": "book", "quantity": 1}, {"category": "tool", "quantity": 3}]], + "expected": {"food": 2, "book": 1, "tool": 7}, + "explanation": "tool 항목 두 개의 수량 4와 3을 더하고 food와 book도 각각 누적합니다." + } + ], + "publicTests": [ + { + "id": "inventory-empty", + "label": "재고 항목이 없는 경우", + "args": [[]], + "expected": {"food": 0, "book": 0, "tool": 0} + }, + { + "id": "inventory-single-category", + "label": "카테고리 하나만 있는 경우", + "args": [[{"category": "book", "quantity": 3}]], + "expected": {"food": 0, "book": 3, "tool": 0} + }, + { + "id": "inventory-duplicate-category", + "label": "같은 카테고리가 반복되는 경우", + "args": [[{"category": "food", "quantity": 2}, {"category": "food", "quantity": 5}]], + "expected": {"food": 7, "book": 0, "tool": 0} + }, + { + "id": "inventory-mixed-duplicates", + "label": "여러 카테고리와 중복 항목", + "args": [[{"category": "food", "quantity": 2}, {"category": "tool", "quantity": 4}, {"category": "book", "quantity": 1}, {"category": "tool", "quantity": 3}]], + "expected": {"food": 2, "book": 1, "tool": 7} + }, + { + "id": "inventory-zero-quantity", + "label": "수량이 0인 항목", + "args": [[{"category": "food", "quantity": 0}, {"category": "book", "quantity": 2}]], + "expected": {"food": 0, "book": 2, "tool": 0} + }, + { + "id": "inventory-maximum-total", + "label": "같은 카테고리의 최대 수량 누적", + "args": [[{"category": "tool", "quantity": 100}, {"category": "tool", "quantity": 100}, {"category": "tool", "quantity": 100}]], + "expected": {"food": 0, "book": 0, "tool": 300} + } + ], + "runTestIds": ["inventory-empty", "inventory-duplicate-category", "inventory-mixed-duplicates"], + "failureExplanations": [ + { + "testId": "inventory-empty", + "message": "항목이 없어도 세 카테고리 키가 0인 초기 객체를 반환해야 합니다." + }, + { + "testId": "inventory-single-category", + "message": "현재 항목의 category를 반환 객체의 키로 사용했는지 확인하세요." + }, + { + "testId": "inventory-duplicate-category", + "message": "이전 합계를 덮어쓰지 말고 현재 quantity를 더해야 합니다." + }, + { + "testId": "inventory-mixed-duplicates", + "message": "각 카테고리가 서로 독립적인 누적값을 유지하는지 확인하세요." + }, + { + "testId": "inventory-zero-quantity", + "message": "0도 유효한 수량이며 다른 카테고리의 누적에 영향을 주지 않아야 합니다." + }, + { + "testId": "inventory-maximum-total", + "message": "같은 카테고리가 여러 번 반복되어도 모든 수량을 빠짐없이 누적해야 합니다." + } + ] + }, + { + "id": "coding-test-javascript-increasing-run", + "slug": "longest-increasing-run", + "revision": 1, + "order": 3, + "lessonId": "js-04-collections", + "conceptIds": ["js.arrays"], + "difficulty": "beginner", + "type": "array", + "tags": ["배열", "연속 구간", "상태"], + "estimatedMinutes": 20, + "title": "가장 긴 연속 증가 구간", + "summary": "배열에서 바로 이전 값보다 엄격히 커지는 가장 긴 연속 구간의 길이를 찾습니다.", + "description": "`longestIncreasingRun(values)` 함수를 작성하세요. 인접한 다음 값이 바로 이전 값보다 클 때만 같은 증가 구간입니다. 같거나 작아지면 새 구간이 시작됩니다. 부분 수열이 아니라 원래 배열에서 서로 붙어 있는 구간을 평가합니다.", + "functionContract": { + "parameters": [ + { + "name": "values", + "type": "integer[]", + "description": "길이 0 이상 100 이하이며 각 값이 -1,000 이상 1,000 이하인 정수 배열입니다." + } + ], + "returns": { + "type": "integer", + "description": "가장 긴 연속 증가 구간의 길이이며 빈 배열이면 0입니다." + }, + "constraints": [ + "증가는 다음 값이 이전 값보다 엄격히 큰 경우입니다.", + "같은 값이 연속되면 증가 구간이 끊깁니다.", + "빈 배열은 0을 반환합니다." + ], + "complexity": { + "time": "O(n)", + "space": "O(1)" + } + }, + "entryPoint": "longestIncreasingRun", + "starterCode": "function longestIncreasingRun(values) {\n if (values.length === 0) return 0;\n\n // 현재 구간 길이와 지금까지의 최댓값을 갱신하세요.\n return 1;\n}\n", + "examples": [ + { + "args": [[1, 4, 2, 3, 5, 0]], + "expected": 3, + "explanation": "연속 구간 [2, 3, 5]의 길이 3이 가장 깁니다." + } + ], + "publicTests": [ + { + "id": "run-empty-array", + "label": "빈 배열", + "args": [[]], + "expected": 0 + }, + { + "id": "run-single-value", + "label": "값이 하나인 배열", + "args": [[7]], + "expected": 1 + }, + { + "id": "run-all-increasing", + "label": "전체가 증가하는 배열", + "args": [[1, 2, 3, 4, 5]], + "expected": 5 + }, + { + "id": "run-reset-and-grow", + "label": "감소 뒤 더 긴 증가 구간", + "args": [[1, 4, 2, 3, 5, 0]], + "expected": 3 + }, + { + "id": "run-equal-breaks", + "label": "같은 값에서 증가가 끊기는 경우", + "args": [[2, 2, 3]], + "expected": 2 + }, + { + "id": "run-all-decreasing", + "label": "전체가 감소하는 배열", + "args": [[5, 4, 3, 2]], + "expected": 1 + } + ], + "runTestIds": ["run-empty-array", "run-reset-and-grow", "run-equal-breaks"], + "failureExplanations": [ + { + "testId": "run-empty-array", + "message": "빈 배열에는 길이 1인 구간도 없으므로 0을 별도로 반환해야 합니다." + }, + { + "testId": "run-single-value", + "message": "값 하나는 그 자체로 길이 1인 연속 증가 구간입니다." + }, + { + "testId": "run-all-increasing", + "message": "증가가 이어질 때 현재 구간 길이와 최댓값을 함께 갱신하세요." + }, + { + "testId": "run-reset-and-grow", + "message": "감소가 나타나면 현재 구간만 1로 초기화하고 이전 최댓값은 유지해야 합니다." + }, + { + "testId": "run-equal-breaks", + "message": "같은 값은 엄격한 증가가 아니므로 새 구간을 시작해야 합니다." + }, + { + "testId": "run-all-decreasing", + "message": "감소 배열의 각 값도 길이 1인 구간이며 최종 결과는 1입니다." + } + ] + }, + { + "id": "coding-test-javascript-product-order", + "slug": "sort-products-by-price", + "revision": 1, + "order": 4, + "lessonId": "js-04-collections", + "conceptIds": ["js.arrays", "js.objects", "js.builtins"], + "difficulty": "intermediate", + "type": "sorting", + "tags": ["정렬", "객체 배열", "다중 기준"], + "estimatedMinutes": 25, + "title": "상품 가격 순서 정하기", + "summary": "상품을 가격 오름차순으로 정렬하고 같은 가격에서는 ID 순서를 적용합니다.", + "description": "`sortProductsByPrice(products)` 함수를 작성하세요. 가격이 낮은 상품부터 ID를 반환하고, 가격이 같으면 ASCII 소문자 ID의 사전순으로 정렬합니다. 결과는 상품 객체가 아니라 ID 문자열 배열이어야 합니다.", + "functionContract": { + "parameters": [ + { + "name": "products", + "type": "{ id: string, price: integer }[]", + "description": "서로 다른 소문자 ID와 0 이상 1,000,000 이하 가격을 가진 상품 배열입니다." + } + ], + "returns": { + "type": "string[]", + "description": "가격과 ID 보조 기준으로 정렬된 상품 ID 배열입니다." + }, + "constraints": [ + "가격은 오름차순으로 정렬합니다.", + "가격이 같으면 ID를 ASCII 사전순으로 정렬합니다.", + "반환값에는 ID 문자열만 담습니다." + ], + "complexity": { + "time": "O(n log n)", + "space": "O(n)" + } + }, + "entryPoint": "sortProductsByPrice", + "starterCode": "function sortProductsByPrice(products) {\n // 가격과 ID를 차례로 비교하는 정렬 기준을 작성하세요.\n return [];\n}\n", + "examples": [ + { + "args": [[{"id": "b", "price": 10}, {"id": "c", "price": 5}, {"id": "a", "price": 10}]], + "expected": ["c", "a", "b"], + "explanation": "c가 가장 저렴하고, 가격이 같은 a와 b는 ID 순서로 배치됩니다." + } + ], + "publicTests": [ + { + "id": "products-empty", + "label": "상품이 없는 경우", + "args": [[]], + "expected": [] + }, + { + "id": "products-single", + "label": "상품이 하나인 경우", + "args": [[{"id": "only", "price": 50}]], + "expected": ["only"] + }, + { + "id": "products-distinct-prices", + "label": "모든 가격이 다른 경우", + "args": [[{"id": "high", "price": 90}, {"id": "low", "price": 10}, {"id": "mid", "price": 40}]], + "expected": ["low", "mid", "high"] + }, + { + "id": "products-tied-price", + "label": "두 상품의 가격이 같은 경우", + "args": [[{"id": "z", "price": 5}, {"id": "a", "price": 5}]], + "expected": ["a", "z"] + }, + { + "id": "products-mixed-ties", + "label": "가격 차이와 동률이 함께 있는 경우", + "args": [[{"id": "b", "price": 10}, {"id": "c", "price": 5}, {"id": "a", "price": 10}]], + "expected": ["c", "a", "b"] + }, + { + "id": "products-price-boundaries", + "label": "최소와 최대 가격", + "args": [[{"id": "max", "price": 1000000}, {"id": "free", "price": 0}, {"id": "one", "price": 1}]], + "expected": ["free", "one", "max"] + } + ], + "runTestIds": ["products-distinct-prices", "products-tied-price", "products-mixed-ties"], + "failureExplanations": [ + { + "testId": "products-empty", + "message": "빈 입력에서는 상품 객체가 아닌 빈 ID 배열을 반환해야 합니다." + }, + { + "testId": "products-single", + "message": "정렬 후 상품 객체에서 id만 추출했는지 확인하세요." + }, + { + "testId": "products-distinct-prices", + "message": "문자열 ID보다 숫자 price를 첫 번째 정렬 기준으로 사용하세요." + }, + { + "testId": "products-tied-price", + "message": "가격 차이가 0일 때 ID를 두 번째 기준으로 비교해야 합니다." + }, + { + "testId": "products-mixed-ties", + "message": "가격 정렬을 유지하면서 같은 가격 묶음 안에서만 ID 순서를 적용하세요." + }, + { + "testId": "products-price-boundaries", + "message": "0과 1,000,000도 일반 가격과 같은 숫자 오름차순으로 비교해야 합니다." + } + ] + }, + { + "id": "coding-test-javascript-first-position", + "slug": "find-first-position", + "revision": 1, + "order": 5, + "lessonId": "js-07-review-practice", + "conceptIds": ["js.data-flow", "js.problem-decomposition"], + "difficulty": "intermediate", + "type": "search", + "tags": ["이진 탐색", "경계", "중복"], + "estimatedMinutes": 30, + "title": "정렬 배열의 첫 위치 찾기", + "summary": "오름차순 배열에서 목표값이 처음 나타나는 인덱스를 이진 탐색으로 찾습니다.", + "description": "`findFirstPosition(sortedValues, target)` 함수를 작성하세요. target이 여러 번 있으면 가장 작은 인덱스를 반환하고, 없으면 -1을 반환합니다. 배열 전체를 차례로 찾는 대신 탐색 범위를 절반씩 줄이세요.", + "functionContract": { + "parameters": [ + { + "name": "sortedValues", + "type": "integer[]", + "description": "오름차순으로 정렬된 길이 0 이상 1,000 이하 정수 배열입니다." + }, + { + "name": "target", + "type": "integer", + "description": "찾을 정수입니다." + } + ], + "returns": { + "type": "integer", + "description": "target의 첫 인덱스 또는 target이 없을 때 -1입니다." + }, + "constraints": [ + "sortedValues는 오름차순으로 정렬되어 있습니다.", + "중복된 target 중 첫 번째 인덱스를 반환합니다.", + "탐색 범위를 절반씩 줄이는 O(log n) 풀이를 목표로 합니다." + ], + "complexity": { + "time": "O(log n)", + "space": "O(1)" + } + }, + "entryPoint": "findFirstPosition", + "starterCode": "function findFirstPosition(sortedValues, target) {\n let left = 0;\n let right = sortedValues.length - 1;\n\n // target을 찾아도 더 왼쪽에 같은 값이 있는지 확인하세요.\n return -1;\n}\n", + "examples": [ + { + "args": [[1, 2, 2, 2, 3], 2], + "expected": 1, + "explanation": "2는 세 번 등장하며 그중 가장 작은 인덱스는 1입니다." + } + ], + "publicTests": [ + { + "id": "search-empty", + "label": "빈 배열", + "args": [[], 3], + "expected": -1 + }, + { + "id": "search-found-middle", + "label": "가운데 이후에서 목표값 발견", + "args": [[1, 3, 5, 7, 9], 7], + "expected": 3 + }, + { + "id": "search-target-absent", + "label": "배열 사이 범위에 목표값이 없음", + "args": [[1, 3, 5, 7], 4], + "expected": -1 + }, + { + "id": "search-first-duplicate", + "label": "중복 목표값의 첫 인덱스", + "args": [[1, 2, 2, 2, 3], 2], + "expected": 1 + }, + { + "id": "search-first-boundary", + "label": "첫 원소가 목표값", + "args": [[-5, -1, 0, 4], -5], + "expected": 0 + }, + { + "id": "search-last-boundary", + "label": "마지막 원소가 목표값", + "args": [[-2, 0, 3, 6, 9], 9], + "expected": 4 + } + ], + "runTestIds": ["search-found-middle", "search-target-absent", "search-first-duplicate"], + "failureExplanations": [ + { + "testId": "search-empty", + "message": "초기 오른쪽 경계가 -1이면 반복하지 않고 -1을 반환해야 합니다." + }, + { + "testId": "search-found-middle", + "message": "중간값과 target 비교 결과에 따라 왼쪽 또는 오른쪽 경계를 올바르게 옮기세요." + }, + { + "testId": "search-target-absent", + "message": "탐색 범위가 비었을 때 저장된 정답이 없다면 -1을 반환해야 합니다." + }, + { + "testId": "search-first-duplicate", + "message": "target을 찾은 즉시 끝내지 말고 위치를 저장한 뒤 더 왼쪽 범위를 탐색하세요." + }, + { + "testId": "search-first-boundary", + "message": "왼쪽 경계 0도 탐색 범위에 포함되는지 확인하세요." + }, + { + "testId": "search-last-boundary", + "message": "오른쪽 경계를 배열 길이 - 1로 시작하고 마지막 인덱스까지 포함하세요." + } + ] + }, + { + "id": "coding-test-javascript-grid-robot", + "slug": "simulate-grid-robot", + "revision": 2, + "order": 6, + "lessonId": "js-07-review-practice", + "conceptIds": ["js.data-flow", "js.problem-decomposition", "js.debugging"], + "difficulty": "advanced", + "type": "simulation", + "tags": ["시뮬레이션", "좌표", "상태 전이"], + "estimatedMinutes": 40, + "title": "장애물을 피하는 격자 로봇", + "summary": "방향과 좌표 상태를 갱신하며 격자 경계와 장애물을 함께 처리합니다.", + "description": "`simulateGridRobot(commands, obstacles)` 함수를 작성하세요. 로봇은 5×5 격자의 (0, 0)에서 북쪽 N을 보고 시작합니다. L과 R은 제자리 회전, F는 현재 방향으로 한 칸 이동입니다. 격자 밖이거나 장애물 좌표인 칸으로는 이동하지 않고 다음 명령을 처리합니다.", + "functionContract": { + "parameters": [ + { + "name": "commands", + "type": "(\"L\" | \"R\" | \"F\")[]", + "description": "길이 0 이상 100 이하인 명령 배열입니다." + }, + { + "name": "obstacles", + "type": "[integer, integer][]", + "description": "서로 다른 장애물의 [x, y] 좌표 배열이며 시작 좌표는 포함하지 않습니다." + } + ], + "returns": { + "type": "{ x: integer, y: integer, direction: \"N\" | \"E\" | \"S\" | \"W\" }", + "description": "모든 명령 처리 후 로봇의 좌표와 방향입니다." + }, + "constraints": [ + "x와 y는 항상 0 이상 4 이하입니다.", + "북쪽 이동은 y를 1 늘리고 동쪽 이동은 x를 1 늘립니다.", + "남쪽 이동은 y를 1 줄이고 서쪽 이동은 x를 1 줄입니다.", + "막힌 F 명령은 위치만 유지하며 방향과 다음 명령 처리에는 영향을 주지 않습니다." + ], + "complexity": { + "time": "O(c + o)", + "space": "O(o)" + } + }, + "entryPoint": "simulateGridRobot", + "starterCode": "function simulateGridRobot(commands, obstacles) {\n let x = 0;\n let y = 0;\n let direction = \"N\";\n\n // 회전과 이동을 명령 순서대로 처리하세요.\n\n return { x, y, direction };\n}\n", + "examples": [ + { + "args": [["F", "R", "F"], [[0, 1]]], + "expected": {"x": 1, "y": 0, "direction": "E"}, + "explanation": "첫 F는 북쪽 장애물에 막히고, 오른쪽으로 회전한 뒤 동쪽으로 한 칸 이동합니다." + } + ], + "publicTests": [ + { + "id": "robot-no-commands", + "label": "명령이 없는 경우", + "args": [[], []], + "expected": {"x": 0, "y": 0, "direction": "N"} + }, + { + "id": "robot-north-boundary", + "label": "북쪽 경계에서 추가 이동", + "args": [["F", "F", "F", "F", "F", "F"], []], + "expected": {"x": 0, "y": 4, "direction": "N"} + }, + { + "id": "robot-turn-east", + "label": "동쪽으로 회전한 뒤 이동", + "args": [["R", "F", "F"], []], + "expected": {"x": 2, "y": 0, "direction": "E"} + }, + { + "id": "robot-blocked-at-start", + "label": "시작점 바로 앞의 장애물", + "args": [["F", "R", "F"], [[0, 1]]], + "expected": {"x": 1, "y": 0, "direction": "E"} + }, + { + "id": "robot-full-rotation", + "label": "오른쪽으로 한 바퀴 회전", + "args": [["R", "R", "R", "R"], []], + "expected": {"x": 0, "y": 0, "direction": "N"} + }, + { + "id": "robot-obstacle-after-move", + "label": "이동 중 만나는 장애물", + "args": [["F", "F", "R", "F", "F", "L", "F"], [[1, 2]]], + "expected": {"x": 0, "y": 3, "direction": "N"} + }, + { + "id": "robot-move-south", + "label": "남쪽으로 되돌아 이동", + "args": [["F", "F", "R", "R", "F"], []], + "expected": {"x": 0, "y": 1, "direction": "S"} + }, + { + "id": "robot-move-west", + "label": "서쪽으로 되돌아 이동", + "args": [["R", "F", "F", "R", "R", "F"], []], + "expected": {"x": 1, "y": 0, "direction": "W"} + } + ], + "runTestIds": ["robot-north-boundary", "robot-blocked-at-start", "robot-obstacle-after-move"], + "failureExplanations": [ + { + "testId": "robot-no-commands", + "message": "명령이 없으면 시작 좌표와 북쪽 방향을 그대로 반환해야 합니다." + }, + { + "testId": "robot-north-boundary", + "message": "다음 좌표가 0~4 범위 안인지 확인한 뒤에만 위치를 갱신하세요." + }, + { + "testId": "robot-turn-east", + "message": "R 회전 뒤 동쪽 이동은 x를 늘리고 y는 유지해야 합니다." + }, + { + "testId": "robot-blocked-at-start", + "message": "장애물에 막힌 이동만 건너뛰고 이어지는 회전과 이동 명령은 계속 처리해야 합니다." + }, + { + "testId": "robot-full-rotation", + "message": "방향 인덱스가 배열 끝을 넘으면 다시 북쪽으로 순환해야 합니다." + }, + { + "testId": "robot-obstacle-after-move", + "message": "현재 좌표가 아니라 이동하려는 다음 좌표가 장애물인지 검사하세요." + }, + { + "testId": "robot-move-south", + "message": "남쪽을 볼 때는 x를 유지하고 y를 1 줄여야 합니다." + }, + { + "testId": "robot-move-west", + "message": "서쪽을 볼 때는 y를 유지하고 x를 1 줄여야 합니다." + } + ] + } + ] +} diff --git a/content/schema/coding-test.schema.json b/content/schema/coding-test.schema.json new file mode 100644 index 0000000..05e4bca --- /dev/null +++ b/content/schema/coding-test.schema.json @@ -0,0 +1,195 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bam.dev/schema/coding-test.schema.json", + "title": "BAM.dev coding test collection", + "type": "object", + "required": ["schemaVersion", "contractVersion", "languageId", "title", "problems"], + "properties": { + "schemaVersion": { "const": 1 }, + "contractVersion": { "const": 1 }, + "languageId": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" + }, + "title": { "type": "string", "minLength": 1 }, + "problems": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/problem" } + } + }, + "additionalProperties": false, + "$defs": { + "stableId": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "conceptId": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$" + }, + "problem": { + "type": "object", + "required": [ + "id", + "slug", + "revision", + "order", + "lessonId", + "conceptIds", + "difficulty", + "type", + "tags", + "estimatedMinutes", + "title", + "summary", + "description", + "functionContract", + "entryPoint", + "starterCode", + "examples", + "publicTests", + "runTestIds", + "failureExplanations" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^coding-test-[a-z][a-z0-9]*(?:-[a-z0-9]+)+$" + }, + "slug": { "$ref": "#/$defs/stableId" }, + "revision": { "type": "integer", "minimum": 1 }, + "order": { "type": "integer", "minimum": 1 }, + "lessonId": { "$ref": "#/$defs/stableId" }, + "conceptIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/$defs/conceptId" } + }, + "difficulty": { "enum": ["beginner", "intermediate", "advanced"] }, + "type": { "enum": ["string", "array", "object", "sorting", "search", "simulation"] }, + "tags": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "estimatedMinutes": { "type": "integer", "minimum": 1 }, + "title": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "functionContract": { "$ref": "#/$defs/functionContract" }, + "entryPoint": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "starterCode": { "type": "string", "minLength": 1 }, + "examples": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { "$ref": "#/$defs/example" } + }, + "publicTests": { + "type": "array", + "minItems": 4, + "maxItems": 12, + "items": { "$ref": "#/$defs/publicTest" } + }, + "runTestIds": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "uniqueItems": true, + "items": { "$ref": "#/$defs/stableId" } + }, + "failureExplanations": { + "type": "array", + "minItems": 4, + "maxItems": 12, + "items": { "$ref": "#/$defs/failureExplanation" } + } + }, + "additionalProperties": false + }, + "functionContract": { + "type": "object", + "required": ["parameters", "returns", "constraints", "complexity"], + "properties": { + "parameters": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/parameter" } + }, + "returns": { "$ref": "#/$defs/returnValue" }, + "constraints": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "complexity": { "$ref": "#/$defs/complexity" } + }, + "additionalProperties": false + }, + "parameter": { + "type": "object", + "required": ["name", "type", "description"], + "properties": { + "name": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, + "type": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "returnValue": { + "type": "object", + "required": ["type", "description"], + "properties": { + "type": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "complexity": { + "type": "object", + "required": ["time", "space"], + "properties": { + "time": { "type": "string", "minLength": 1 }, + "space": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "example": { + "type": "object", + "required": ["args", "expected", "explanation"], + "properties": { + "args": { "type": "array", "items": {} }, + "expected": {}, + "explanation": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "publicTest": { + "type": "object", + "required": ["id", "label", "args", "expected"], + "properties": { + "id": { "$ref": "#/$defs/stableId" }, + "label": { "type": "string", "minLength": 1 }, + "args": { "type": "array", "items": {} }, + "expected": {} + }, + "additionalProperties": false + }, + "failureExplanation": { + "type": "object", + "required": ["testId", "message"], + "properties": { + "testId": { "$ref": "#/$defs/stableId" }, + "message": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 7d425af..9fc82b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,28 +7,30 @@ curriculum.json + Markdown ──► 학습 화면 ───────── │ │ ├── quiz JSON ──► 검증·채점 ──► 객관식 화면 ├──► ProgressRepository ──► localStorage │ │ - └── quest JSON ─► 계약 검증 ──► Quest 화면 ─┘ - │ - └──► 테스트별 one-shot Worker ──► 공개 테스트 결과 + ├── quest JSON ─► 계약 검증 ──► Quest 화면 ──┤ + │ │ + └── coding-test JSON ─► 목록·풀이·제출 화면 ─┘ + │ + └──► 테스트별 one-shot Worker ──► 공개 테스트 결과 ``` -콘텐츠는 정적 읽기 전용 데이터이고, 진도는 사용자별 변경 데이터입니다. 객관식과 Quest를 안정적인 `lesson.id`·`conceptId`로 연결하고 Quest 자체는 `quest.id`와 `revision`으로 식별하여, 콘텐츠 수정이 사용자 상태 형식을 불필요하게 바꾸지 않도록 합니다. +콘텐츠는 정적 읽기 전용 데이터이고, 진도는 사용자별 변경 데이터입니다. 객관식·Quest·코딩테스트를 안정적인 `lesson.id`·`conceptId`로 연결하고 실행 문제는 ID와 `revision`으로 식별하여, 콘텐츠 수정이 사용자 상태 형식을 불필요하게 바꾸지 않도록 합니다. ## 브라우저 앱 -- 해시 라우팅: 정적 서버에서도 새로고침 경로 문제 없이 학습은 `#/learn//`, 복습은 `#/review/`, Quest는 `#/quest//`를 사용합니다. 학습과 복습은 공통 언어 라우팅을 사용하고, 현재 JavaScript에만 있는 Quest는 JavaScript 경로만 제공합니다. -- 콘텐츠 로딩: `curriculum.json` 검증 후 선택한 Markdown과 언어별 객관식·Quest JSON을 각 계약으로 검증합니다. 교안의 실행 예제 데이터는 `content/fixtures//`에 두고 same-origin으로 불러오며 `content/` 전체와 함께 정적 빌드에 포함합니다. +- 해시 라우팅: 학습은 `#/learn//`, 복습은 `#/review/`, Quest는 `#/quest//`, 코딩테스트 목록·문제는 `#/coding-tests`와 `#/coding-tests//`을 사용합니다. +- 콘텐츠 로딩: `curriculum.json` 검증 후 선택한 Markdown과 언어별 객관식·Quest·코딩테스트 JSON을 각 계약으로 검증합니다. 교안의 실행 예제 데이터는 `content/fixtures//`에 두고 same-origin으로 불러오며 `content/` 전체와 함께 정적 빌드에 포함합니다. - Markdown: 프로젝트가 신뢰하는 제한된 문법만 HTML로 변환하며 원시 HTML은 항상 이스케이프합니다. - 객관식: 콘텐츠 검증, 한 문제 채점, 전체 요약을 순수 도메인 함수로 분리합니다. 선택 전에는 정답 정보를 화면에 렌더링하지 않고 채점 후 네 선택지의 근거를 모두 표시합니다. - Quest: 문제 계약, 시작 코드, 예시, 공개 테스트와 실패 설명을 콘텐츠로 관리합니다. 테스트마다 새 module Worker를 만들고 문법·런타임·시간·출력 제한·취소를 서로 다른 결과로 표시합니다. 브라우저에 포함된 테스트는 모두 공개 테스트입니다. -- 진도: `ProgressRepository` 계약과 `LocalStorageProgressRepository` 구현을 분리합니다. 학습 완료, 객관식 시도·오답 ID, Quest 초안·실행·완료 ID를 같은 버전 데이터 안의 독립 배열로 관리합니다. Quest 실행 기록에는 사용자 소스를 저장하지 않습니다. +- 코딩테스트: 목록 검색과 난이도·언어·유형·풀이 상태 필터를 순수 도메인 함수로 분리합니다. 빠른 실행은 공개 테스트 일부, 제출은 전부를 사용하며 `CodingTestRunnerAdapter`가 기존 Worker DTO에 투영한 뒤 문제 ID와 실행 모드로 결과를 복원합니다. +- 진도: `ProgressRepository` 계약과 `LocalStorageProgressRepository` 구현을 분리합니다. 학습 완료, 객관식 시도·오답 ID, Quest 상태와 코딩테스트 초안·제출·리비전별 완료를 같은 버전 데이터 안의 독립 배열로 관리합니다. 실행·제출 기록에는 사용자 소스를 저장하지 않습니다. - 저장 장애: 브라우저 저장소 접근이 막히면 메모리 저장소로 전환하며 저장소 계약이 영속 여부를 화면에 제공합니다. - 언어 전환: 사이드바의 공통 언어 내비게이션은 `available`과 `sample` 언어를 첫 교안으로 연결하고, `planned` 언어는 비활성 상태로 표시합니다. -- 반응형: 데스크톱은 208px 사이드바와 Quest 설명·편집기 2열을 사용합니다. 모바일은 상단 메뉴, 오버레이 내비게이션과 Quest 1열 흐름을 사용합니다. +- 반응형: 데스크톱은 208px 사이드바와 Quest 및 코딩테스트 분할 화면을 사용합니다. 모바일은 상단 메뉴, 오버레이 내비게이션과 문제→편집기→결과 1열 흐름을 사용합니다. ## 향후 확장 -- 코딩테스트는 Quest와 마찬가지로 별도 콘텐츠 컬렉션을 사용하고 `lessonId`와 `conceptId`로 교안에 연결합니다. - HTML, CSS, Java 샘플 교안과 객관식은 JavaScript와 같은 언어·교안·평가 스키마로 검증됩니다. 정식 콘텐츠 확장은 이후 단계에서 진행합니다. - Supabase 도입 시 원격 저장소 구현과 로컬→원격 마이그레이션 계층만 추가합니다. - Java 코드는 브라우저에서 실행하지 않고 격리된 채점 서비스로 전달합니다. diff --git a/docs/content-schema.md b/docs/content-schema.md index 9ece537..5c2c4cb 100644 --- a/docs/content-schema.md +++ b/docs/content-schema.md @@ -77,3 +77,23 @@ | `commonMistakes` | 정답을 직접 노출하지 않는 대표 오개념 설명 | 각 예시와 공개 테스트의 `args` 개수는 함수 매개변수 개수와 같아야 합니다. JSON 입출력은 경로당 컨테이너 512단계와 테스트별 16 KiB 제한을 지키며, 상세 산정 방식은 [Code Quest 작성 가이드](code-quest-authoring.md#문제-계약)를 따릅니다. Quest 순서, ID·slug·공개 테스트 ID의 전역 고유성, 교안·개념 참조, `failureExplanations`의 1:1 대응과 힌트 단계 순서는 `npm run validate:content` 및 전용 콘텐츠 테스트로 검증합니다. 공개 테스트는 사용자가 브라우저 개발자 도구로 확인할 수 있으므로 비밀 테스트라고 표현하지 않습니다. + +## 코딩테스트 컬렉션 + +`content/coding-tests/.json`은 목록 검색·필터와 제출 채점에 사용하는 문제를 정의합니다. `content/schema/coding-test.schema.json`과 런타임 검증을 함께 적용합니다. + +| 필드 | 의미 | +| --- | --- | +| `problem.id` | `coding-test--...` 형식의 안정적인 전역 ID | +| `slug`, `revision`, `order` | URL, 문제 계약 버전, 언어 내 연속 순서 | +| `lessonId`, `conceptIds` | 같은 언어의 근거 교안과 개념 | +| `difficulty` | `beginner`, `intermediate`, `advanced` | +| `type`, `tags` | 유형 필터 값과 검색용 주제 | +| `description`, `functionContract` | 문제 설명, 매개변수·반환·제한·목표 복잡도 | +| `entryPoint`, `starterCode` | 호출할 함수 이름과 초기 코드 | +| `examples` | 공개 테스트와 실제 입출력이 일치하는 예제 | +| `publicTests` | 제출 시 모두 실행하는 브라우저 포함 공개 테스트 | +| `runTestIds` | 빠른 실행에 쓰는 `publicTests`의 진부분집합 | +| `failureExplanations` | 각 공개 테스트에 정확히 하나씩 대응하는 확인 지점 | + +`테스트 실행`은 `runTestIds`가 가리키는 사례만, `제출 및 채점`은 `publicTests` 전체를 실행합니다. 둘 다 같은 브라우저 공개 데이터이며 비밀·숨김 테스트가 아닙니다. 기준 풀이, 공개 테스트와 중복되지 않는 독립 사례, 대표 오답은 `tests/coding-test-content.test.js`에서 실제 JavaScript 런타임으로 검증합니다. diff --git a/docs/problem-verification.md b/docs/problem-verification.md new file mode 100644 index 0000000..0399c01 --- /dev/null +++ b/docs/problem-verification.md @@ -0,0 +1,30 @@ +# 문제 검증 기록 + +검증일: 2026-08-18 + +## 결론 + +현재 제공되는 객관식 17문항, JavaScript Code Quest 5문제, JavaScript 코딩테스트 6문제의 정답과 기대값을 전수 확인했습니다. 스키마 통과만으로 검증 완료를 판단하지 않고 정답 근거, 개별 오답 설명, 기준 풀이 실행, 공개 사례와 겹치지 않는 추가 사례를 함께 확인했습니다. + +| 문제군 | 전수 결과 | 독립 검증 근거 | +| --- | --- | --- | +| 객관식 17문항 | 정답키 17/17 일치, 선택지 68/68에 판정과 개별 이유 | 콘텐츠 담당의 독립 정답표 대조 + 스키마·교안·개념·중복 자동 검증 | +| Code Quest 5문제 | 기준 풀이·기대값 일치, 대표 오답 7개가 의도한 사례에서 실패 | 선언 사례 47개와 별도 생성 사례 16,114개 실행, 공개/추가 사례 교차 중복 회귀 검사 | +| 코딩테스트 6문제 | 공개 테스트 38개와 독립 사례 7개 기준 풀이 통과 | 문제별 대표 오답 실행, starter code가 완성 답안이 아님을 검사 | + +## 감사에서 발견하고 수정한 항목 + +- 배송비 Code Quest의 추가 검증 한 건이 공개 테스트와 입력·기대값까지 같았습니다. 입력을 독립 사례로 교체하고, 앞으로 모든 Quest에서 공개 테스트와 추가 검증 사이의 ID 및 `args + expected` 중복을 자동 거부하도록 회귀 테스트를 추가했습니다. +- 격자 로봇 코딩테스트의 남쪽·서쪽 이동 벡터가 기존 사례에서 실행되지 않았습니다. 문제 리비전을 2로 올리고 두 방향 공개 테스트, 별도 결합 검증 사례와 해당 벡터를 뒤집은 대표 오답을 추가했습니다. +- Java 객관식은 교안의 오류 예시와 수정 방향을 그대로 활용하므로 새 문제로서의 자극은 낮습니다. 정답과 모든 오답 설명은 정확하며, 이는 정확성 결함이 아닌 향후 콘텐츠 다양화 항목으로 남깁니다. + +## 재현 명령 + +```bash +npm run validate:content +node --test tests/quiz-content.test.js tests/extension-content.test.js +node --test tests/code-quest-content.test.js tests/coding-test-content.test.js +npm run check +``` + +브라우저에 포함된 실행·제출 테스트는 모두 공개 테스트입니다. 화면과 문서 어디에서도 이를 비밀 또는 숨김 테스트라고 표현하지 않습니다. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3b38e9b..41ddfec 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,11 +7,11 @@ | 2차 객관식 복습 | 완료 | 14문항, 즉시 채점, 정답·모든 오답 설명, 결과·오답 저장, 독립 검증 | | 3차 JavaScript Code Quest | 완료 | 5개 Quest, 코드 편집·초안 복원, 공개 테스트 실행, 오류·실패 설명, 단계별 힌트, 완료 저장 | | 확장 검증 게이트 | 완료 | HTML·CSS·Java 교안·객관식 샘플 각 1개, 언어 전환, 공통 스키마·라우팅 검증 | -| 4차 JavaScript 코딩테스트 | 예정 | 목록/필터/검색, 분할 풀이, 실행·제출 | +| 4차 JavaScript 코딩테스트 | 완료 | 6문제, 검색·4종 필터, 반응형 분할 풀이, 공개 테스트 실행·제출, 초안·풀이 저장 | | 5차 HTML·CSS 정식 확장 | 예정 | 학습, 객관식, Quest | | 6차 Web Project | 예정 | 제출, 자동/수동 평가 분리, 부분 점수 | | 7차 Java 정식 확장 | 예정 | 격리 채점기 포함 전체 학습 흐름 | | 8차 Supabase·마이페이지 | 예정 | 인증, RLS, 진도 마이그레이션, 기록 | | 9차 학습 튜터 | 예정 | 교안·코드 근거 응답과 단계별 힌트 | -0~3차는 `codex/js-foundation`에서 통합 검증한 뒤 `dev`에 병합했습니다. 확장 검증 게이트에서는 HTML·CSS·Java 샘플을 같은 콘텐츠·객관식 계약과 언어 라우팅으로 제공할 수 있음을 확인했습니다. 다음 구현은 별도 `codex/` 브랜치에서 진행하는 4차 JavaScript 코딩테스트입니다. +0~3차는 `codex/js-foundation`에서 통합 검증한 뒤 `dev`에 병합했습니다. 확장 검증 게이트에서는 HTML·CSS·Java 샘플을 같은 콘텐츠·객관식 계약과 언어 라우팅으로 제공할 수 있음을 확인했습니다. 4차는 `codex/javascript-coding-test`에서 별도 콘텐츠·진도 계약으로 구현했으며, 다음 단계는 HTML·CSS 정식 확장입니다. diff --git a/package.json b/package.json index 9998c85..039feed 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "node scripts/dev-server.mjs", "build": "node scripts/build.mjs", - "test": "node --test", + "test": "node scripts/run-tests.mjs", "validate:content": "node scripts/validate-content.mjs", "check": "npm run validate:content && npm test && npm run build" }, diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..bdf2810 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,52 @@ +import { spawn } from "node:child_process"; +import { readdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +async function collectTestFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name, "en")); + + const files = []; + for (const entry of entries) { + const entryPath = resolve(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectTestFiles(entryPath))); + } else if (entry.isFile() && entry.name.endsWith(".test.js")) { + files.push(entryPath); + } + } + return files; +} + +export async function findTestFiles(directory) { + return collectTestFiles(resolve(directory)); +} + +export function runTestFiles(testFiles, cwd) { + return new Promise((resolveExitCode, reject) => { + const child = spawn(process.execPath, ["--test", ...testFiles], { + cwd, + shell: false, + stdio: "inherit", + }); + child.once("error", reject); + child.once("exit", (code) => resolveExitCode(code ?? 1)); + }); +} + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const isDirectRun = + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href; + +if (isDirectRun) { + try { + const testFiles = await findTestFiles(resolve(projectRoot, "tests")); + if (testFiles.length === 0) throw new Error("실행할 테스트 파일이 없습니다."); + process.exitCode = await runTestFiles(testFiles, projectRoot); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/validate-content.mjs b/scripts/validate-content.mjs index 8fc3196..47afe7d 100644 --- a/scripts/validate-content.mjs +++ b/scripts/validate-content.mjs @@ -3,6 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { assertValidCurriculum } from "../src/core/content.js"; import { assertValidQuizCollection } from "../src/core/quiz.js"; +import { assertValidCodingTestCollection } from "../src/core/coding-test.js"; import { areJsonValuesEqual, assertValidExecutionRequest, @@ -500,12 +501,103 @@ for (const language of curriculum.languages.filter((item) => item.status === "av } } +const codingTestSchemaPath = path.join( + projectRoot, + "content", + "schema", + "coding-test.schema.json", +); +let codingTestSchema; +try { + codingTestSchema = JSON.parse(await readFile(codingTestSchemaPath, "utf8")); +} catch { + contentErrors.push("코딩테스트 스키마 파일을 읽을 수 없습니다."); +} + +const codingTestDirectory = path.join(projectRoot, "content", "coding-tests"); +const codingTestCollections = new Map(); +const codingTestProblemIds = new Set(); +const codingTestSlugs = new Set(); +const codingTestPublicTestIds = new Set(); +let codingTestFileNames = []; +try { + codingTestFileNames = (await readdir(codingTestDirectory)) + .filter((fileName) => fileName.endsWith(".json")) + .sort(); +} catch { + contentErrors.push("코딩테스트 콘텐츠 디렉터리를 읽을 수 없습니다."); +} + +for (const fileName of codingTestFileNames) { + const languageId = fileName.slice(0, -".json".length); + const codingTestPath = path.join(codingTestDirectory, fileName); + try { + if (!STABLE_ID_PATTERN.test(languageId)) { + throw new Error(`파일명 언어 ID 형식이 올바르지 않습니다: ${languageId}`); + } + const document = JSON.parse(await readFile(codingTestPath, "utf8")); + if (codingTestSchema) { + const schemaErrors = []; + validateSchemaValue( + document, + codingTestSchema, + codingTestSchema, + "$", + schemaErrors, + ); + if (schemaErrors.length > 0) { + throw new Error(`JSON Schema 불일치:\n- ${schemaErrors.join("\n- ")}`); + } + } + const collection = assertValidCodingTestCollection(document, curriculum); + if (collection.languageId !== languageId) { + throw new Error( + `파일명 언어 ${languageId}와 languageId ${collection.languageId}가 다릅니다.`, + ); + } + if (!curriculum.languages.some((language) => language.id === languageId)) { + throw new Error(`커리큘럼에 없는 언어입니다: ${languageId}`); + } + + for (const problem of collection.problems) { + if (codingTestProblemIds.has(problem.id)) { + throw new Error(`다른 컬렉션과 코딩테스트 문제 ID가 중복됩니다: ${problem.id}`); + } + if (codingTestSlugs.has(problem.slug)) { + throw new Error(`다른 컬렉션과 코딩테스트 slug가 중복됩니다: ${problem.slug}`); + } + codingTestProblemIds.add(problem.id); + codingTestSlugs.add(problem.slug); + for (const publicTest of problem.publicTests) { + if (codingTestPublicTestIds.has(publicTest.id)) { + throw new Error( + `다른 코딩테스트 문제와 공개 테스트 ID가 중복됩니다: ${publicTest.id}`, + ); + } + codingTestPublicTestIds.add(publicTest.id); + } + } + codingTestCollections.set(languageId, collection); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + contentErrors.push(`${languageId}: 코딩테스트 콘텐츠 검증 실패 (${message})`); + } +} + +if (!codingTestCollections.has("javascript")) { + contentErrors.push("javascript: 4차 코딩테스트 콘텐츠가 없습니다."); +} + if (contentErrors.length > 0) { throw new Error(`콘텐츠 파일 검증 실패:\n- ${contentErrors.join("\n- ")}`); } const availableLanguages = curriculum.languages.filter((language) => language.status === "available"); const sampleLanguages = curriculum.languages.filter((language) => language.status === "sample"); +const codingTestProblemCount = [...codingTestCollections.values()].reduce( + (total, collection) => total + collection.problems.length, + 0, +); console.log( - `콘텐츠 검증 완료: 정식 언어 ${availableLanguages.length}개, 샘플 언어 ${sampleLanguages.length}개, 교안 ${curriculum.lessons.length}개, 객관식 ${[...quizCollections.values()].reduce((total, collection) => total + collection.questions.length, 0)}문항, Code Quest ${[...questCollections.values()].reduce((total, collection) => total + collection.quests.length, 0)}개`, + `콘텐츠 검증 완료: 정식 언어 ${availableLanguages.length}개, 샘플 언어 ${sampleLanguages.length}개, 교안 ${curriculum.lessons.length}개, 객관식 ${[...quizCollections.values()].reduce((total, collection) => total + collection.questions.length, 0)}문항, Code Quest ${[...questCollections.values()].reduce((total, collection) => total + collection.quests.length, 0)}개, 코딩테스트 ${codingTestProblemCount}개`, ); diff --git a/src/app.js b/src/app.js index 5a17ba5..4b28ae3 100644 --- a/src/app.js +++ b/src/app.js @@ -6,14 +6,23 @@ import { } from "./core/content.js"; import { DEFAULT_LANGUAGE_ID, + buildCodingTestHash, + buildCodingTestListHash, buildLessonHash, buildQuestHash, buildReviewHash, getAdjacentLessons, + parseCodingTestHash, parseQuestHash, parseReviewHash, resolveLessonRoute, } from "./core/navigation.js"; +import { + filterCodingTestProblems, + findCodingTestProblemBySlug, + getCodingTestProblemsInOrder, + loadCodingTestCollection, +} from "./core/coding-test.js"; import { createCodeQuestExecutionRequest, findCodeQuestBySlug, @@ -23,6 +32,7 @@ import { } from "./core/code-quest.js"; import { gradeQuestion, loadQuizCollection, summarizeQuiz } from "./core/quiz.js"; import { BrowserCodeQuestRunner } from "./grading/browser-code-quest-runner.js"; +import { CodingTestRunnerAdapter } from "./grading/coding-test-runner-adapter.js"; import { createBrowserStorage, LocalStorageProgressRepository, @@ -43,8 +53,37 @@ import { renderCodeQuestNavigationLink, renderCodeQuestView, } from "./ui/code-quest-view.js"; +import { + getCodingTestDraftStatusMessage, + renderCodingTestListView, + renderCodingTestLoadingView, + renderCodingTestNavigationLink, + renderCodingTestView, +} from "./ui/coding-test-view.js"; const QUEST_DRAFT_SAVE_DEBOUNCE_MS = 250; +const CODING_TEST_SEARCH_DEBOUNCE_MS = 250; + +function createDefaultCodingTestFilters() { + return { + query: "", + difficulty: "all", + language: "all", + type: "all", + status: "all", + }; +} + +export async function loadCodingTestCollectionSafely( + curriculum, + loader = loadCodingTestCollection, +) { + try { + return await loader(DEFAULT_LANGUAGE_ID, curriculum); + } catch { + return null; + } +} export class BamLearningApp { constructor(root) { @@ -60,6 +99,16 @@ export class BamLearningApp { this.codeQuestCollection = null; this.codeQuestState = null; this.codeQuestRunner = new BrowserCodeQuestRunner(); + this.codingTestCollection = null; + this.codingTestFilters = createDefaultCodingTestFilters(); + this.codingTestState = null; + this.codingTestRunner = new CodingTestRunnerAdapter(this.codeQuestRunner); + this.activeCodingTestExecution = null; + this.pendingCodingTestSearchRender = null; + this.codingTestSearchRenderTimer = null; + this.pendingCodingTestDraftSave = null; + this.codingTestDraftSaveTimer = null; + this.codingTestRequestSequence = 0; this.activeQuestExecution = null; this.pendingQuestDraftSave = null; this.questDraftSaveTimer = null; @@ -79,6 +128,9 @@ export class BamLearningApp { DEFAULT_LANGUAGE_ID, this.curriculum, ); + this.codingTestCollection = await loadCodingTestCollectionSafely( + this.curriculum, + ); await this.openRoute({ useLastLesson: true }); } catch (error) { this.renderFatalError(error); @@ -94,7 +146,11 @@ export class BamLearningApp { }); window.addEventListener("hashchange", () => this.openRoute()); - window.addEventListener("pagehide", () => this.flushPendingQuestDraftSave()); + window.addEventListener("pagehide", () => { + this.cancelPendingCodingTestSearchRender(); + this.flushPendingQuestDraftSave(); + this.flushPendingCodingTestDraftSave(); + }); window.addEventListener("storage", (event) => { if (event.key !== PROGRESS_STORAGE_KEY) return; if (this.currentLesson) this.renderLesson(); @@ -103,6 +159,10 @@ export class BamLearningApp { this.renderQuiz(); } else if (this.currentView === "quest" && this.codeQuestState) { this.renderCodeQuest(); + } else if (this.currentView === "coding-test-list" && this.codingTestCollection) { + this.renderCodingTestList(); + } else if (this.currentView === "coding-test" && this.codingTestState) { + this.renderCodingTest(); } }); this.mobileMedia.addEventListener("change", () => { @@ -129,9 +189,30 @@ export class BamLearningApp { } async openRoute({ useLastLesson = false } = {}) { + this.cancelPendingCodingTestSearchRender(); this.flushPendingQuestDraftSave(); + this.flushPendingCodingTestDraftSave(); if (!this.curriculum) return; this.abortQuestExecutionForNavigation(); + this.abortCodingTestExecutionForNavigation(); + + const codingTestRoute = parseCodingTestHash(window.location.hash); + if (codingTestRoute?.kind === "list") { + this.openCodingTestListRoute(); + return; + } + if (codingTestRoute?.kind === "problem") { + await this.openCodingTestRoute( + codingTestRoute.languageId, + codingTestRoute.slug, + ); + return; + } + if (/^#\/coding-tests(?:\/|$)/u.test(String(window.location.hash))) { + window.history.replaceState(null, "", buildCodingTestListHash()); + this.openCodingTestListRoute(); + return; + } const questRoute = parseQuestHash(window.location.hash); if (questRoute) { @@ -218,6 +299,7 @@ export class BamLearningApp { this.currentView = "lesson"; this.codeQuestState = null; + this.codingTestState = null; this.currentLesson = null; this.currentMarkdown = ""; const canonicalHash = buildLessonHash(lesson.languageId, lesson.slug); @@ -266,6 +348,7 @@ export class BamLearningApp { this.quizCollection = null; this.quizSession = null; this.codeQuestState = null; + this.codingTestState = null; this.menuOpen = false; this.syncMenuState(); @@ -309,6 +392,7 @@ export class BamLearningApp { this.quizCollection = null; this.quizSession = null; this.codeQuestState = null; + this.codingTestState = null; this.menuOpen = false; this.syncMenuState(); this.root.innerHTML = renderCodeQuestLoadingView({ languageName: language.name }); @@ -366,7 +450,126 @@ export class BamLearningApp { } } + openCodingTestListRoute() { + ++this.renderSequence; + if (!this.codingTestCollection) { + this.renderFatalError(new Error("등록된 코딩테스트가 없습니다.")); + return; + } + + this.currentView = "coding-test-list"; + this.currentLesson = null; + this.currentMarkdown = ""; + this.quizCollection = null; + this.quizSession = null; + this.codeQuestState = null; + this.codingTestState = null; + this.menuOpen = false; + this.syncMenuState(); + + const canonicalHash = buildCodingTestListHash(); + if (window.location.hash !== canonicalHash) { + window.history.replaceState(null, "", canonicalHash); + } + + this.renderCodingTestList(); + document.title = `${this.codingTestCollection.title} · BAM.dev`; + window.scrollTo({ top: 0, behavior: "instant" }); + if (this.hasRenderedView) { + window.requestAnimationFrame(() => { + focusMainContent(document.querySelector("#coding-test-list-title")); + }); + } + this.hasRenderedView = true; + } + + async openCodingTestRoute(languageId, slug) { + const sequence = ++this.renderSequence; + const language = getLanguage(this.curriculum, languageId); + if ( + !language || + language.status === "planned" || + languageId !== DEFAULT_LANGUAGE_ID + ) { + window.history.replaceState(null, "", buildCodingTestListHash()); + this.openCodingTestListRoute(); + return; + } + + this.currentView = "coding-test"; + this.currentLesson = null; + this.currentMarkdown = ""; + this.quizCollection = null; + this.quizSession = null; + this.codeQuestState = null; + this.codingTestState = null; + this.menuOpen = false; + this.syncMenuState(); + this.root.innerHTML = renderCodingTestLoadingView({ title: language.name }); + + try { + if ( + !this.codingTestCollection || + this.codingTestCollection.languageId !== languageId + ) { + this.codingTestCollection = await loadCodingTestCollection( + languageId, + this.curriculum, + ); + } + if (sequence !== this.renderSequence) return; + + const problem = findCodingTestProblemBySlug(this.codingTestCollection, slug); + if (!problem) { + window.history.replaceState(null, "", buildCodingTestListHash()); + this.openCodingTestListRoute(); + return; + } + const canonicalHash = buildCodingTestHash(languageId, problem.slug); + if (window.location.hash !== canonicalHash) { + window.history.replaceState(null, "", canonicalHash); + } + + let draft = null; + let draftStatus = "starter"; + let uiError = null; + try { + draft = this.progressRepository.getCodingTestDraft( + problem.id, + problem.revision, + ); + if (draft) { + const persistence = this.progressRepository.getPersistenceStatus(); + draftStatus = persistence.isPersistent ? "saved" : "memory"; + } + } catch { + draftStatus = "failed"; + uiError = "저장된 초안을 읽지 못해 초기 코드를 불러왔습니다."; + } + + this.codingTestState = { + problem, + source: draft ? draft.source : problem.starterCode, + isRunning: false, + cancelRequested: false, + executionMode: null, + draftStatus, + uiError, + report: null, + reportPersistenceStatus: null, + }; + this.renderCodingTest(); + document.title = `${problem.title} · BAM.dev`; + window.scrollTo({ top: 0, behavior: "instant" }); + if (this.hasRenderedView) this.focusCodingTestTitle(); + this.hasRenderedView = true; + } catch (error) { + if (sequence === this.renderSequence) this.renderFatalError(error); + } + } + handleClick(event) { + if (this.handleCodingTestClick(event)) return; if (this.handleCodeQuestClick(event)) return; if (this.handleQuizClick(event)) return; @@ -429,6 +632,16 @@ export class BamLearningApp { } handleChange(event) { + const codingTestFilter = event.target.closest("[data-coding-test-filter]"); + if (codingTestFilter && this.currentView === "coding-test-list") { + const filterName = codingTestFilter.dataset.codingTestFilter; + if (["difficulty", "language", "type", "status"].includes(filterName)) { + this.codingTestFilters[filterName] = codingTestFilter.value; + this.renderCodingTestList({ focusSelector: `[data-coding-test-filter="${filterName}"]` }); + } + return; + } + const option = event.target.closest("[data-quiz-option]"); const question = this.getCurrentQuizQuestion(); if ( @@ -455,6 +668,33 @@ export class BamLearningApp { } handleInput(event) { + const codingTestSearch = event.target.closest("[data-coding-test-search]"); + if (codingTestSearch && this.currentView === "coding-test-list") { + if (event.isComposing) { + this.cancelPendingCodingTestSearchRender(); + return; + } + const cursorPosition = codingTestSearch.selectionStart; + this.codingTestFilters.query = codingTestSearch.value; + this.scheduleCodingTestSearchRender(codingTestSearch, cursorPosition); + return; + } + + const codingTestEditor = event.target.closest("[data-coding-test-source]"); + const codingTestState = this.codingTestState; + if ( + codingTestEditor && + this.currentView === "coding-test" && + codingTestState && + !codingTestState.isRunning + ) { + codingTestState.source = codingTestEditor.value; + codingTestState.uiError = null; + this.scheduleCodingTestDraftSave(codingTestState); + this.updateCodingTestDraftFeedback(); + return; + } + const editor = event.target.closest("[data-quest-source]"); const state = this.codeQuestState; if (!editor || this.currentView !== "quest" || !state || state.isRunning) return; @@ -465,6 +705,38 @@ export class BamLearningApp { this.updateCodeQuestDraftFeedback(); } + handleCodingTestClick(event) { + if (this.currentView === "coding-test-list") { + if (event.target.closest("[data-coding-test-filter-reset]")) { + this.codingTestFilters = createDefaultCodingTestFilters(); + this.renderCodingTestList({ focusSelector: "[data-coding-test-search]" }); + this.announce("코딩테스트 필터를 초기화했습니다."); + return true; + } + return false; + } + + if (this.currentView !== "coding-test" || !this.codingTestState) return false; + if (event.target.closest("[data-coding-test-run]")) { + void this.executeCurrentCodingTest("run"); + return true; + } + if (event.target.closest("[data-coding-test-submit]")) { + void this.executeCurrentCodingTest("submit"); + return true; + } + const cancelButton = event.target.closest("[data-coding-test-cancel]"); + if (cancelButton) { + this.cancelCodingTestRun(cancelButton); + return true; + } + if (event.target.closest("[data-coding-test-reset]")) { + this.resetCodingTestSource(); + return true; + } + return false; + } + handleCodeQuestClick(event) { if (this.currentView !== "quest" || !this.codeQuestState) return false; @@ -760,6 +1032,156 @@ export class BamLearningApp { if (isCurrentOwner) this.updateCodeQuestDraftFeedback(); } + updateCodingTestDraftFeedback() { + const state = this.codingTestState; + if (!state) return; + + const status = document.querySelector("[data-coding-test-draft-status]"); + if (status) { + status.textContent = getCodingTestDraftStatusMessage(state.draftStatus); + status.classList.toggle( + "is-warning", + state.draftStatus === "failed" || state.draftStatus === "memory", + ); + } + const error = document.querySelector("[data-coding-test-error]"); + if (error) error.textContent = state.uiError ?? ""; + const sourceIsEmpty = state.source.trim().length === 0; + for (const button of document.querySelectorAll( + "[data-coding-test-run], [data-coding-test-submit]", + )) { + button.disabled = sourceIsEmpty; + } + } + + setCodingTestSearchRenderTimer(callback) { + return window.setTimeout(callback, CODING_TEST_SEARCH_DEBOUNCE_MS); + } + + clearCodingTestSearchRenderTimer(timer) { + window.clearTimeout(timer); + } + + clearScheduledCodingTestSearchRender() { + if (this.codingTestSearchRenderTimer == null) return; + this.clearCodingTestSearchRenderTimer(this.codingTestSearchRenderTimer); + this.codingTestSearchRenderTimer = null; + } + + scheduleCodingTestSearchRender(owner, cursorPosition) { + this.clearScheduledCodingTestSearchRender(); + const pending = { + owner, + cursorPosition: Number.isSafeInteger(cursorPosition) ? cursorPosition : null, + }; + this.pendingCodingTestSearchRender = pending; + this.codingTestSearchRenderTimer = this.setCodingTestSearchRenderTimer(() => { + if (this.pendingCodingTestSearchRender !== pending) return; + this.pendingCodingTestSearchRender = null; + this.codingTestSearchRenderTimer = null; + if (this.currentView !== "coding-test-list") return; + + const shouldRestoreFocus = globalThis.document?.activeElement === pending.owner; + this.renderCodingTestList({ + focusSelector: shouldRestoreFocus ? "[data-coding-test-search]" : null, + cursorPosition: shouldRestoreFocus ? pending.cursorPosition : null, + }); + }); + } + + cancelPendingCodingTestSearchRender() { + this.clearScheduledCodingTestSearchRender(); + this.pendingCodingTestSearchRender = null; + } + + setCodingTestDraftSaveTimer(callback) { + return window.setTimeout(callback, QUEST_DRAFT_SAVE_DEBOUNCE_MS); + } + + clearCodingTestDraftSaveTimer(timer) { + window.clearTimeout(timer); + } + + clearScheduledCodingTestDraftSave() { + if (this.codingTestDraftSaveTimer === null) return; + this.clearCodingTestDraftSaveTimer(this.codingTestDraftSaveTimer); + this.codingTestDraftSaveTimer = null; + } + + scheduleCodingTestDraftSave(state) { + const languageId = this.codingTestCollection?.languageId; + if (!languageId) { + state.draftStatus = "failed"; + state.uiError = "초안을 저장하지 못했습니다. 편집 중인 코드는 화면에 유지됩니다."; + return; + } + + const previous = this.pendingCodingTestDraftSave; + if (previous && previous.problemId !== state.problem.id) { + this.flushPendingCodingTestDraftSave(); + } else { + this.clearScheduledCodingTestDraftSave(); + } + + const pending = { + owner: state, + problemId: state.problem.id, + problemRevision: state.problem.revision, + languageId, + source: state.source, + }; + this.pendingCodingTestDraftSave = pending; + this.codingTestDraftSaveTimer = this.setCodingTestDraftSaveTimer(() => { + if (this.pendingCodingTestDraftSave !== pending) return; + this.pendingCodingTestDraftSave = null; + this.codingTestDraftSaveTimer = null; + this.persistCodingTestDraft(pending); + }); + } + + flushPendingCodingTestDraftSave() { + const pending = this.pendingCodingTestDraftSave; + if (!pending) return false; + this.clearScheduledCodingTestDraftSave(); + this.pendingCodingTestDraftSave = null; + this.persistCodingTestDraft(pending); + return true; + } + + cancelPendingCodingTestDraftSave() { + this.clearScheduledCodingTestDraftSave(); + this.pendingCodingTestDraftSave = null; + } + + persistCodingTestDraft(pending) { + const isCurrentOwner = + this.currentView === "coding-test" && + this.codingTestState === pending.owner && + pending.owner.problem.id === pending.problemId; + + try { + this.progressRepository.saveCodingTestDraft({ + problemId: pending.problemId, + problemRevision: pending.problemRevision, + languageId: pending.languageId, + source: pending.source, + }); + const persistence = this.progressRepository.getPersistenceStatus(); + if (isCurrentOwner) { + pending.owner.draftStatus = persistence.isPersistent ? "saved" : "memory"; + pending.owner.uiError = null; + } + } catch { + if (isCurrentOwner) { + pending.owner.draftStatus = "failed"; + pending.owner.uiError = + "초안을 저장하지 못했습니다. 편집 중인 코드는 화면에 유지됩니다."; + } + } + + if (isCurrentOwner) this.updateCodingTestDraftFeedback(); + } + revealNextCodeQuestHint() { const state = this.codeQuestState; if (!state || state.isRunning) return; @@ -799,13 +1221,152 @@ export class BamLearningApp { }); } + resetCodingTestSource() { + const state = this.codingTestState; + if (!state || state.isRunning) return; + + this.cancelPendingCodingTestDraftSave(); + state.source = state.problem.starterCode; + state.executionMode = null; + state.report = null; + state.reportPersistenceStatus = null; + state.uiError = null; + try { + this.progressRepository.clearCodingTestDraft(state.problem.id); + const persistence = this.progressRepository.getPersistenceStatus(); + state.draftStatus = persistence.isPersistent ? "starter" : "memory"; + } catch { + state.draftStatus = "failed"; + state.uiError = + "편집기는 초기 코드로 되돌렸지만 저장된 초안을 지우지 못했습니다."; + } + + this.renderCodingTest(); + window.requestAnimationFrame(() => { + document.querySelector("[data-coding-test-source]")?.focus({ preventScroll: true }); + }); + } + + async executeCurrentCodingTest(mode) { + const state = this.codingTestState; + if ( + !state || + this.currentView !== "coding-test" || + state.isRunning || + !["run", "submit"].includes(mode) + ) { + return; + } + + this.flushPendingCodingTestDraftSave(); + if (this.activeCodingTestExecution || this.activeQuestExecution) { + state.uiError = "이전 코드 실행을 정리하고 있습니다. 잠시 후 다시 시도해 주세요."; + this.renderCodingTest(); + return; + } + + this.codingTestRequestSequence += 1; + const requestId = `coding-test-${mode}-${Date.now().toString(36)}-${this.codingTestRequestSequence.toString(36)}`; + const execution = { + requestId, + problemId: state.problem.id, + mode, + controller: new AbortController(), + submissionRecorded: false, + }; + this.activeCodingTestExecution = execution; + state.isRunning = true; + state.cancelRequested = false; + state.executionMode = mode; + state.uiError = null; + state.report = null; + state.reportPersistenceStatus = null; + this.renderCodingTest(); + window.requestAnimationFrame(() => { + document.querySelector("[data-coding-test-cancel]")?.focus({ preventScroll: true }); + }); + + let report; + try { + report = await this.codingTestRunner.run( + { + collection: this.codingTestCollection, + problem: state.problem, + source: state.source, + requestId, + mode, + }, + { signal: execution.controller.signal }, + ); + } catch (error) { + if (this.activeCodingTestExecution === execution) { + this.activeCodingTestExecution = null; + } + if (this.codingTestState !== state || this.currentView !== "coding-test") return; + state.isRunning = false; + state.cancelRequested = false; + state.uiError = + error instanceof Error ? error.message : "코드 실행 결과를 받지 못했습니다."; + this.renderCodingTest(); + return; + } + + let reportPersistenceStatus = null; + if (mode === "submit" && !execution.submissionRecorded) { + execution.submissionRecorded = true; + reportPersistenceStatus = "saved"; + try { + this.progressRepository.recordCodingTestSubmission({ + problemId: report.problemId, + problemRevision: report.problemRevision, + languageId: report.languageId, + outcome: report.outcome, + passed: report.summary.passed, + total: report.summary.total, + }); + const persistence = this.progressRepository.getPersistenceStatus(); + reportPersistenceStatus = persistence.isPersistent ? "saved" : "memory"; + } catch { + reportPersistenceStatus = "failed"; + } + } + + if (this.activeCodingTestExecution === execution) { + this.activeCodingTestExecution = null; + } + if ( + this.codingTestState !== state || + this.currentView !== "coding-test" || + state.problem.id !== execution.problemId + ) { + return; + } + + state.isRunning = false; + state.cancelRequested = false; + state.report = report; + state.reportPersistenceStatus = reportPersistenceStatus; + this.renderCodingTest(); + this.focusCodingTestResults(); + } + + cancelCodingTestRun(button) { + const state = this.codingTestState; + const execution = this.activeCodingTestExecution; + if (!state?.isRunning || !execution || execution.controller.signal.aborted) return; + state.cancelRequested = true; + button.disabled = true; + button.textContent = "취소하는 중…"; + execution.controller.abort(); + } + async runCurrentCodeQuest() { const state = this.codeQuestState; if (!state || this.currentView !== "quest" || state.isRunning) return; this.flushPendingQuestDraftSave(); - if (this.activeQuestExecution) { + if (this.activeQuestExecution || this.activeCodingTestExecution) { state.uiError = "이전 코드 실행을 정리하고 있습니다. 잠시 후 다시 실행해 주세요."; this.renderCodeQuest(); return; @@ -913,6 +1474,11 @@ export class BamLearningApp { if (execution && !execution.controller.signal.aborted) execution.controller.abort(); } + abortCodingTestExecutionForNavigation() { + const execution = this.activeCodingTestExecution; + if (execution && !execution.controller.signal.aborted) execution.controller.abort(); + } + focusCodeQuestTitle() { window.requestAnimationFrame(() => { focusMainContent( @@ -929,6 +1495,23 @@ export class BamLearningApp { }); } + focusCodingTestTitle() { + window.requestAnimationFrame(() => { + focusMainContent( + document.querySelector("#coding-test-title") ?? + document.querySelector("#lesson-content"), + ); + }); + } + + focusCodingTestResults() { + window.requestAnimationFrame(() => { + const results = document.querySelector("[data-coding-test-results]"); + results?.scrollIntoView({ behavior: "instant", block: "start" }); + focusMainContent(results ?? document.querySelector("#lesson-content")); + }); + } + async copyCode(button, code) { try { await navigator.clipboard.writeText(code); @@ -973,6 +1556,11 @@ export class BamLearningApp { const completedCodeQuestCount = codeQuests.filter((item) => progress.completedQuestIds.includes(item.id), ).length; + const codingTestProblems = + language.id === this.codingTestCollection?.languageId + ? getCodingTestProblemsInOrder(this.codingTestCollection) + : []; + const solvedCodingTestProblemIds = this.getSolvedCodingTestProblemIds(progress); this.root.innerHTML = `
@@ -1035,6 +1623,16 @@ export class BamLearningApp { }) : "" } + ${ + codingTestProblems.length > 0 + ? renderCodingTestNavigationLink({ + href: buildCodingTestListHash(), + isCurrent: false, + solvedCount: solvedCodingTestProblemIds.size, + totalCount: codingTestProblems.length, + }) + : "" + } @@ -1111,6 +1709,11 @@ export class BamLearningApp { const completedCodeQuestCount = codeQuests.filter((item) => progress.completedQuestIds.includes(item.id), ).length; + const codingTestProblems = + language.id === this.codingTestCollection?.languageId + ? getCodingTestProblemsInOrder(this.codingTestCollection) + : []; + const solvedCodingTestProblemIds = this.getSolvedCodingTestProblemIds(progress); const session = this.quizSession; const question = this.getCurrentQuizQuestion(); const mainContent = @@ -1196,6 +1799,16 @@ export class BamLearningApp { }) : "" } + ${ + codingTestProblems.length > 0 + ? renderCodingTestNavigationLink({ + href: buildCodingTestListHash(), + isCurrent: false, + solvedCount: solvedCodingTestProblemIds.size, + totalCount: codingTestProblems.length, + }) + : "" + } @@ -1230,6 +1843,8 @@ export class BamLearningApp { const completedQuestCount = quests.filter((quest) => completedQuestIds.has(quest.id), ).length; + const codingTestProblems = getCodingTestProblemsInOrder(this.codingTestCollection); + const solvedCodingTestProblemIds = this.getSolvedCodingTestProblemIds(progress); const firstLessonHref = buildLessonHash(lessons[0].languageId, lessons[0].slug); const currentQuestHref = buildQuestHash(language.id, state.quest.slug); const mainContent = renderCodeQuestView({ @@ -1317,6 +1932,220 @@ export class BamLearningApp { completedCount: completedQuestCount, totalCount: quests.length, })} + ${renderCodingTestNavigationLink({ + href: buildCodingTestListHash(), + isCurrent: false, + solvedCount: solvedCodingTestProblemIds.size, + totalCount: codingTestProblems.length, + })} + + + + ${mainContent} +
+
+ `; + this.syncMenuState(); + } + + getSolvedCodingTestProblemIds(progress = this.progressRepository.getProgress()) { + const completions = Array.isArray(progress.completedCodingTestProblems) + ? progress.completedCodingTestProblems + : []; + const revisionByProblemId = new Map( + getCodingTestProblemsInOrder(this.codingTestCollection).map((problem) => [ + problem.id, + problem.revision, + ]), + ); + return new Set( + completions + .filter( + (completion) => + revisionByProblemId.get(completion.problemId) === + completion.problemRevision, + ) + .map((completion) => completion.problemId), + ); + } + + renderCodingTestList({ focusSelector = null, cursorPosition = null } = {}) { + this.cancelPendingCodingTestSearchRender(); + const collection = this.codingTestCollection; + if (!this.curriculum || !collection) return; + const language = getLanguage(this.curriculum, collection.languageId); + if (!language) return; + + const allProblems = getCodingTestProblemsInOrder(collection); + const progress = this.progressRepository.getProgress(); + const solvedProblemIds = this.getSolvedCodingTestProblemIds(progress); + const languageMatches = + this.codingTestFilters.language === "all" || + this.codingTestFilters.language === collection.languageId; + const visibleProblems = languageMatches + ? filterCodingTestProblems(collection, { + query: this.codingTestFilters.query, + difficulty: this.codingTestFilters.difficulty, + type: this.codingTestFilters.type, + status: this.codingTestFilters.status, + completedProblemIds: solvedProblemIds, + }) + : []; + const hrefByProblemId = Object.fromEntries( + allProblems.map((problem) => [ + problem.id, + buildCodingTestHash(collection.languageId, problem.slug), + ]), + ); + const mainContent = renderCodingTestListView({ + title: collection.title, + problems: visibleProblems, + totalCount: allProblems.length, + filters: this.codingTestFilters, + languageName: language.name, + languageOptions: [{ value: language.id, label: language.name }], + typeOptions: [...new Set(allProblems.map((problem) => problem.type))], + solvedProblemIds, + hrefByProblemId, + }); + this.renderCodingTestShell(mainContent, progress, solvedProblemIds); + + if (focusSelector) { + window.requestAnimationFrame(() => { + const target = document.querySelector(focusSelector); + target?.focus({ preventScroll: true }); + if ( + Number.isSafeInteger(cursorPosition) && + typeof target?.setSelectionRange === "function" + ) { + target.setSelectionRange(cursorPosition, cursorPosition); + } + }); + } + } + + renderCodingTest() { + const state = this.codingTestState; + const collection = this.codingTestCollection; + if (!this.curriculum || !collection || !state) return; + const language = getLanguage(this.curriculum, collection.languageId); + if (!language) return; + const progress = this.progressRepository.getProgress(); + const solvedProblemIds = this.getSolvedCodingTestProblemIds(progress); + const mainContent = renderCodingTestView({ + languageName: language.name, + collectionTitle: collection.title, + listHref: buildCodingTestListHash(), + problem: state.problem, + source: state.source, + isRunning: state.isRunning, + cancelRequested: state.cancelRequested, + executionMode: state.executionMode, + draftStatus: state.draftStatus, + uiError: state.uiError, + report: state.report, + reportPersistenceStatus: state.reportPersistenceStatus, + isSolved: solvedProblemIds.has(state.problem.id), + }); + this.renderCodingTestShell(mainContent, progress, solvedProblemIds); + } + + renderCodingTestShell( + mainContent, + progressSnapshot = null, + solvedProblemIdsSnapshot = null, + ) { + const collection = this.codingTestCollection; + if (!this.curriculum || !collection) return; + const language = getLanguage(this.curriculum, collection.languageId); + const lessons = getLessonsForLanguage(this.curriculum, collection.languageId); + if (!language || lessons.length === 0) return; + + const progress = progressSnapshot ?? this.progressRepository.getProgress(); + const solvedProblemIds = + solvedProblemIdsSnapshot ?? this.getSolvedCodingTestProblemIds(progress); + const completedLessonIds = new Set(progress.completedLessonIds); + const completedLessonCount = lessons.filter((lesson) => + completedLessonIds.has(lesson.id), + ).length; + const lessonProgressPercent = Math.round( + (completedLessonCount / lessons.length) * 100, + ); + const quests = getCodeQuestsInOrder(this.codeQuestCollection).filter( + (quest) => quest.id.startsWith(`quest-${language.id}-`), + ); + const completedQuestCount = quests.filter((quest) => + progress.completedQuestIds.includes(quest.id), + ).length; + const problems = getCodingTestProblemsInOrder(collection); + const firstLessonHref = buildLessonHash(lessons[0].languageId, lessons[0].slug); + + this.root.innerHTML = ` +
+
+ + + BAM.dev + + +
+ + diff --git a/src/core/coding-test.js b/src/core/coding-test.js new file mode 100644 index 0000000..306e18a --- /dev/null +++ b/src/core/coding-test.js @@ -0,0 +1,529 @@ +import { + areJsonValuesEqual, + serializedJsonByteLength, + validateExecutionRequest, +} from "../grading/code-grading.js"; + +const COLLECTION_FIELDS = new Set([ + "schemaVersion", + "contractVersion", + "languageId", + "title", + "problems", +]); +const PROBLEM_FIELDS = new Set([ + "id", + "slug", + "revision", + "order", + "lessonId", + "conceptIds", + "difficulty", + "type", + "tags", + "estimatedMinutes", + "title", + "summary", + "description", + "functionContract", + "entryPoint", + "starterCode", + "examples", + "publicTests", + "runTestIds", + "failureExplanations", +]); +const FUNCTION_CONTRACT_FIELDS = new Set([ + "parameters", + "returns", + "constraints", + "complexity", +]); +const PARAMETER_FIELDS = new Set(["name", "type", "description"]); +const RETURN_FIELDS = new Set(["type", "description"]); +const COMPLEXITY_FIELDS = new Set(["time", "space"]); +const EXAMPLE_FIELDS = new Set(["args", "expected", "explanation"]); +const PUBLIC_TEST_FIELDS = new Set(["id", "label", "args", "expected"]); +const FAILURE_EXPLANATION_FIELDS = new Set(["testId", "message"]); + +const LANGUAGE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; +const STABLE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const CONCEPT_ID_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; +const ENTRY_POINT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const MAX_JSON_BYTES = 16 * 1024; +const NON_PUBLIC_TEST_TERMS = /비밀\s*테스트|숨김\s*테스트|secret\s*tests?|hidden\s*tests?/iu; + +export const CODING_TEST_DIFFICULTIES = Object.freeze([ + "beginner", + "intermediate", + "advanced", +]); +export const CODING_TEST_TYPES = Object.freeze([ + "string", + "array", + "object", + "sorting", + "search", + "simulation", +]); +export const CODING_TEST_EXECUTION_MODES = Object.freeze(["run", "submit"]); + +const difficultySet = new Set(CODING_TEST_DIFFICULTIES); +const typeSet = new Set(CODING_TEST_TYPES); +const executionModeSet = new Set(CODING_TEST_EXECUTION_MODES); + +function isPlainRecord(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function inspectExactRecord(value, label, fields, errors) { + if (!isPlainRecord(value)) { + errors.push(`${label}은 일반 객체여야 합니다.`); + return null; + } + const keys = Reflect.ownKeys(value); + for (const key of keys) { + if (typeof key !== "string" || !fields.has(key)) { + errors.push(`${label}에 허용되지 않은 필드가 있습니다: ${String(key)}`); + continue; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) { + errors.push(`${label}.${key}는 getter나 숨겨진 필드가 아닌 값이어야 합니다.`); + } + } + for (const field of fields) { + if (!Object.hasOwn(value, field)) errors.push(`${label}.${field}는 필수 필드입니다.`); + } + return value; +} + +function inspectArray(value, label, errors, minimum = 0, maximum = Infinity) { + if (!Array.isArray(value)) { + errors.push(`${label}은 배열이어야 합니다.`); + return []; + } + if (value.length < minimum) errors.push(`${label}에는 최소 ${minimum}개 항목이 필요합니다.`); + if (value.length > maximum) errors.push(`${label}에는 최대 ${maximum}개 항목만 허용됩니다.`); + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !("value" in descriptor)) { + errors.push(`${label}[${index}]은 비어 있지 않은 값 항목이어야 합니다.`); + } + } + return value; +} + +function validateText(value, label, errors) { + if (!isNonEmptyString(value)) errors.push(`${label}은 비어 있지 않은 문자열이어야 합니다.`); +} + +function validateJsonValue(value, label, errors) { + const bytes = serializedJsonByteLength(value, MAX_JSON_BYTES); + if (bytes === null) { + errors.push(`${label}은 순환 없는 JSON 호환 값이어야 합니다.`); + } else if (bytes > MAX_JSON_BYTES) { + errors.push(`${label}은 JSON 기준 ${MAX_JSON_BYTES}바이트 이하여야 합니다.`); + } +} + +function validateFunctionContract(value, label, errors) { + const contract = inspectExactRecord(value, label, FUNCTION_CONTRACT_FIELDS, errors); + if (!contract) return 0; + + const parameters = inspectArray(contract.parameters, `${label}.parameters`, errors, 1); + const names = new Set(); + for (const [index, parameterValue] of parameters.entries()) { + const parameterLabel = `${label}.parameters[${index}]`; + const parameter = inspectExactRecord(parameterValue, parameterLabel, PARAMETER_FIELDS, errors); + if (!parameter) continue; + if (!isNonEmptyString(parameter.name) || !ENTRY_POINT_PATTERN.test(parameter.name)) { + errors.push(`${parameterLabel}.name 형식이 올바르지 않습니다.`); + } else if (names.has(parameter.name)) { + errors.push(`${label}.parameters에 중복된 이름이 있습니다: ${parameter.name}`); + } + names.add(parameter.name); + validateText(parameter.type, `${parameterLabel}.type`, errors); + validateText(parameter.description, `${parameterLabel}.description`, errors); + } + + const returns = inspectExactRecord(contract.returns, `${label}.returns`, RETURN_FIELDS, errors); + if (returns) { + validateText(returns.type, `${label}.returns.type`, errors); + validateText(returns.description, `${label}.returns.description`, errors); + } + + const constraints = inspectArray(contract.constraints, `${label}.constraints`, errors, 1); + const normalizedConstraints = new Set(); + for (const [index, constraint] of constraints.entries()) { + validateText(constraint, `${label}.constraints[${index}]`, errors); + if (isNonEmptyString(constraint)) normalizedConstraints.add(constraint.trim()); + } + if (normalizedConstraints.size !== constraints.length) { + errors.push(`${label}.constraints에 중복된 항목이 있습니다.`); + } + + const complexity = inspectExactRecord( + contract.complexity, + `${label}.complexity`, + COMPLEXITY_FIELDS, + errors, + ); + if (complexity) { + validateText(complexity.time, `${label}.complexity.time`, errors); + validateText(complexity.space, `${label}.complexity.space`, errors); + } + return parameters.length; +} + +function validateExamples(examplesValue, label, parameterCount, publicTests, errors) { + const examples = inspectArray(examplesValue, label, errors, 1, 3); + for (const [index, exampleValue] of examples.entries()) { + const exampleLabel = `${label}[${index}]`; + const example = inspectExactRecord(exampleValue, exampleLabel, EXAMPLE_FIELDS, errors); + if (!example) continue; + const args = inspectArray(example.args, `${exampleLabel}.args`, errors); + if (args.length !== parameterCount) { + errors.push(`${exampleLabel}.args는 함수 매개변수 ${parameterCount}개와 같은 수여야 합니다.`); + } + validateJsonValue(args, `${exampleLabel}.args`, errors); + validateJsonValue(example.expected, `${exampleLabel}.expected`, errors); + validateText(example.explanation, `${exampleLabel}.explanation`, errors); + if ( + !publicTests.some( + (test) => + isPlainRecord(test) && + areJsonValuesEqual(test.args, example.args) && + areJsonValuesEqual(test.expected, example.expected), + ) + ) { + errors.push(`${exampleLabel}은 실제 공개 테스트 입출력과 일치해야 합니다.`); + } + } +} + +function validateProblem(problemValue, index, collection, lessonMap, allTestIds, errors) { + const label = `problems[${index}]`; + const problem = inspectExactRecord(problemValue, label, PROBLEM_FIELDS, errors); + if (!problem) return; + + const idPattern = new RegExp( + `^coding-test-${collection.languageId}-[a-z0-9]+(?:-[a-z0-9]+)*$`, + ); + if (!isNonEmptyString(problem.id) || !idPattern.test(problem.id)) { + errors.push(`${label}.id는 컬렉션 언어로 네임스페이스되어야 합니다.`); + } + if (!isNonEmptyString(problem.slug) || !STABLE_ID_PATTERN.test(problem.slug)) { + errors.push(`${label}.slug 형식이 올바르지 않습니다.`); + } + if (!Number.isSafeInteger(problem.revision) || problem.revision < 1) { + errors.push(`${label}.revision은 1 이상의 안전한 정수여야 합니다.`); + } + if (problem.order !== index + 1) { + errors.push(`${label}.order는 배열 순서에 맞게 1부터 연속되어야 합니다.`); + } + + const lesson = lessonMap.get(problem.lessonId); + if (!lesson) { + errors.push(`${label}.lessonId가 존재하지 않는 교안을 가리킵니다.`); + } else if (lesson.languageId !== collection.languageId) { + errors.push(`${label}.lessonId의 언어가 컬렉션 언어와 다릅니다.`); + } + + const conceptIds = inspectArray(problem.conceptIds, `${label}.conceptIds`, errors, 1); + const seenConceptIds = new Set(); + for (const [conceptIndex, conceptId] of conceptIds.entries()) { + if (!isNonEmptyString(conceptId) || !CONCEPT_ID_PATTERN.test(conceptId)) { + errors.push(`${label}.conceptIds[${conceptIndex}] 형식이 올바르지 않습니다.`); + } else if (seenConceptIds.has(conceptId)) { + errors.push(`${label}.conceptIds에 중복된 ID가 있습니다: ${conceptId}`); + } + seenConceptIds.add(conceptId); + if (lesson && !lesson.conceptIds.includes(conceptId)) { + errors.push(`${label}.conceptIds[${conceptIndex}]가 연결 교안에 없습니다.`); + } + } + + if (!difficultySet.has(problem.difficulty)) errors.push(`${label}.difficulty가 올바르지 않습니다.`); + if (!typeSet.has(problem.type)) errors.push(`${label}.type이 올바르지 않습니다.`); + const tags = inspectArray(problem.tags, `${label}.tags`, errors, 1); + const normalizedTags = new Set(); + for (const [tagIndex, tag] of tags.entries()) { + validateText(tag, `${label}.tags[${tagIndex}]`, errors); + if (isNonEmptyString(tag)) normalizedTags.add(tag.trim().toLocaleLowerCase("ko-KR")); + } + if (normalizedTags.size !== tags.length) errors.push(`${label}.tags에 중복된 값이 있습니다.`); + if (!Number.isSafeInteger(problem.estimatedMinutes) || problem.estimatedMinutes < 1) { + errors.push(`${label}.estimatedMinutes는 1 이상의 안전한 정수여야 합니다.`); + } + for (const field of ["title", "summary", "description"]) { + validateText(problem[field], `${label}.${field}`, errors); + } + + const parameterCount = validateFunctionContract( + problem.functionContract, + `${label}.functionContract`, + errors, + ); + if (!isNonEmptyString(problem.entryPoint) || !ENTRY_POINT_PATTERN.test(problem.entryPoint)) { + errors.push(`${label}.entryPoint 형식이 올바르지 않습니다.`); + } + validateText(problem.starterCode, `${label}.starterCode`, errors); + if ( + isNonEmptyString(problem.entryPoint) && + ENTRY_POINT_PATTERN.test(problem.entryPoint) && + isNonEmptyString(problem.starterCode) && + !new RegExp(`\\b${problem.entryPoint}\\b`).test(problem.starterCode) + ) { + errors.push(`${label}.starterCode에 entryPoint가 포함되어야 합니다.`); + } + + const publicTests = inspectArray(problem.publicTests, `${label}.publicTests`, errors, 4, 12); + const problemTestIds = new Set(); + for (const [testIndex, testValue] of publicTests.entries()) { + const testLabel = `${label}.publicTests[${testIndex}]`; + const publicTest = inspectExactRecord(testValue, testLabel, PUBLIC_TEST_FIELDS, errors); + if (!publicTest) continue; + if (!isNonEmptyString(publicTest.id) || !STABLE_ID_PATTERN.test(publicTest.id)) { + errors.push(`${testLabel}.id 형식이 올바르지 않습니다.`); + } else if (problemTestIds.has(publicTest.id) || allTestIds.has(publicTest.id)) { + errors.push(`공개 테스트 ID가 중복됩니다: ${publicTest.id}`); + } + problemTestIds.add(publicTest.id); + allTestIds.add(publicTest.id); + validateText(publicTest.label, `${testLabel}.label`, errors); + const args = inspectArray(publicTest.args, `${testLabel}.args`, errors); + if (args.length !== parameterCount) { + errors.push(`${testLabel}.args는 함수 매개변수 ${parameterCount}개와 같은 수여야 합니다.`); + } + validateJsonValue(args, `${testLabel}.args`, errors); + validateJsonValue(publicTest.expected, `${testLabel}.expected`, errors); + } + + const runTestIds = inspectArray(problem.runTestIds, `${label}.runTestIds`, errors, 1, 4); + const seenRunTestIds = new Set(); + for (const [runIndex, testId] of runTestIds.entries()) { + if (!isNonEmptyString(testId) || !STABLE_ID_PATTERN.test(testId)) { + errors.push(`${label}.runTestIds[${runIndex}] 형식이 올바르지 않습니다.`); + } else if (seenRunTestIds.has(testId)) { + errors.push(`${label}.runTestIds에 중복된 ID가 있습니다: ${testId}`); + } else if (!problemTestIds.has(testId)) { + errors.push(`${label}.runTestIds가 존재하지 않는 공개 테스트를 가리킵니다: ${testId}`); + } + seenRunTestIds.add(testId); + } + + validateExamples(problem.examples, `${label}.examples`, parameterCount, publicTests, errors); + + const explanations = inspectArray( + problem.failureExplanations, + `${label}.failureExplanations`, + errors, + 4, + 12, + ); + const explanationIds = new Set(); + for (const [explanationIndex, explanationValue] of explanations.entries()) { + const explanationLabel = `${label}.failureExplanations[${explanationIndex}]`; + const explanation = inspectExactRecord( + explanationValue, + explanationLabel, + FAILURE_EXPLANATION_FIELDS, + errors, + ); + if (!explanation) continue; + if (!isNonEmptyString(explanation.testId) || !STABLE_ID_PATTERN.test(explanation.testId)) { + errors.push(`${explanationLabel}.testId 형식이 올바르지 않습니다.`); + } else if (explanationIds.has(explanation.testId)) { + errors.push(`실패 설명 testId가 중복됩니다: ${explanation.testId}`); + } + explanationIds.add(explanation.testId); + validateText(explanation.message, `${explanationLabel}.message`, errors); + } + if ( + explanationIds.size !== problemTestIds.size || + [...problemTestIds].some((testId) => !explanationIds.has(testId)) || + [...explanationIds].some((testId) => !problemTestIds.has(testId)) + ) { + errors.push(`${label}.failureExplanations는 모든 공개 테스트와 1:1로 연결되어야 합니다.`); + } + + if (collection.languageId === "javascript") { + const requestErrors = validateExecutionRequest({ + requestId: `validate-${problem.id}`, + contractVersion: collection.contractVersion, + questId: problem.id, + questRevision: problem.revision, + languageId: collection.languageId, + suite: "public", + source: problem.starterCode, + entryPoint: problem.entryPoint, + tests: publicTests.map(({ id, label: testLabel, args, expected }) => ({ + id, + label: testLabel, + args, + expected, + })), + }); + for (const requestError of requestErrors) { + errors.push(`${label}: ${requestError}`); + } + } + + try { + if (NON_PUBLIC_TEST_TERMS.test(JSON.stringify(problem))) { + errors.push(`${label}에 공개 테스트를 잘못 표현하는 문구가 있습니다.`); + } + } catch { + errors.push(`${label}을 JSON으로 안전하게 확인할 수 없습니다.`); + } +} + +function validateCodingTestCollectionInternal(collectionValue, curriculum) { + const errors = []; + const collection = inspectExactRecord( + collectionValue, + "코딩테스트 컬렉션", + COLLECTION_FIELDS, + errors, + ); + if (!collection) return errors; + + if (collection.schemaVersion !== 1) errors.push("지원하는 코딩테스트 schemaVersion은 1입니다."); + if (collection.contractVersion !== 1) { + errors.push("지원하는 코딩테스트 contractVersion은 1입니다."); + } + if (!isNonEmptyString(collection.languageId) || !LANGUAGE_ID_PATTERN.test(collection.languageId)) { + errors.push("코딩테스트 languageId 형식이 올바르지 않습니다."); + } + validateText(collection.title, "코딩테스트 컬렉션.title", errors); + + const lessons = Array.isArray(curriculum?.lessons) ? curriculum.lessons : []; + const lessonMap = new Map(lessons.map((lesson) => [lesson.id, lesson])); + const problems = inspectArray(collection.problems, "코딩테스트 컬렉션.problems", errors, 1); + const problemIds = new Set(); + const slugs = new Set(); + const allTestIds = new Set(); + + for (const [index, problem] of problems.entries()) { + if (isPlainRecord(problem)) { + if (problemIds.has(problem.id)) errors.push(`코딩테스트 문제 ID가 중복됩니다: ${problem.id}`); + if (slugs.has(problem.slug)) errors.push(`코딩테스트 slug가 중복됩니다: ${problem.slug}`); + problemIds.add(problem.id); + slugs.add(problem.slug); + } + validateProblem(problem, index, collection, lessonMap, allTestIds, errors); + } + return errors; +} + +export function validateCodingTestCollection(collection, curriculum) { + try { + return validateCodingTestCollectionInternal(collection, curriculum); + } catch { + return ["코딩테스트 콘텐츠를 안전하게 검증할 수 없습니다."]; + } +} + +export function assertValidCodingTestCollection(collection, curriculum) { + const errors = validateCodingTestCollection(collection, curriculum); + if (errors.length > 0) { + throw new Error(`코딩테스트 콘텐츠 검증 실패:\n- ${errors.join("\n- ")}`); + } + return collection; +} + +export async function loadCodingTestCollection( + languageId, + curriculum, + fetchImplementation = globalThis.fetch, +) { + if (!isNonEmptyString(languageId) || !LANGUAGE_ID_PATTERN.test(languageId)) { + throw new Error("허용되지 않은 코딩테스트 언어 경로입니다."); + } + if (typeof fetchImplementation !== "function") { + throw new TypeError("코딩테스트 콘텐츠를 불러올 fetch 구현이 필요합니다."); + } + const response = await fetchImplementation(`./content/coding-tests/${languageId}.json`); + if (!response?.ok) { + throw new Error(`코딩테스트 콘텐츠를 불러오지 못했습니다. (${response?.status ?? "unknown"})`); + } + const collection = assertValidCodingTestCollection(await response.json(), curriculum); + if (collection.languageId !== languageId) { + throw new Error("요청한 언어와 코딩테스트 컬렉션 언어가 다릅니다."); + } + return collection; +} + +function resolveProblemList(collectionOrProblems) { + if (Array.isArray(collectionOrProblems)) return collectionOrProblems; + if (isPlainRecord(collectionOrProblems) && Array.isArray(collectionOrProblems.problems)) { + return collectionOrProblems.problems; + } + return []; +} + +export function getCodingTestProblemsInOrder(collectionOrProblems) { + return [...resolveProblemList(collectionOrProblems)].sort((left, right) => left.order - right.order); +} + +export function findCodingTestProblemBySlug(collectionOrProblems, slug) { + if (!isNonEmptyString(slug) || !STABLE_ID_PATTERN.test(slug)) return null; + return resolveProblemList(collectionOrProblems).find((problem) => problem?.slug === slug) ?? null; +} + +export function getCodingTestPublicTestsForMode(problem, mode) { + if (!isPlainRecord(problem) || !Array.isArray(problem.publicTests)) { + throw new TypeError("실행할 코딩테스트 문제가 필요합니다."); + } + if (!executionModeSet.has(mode)) { + throw new TypeError('코딩테스트 실행 모드는 "run" 또는 "submit"이어야 합니다.'); + } + if (mode === "submit") return [...problem.publicTests]; + const testsById = new Map(problem.publicTests.map((test) => [test.id, test])); + return problem.runTestIds.map((testId) => { + const test = testsById.get(testId); + if (!test) throw new Error(`실행 공개 테스트를 찾을 수 없습니다: ${testId}`); + return test; + }); +} + +export function filterCodingTestProblems( + collectionOrProblems, + { + query = "", + difficulty = "all", + type = "all", + status = "all", + completedProblemIds = [], + } = {}, +) { + const normalizedQuery = String(query).normalize("NFKC").trim().toLocaleLowerCase("ko-KR"); + const completedIds = new Set(completedProblemIds); + return getCodingTestProblemsInOrder(collectionOrProblems).filter((problem) => { + if (difficulty !== "all" && problem.difficulty !== difficulty) return false; + if (type !== "all" && problem.type !== type) return false; + const isCompleted = completedIds.has(problem.id); + if (status === "solved" && !isCompleted) return false; + if (status === "unsolved" && isCompleted) return false; + if (!normalizedQuery) return true; + const searchableText = [ + problem.title, + problem.summary, + problem.description, + problem.type, + ...(Array.isArray(problem.conceptIds) ? problem.conceptIds : []), + ...(Array.isArray(problem.tags) ? problem.tags : []), + ] + .join(" ") + .normalize("NFKC") + .toLocaleLowerCase("ko-KR"); + return searchableText.includes(normalizedQuery); + }); +} diff --git a/src/core/navigation.js b/src/core/navigation.js index 664989b..c496ca7 100644 --- a/src/core/navigation.js +++ b/src/core/navigation.js @@ -12,6 +12,14 @@ export function buildQuestHash(languageId, slug) { return `#/quest/${encodeURIComponent(languageId)}/${encodeURIComponent(slug)}`; } +export function buildCodingTestListHash() { + return "#/coding-tests"; +} + +export function buildCodingTestHash(languageId, slug) { + return `#/coding-tests/${encodeURIComponent(languageId)}/${encodeURIComponent(slug)}`; +} + export function parseLessonHash(hash) { const cleanHash = String(hash ?? "").replace(/^#/, ""); const match = cleanHash.match(/^\/learn\/([^/]+)\/([^/]+)\/?$/); @@ -54,6 +62,24 @@ export function parseQuestHash(hash) { } } +export function parseCodingTestHash(hash) { + const cleanHash = String(hash ?? "").replace(/^#/, ""); + if (/^\/coding-tests\/?$/.test(cleanHash)) return { kind: "list" }; + + const match = cleanHash.match(/^\/coding-tests\/([^/]+)\/([^/]+)\/?$/); + if (!match) return null; + + try { + return { + kind: "problem", + languageId: decodeURIComponent(match[1]), + slug: decodeURIComponent(match[2]), + }; + } catch { + return null; + } +} + export function resolveLessonRoute(curriculum, hash, preferredLessonId = null) { const navigableLanguageIds = new Set( (curriculum.languages ?? []) diff --git a/src/grading/coding-test-runner-adapter.js b/src/grading/coding-test-runner-adapter.js new file mode 100644 index 0000000..f6eff46 --- /dev/null +++ b/src/grading/coding-test-runner-adapter.js @@ -0,0 +1,119 @@ +import { getCodingTestPublicTestsForMode } from "../core/coding-test.js"; +import { BrowserCodeQuestRunner } from "./browser-code-quest-runner.js"; +import { createExecutionRequestSnapshot } from "./code-grading.js"; + +const MODES = new Set(["run", "submit"]); + +function isPlainRecord(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function getCanonicalProblem(collection, problem) { + if (!isPlainRecord(collection) || !Array.isArray(collection.problems)) { + throw new TypeError("코딩테스트 컬렉션이 필요합니다."); + } + if (!isPlainRecord(problem) || !isNonEmptyString(problem.id)) { + throw new TypeError("실행할 코딩테스트 문제가 필요합니다."); + } + const canonicalProblem = collection.problems.find((candidate) => candidate?.id === problem.id); + if (!canonicalProblem) throw new Error("코딩테스트 문제가 컬렉션에 속하지 않습니다."); + return canonicalProblem; +} + +export function createCodingTestRunnerRequest({ + collection, + problem, + source, + requestId, + mode, +}) { + if (!MODES.has(mode)) { + throw new TypeError('코딩테스트 실행 모드는 "run" 또는 "submit"이어야 합니다.'); + } + const canonicalProblem = getCanonicalProblem(collection, problem); + const publicTests = getCodingTestPublicTestsForMode(canonicalProblem, mode); + const runnerRequest = createExecutionRequestSnapshot({ + requestId, + contractVersion: collection.contractVersion, + questId: canonicalProblem.id, + questRevision: canonicalProblem.revision, + languageId: collection.languageId, + suite: "public", + source, + entryPoint: canonicalProblem.entryPoint, + tests: publicTests.map(({ id, label, args, expected }) => ({ + id, + label, + args, + expected, + })), + }); + + return Object.freeze({ + problemId: canonicalProblem.id, + problemRevision: canonicalProblem.revision, + mode, + runnerRequest, + }); +} + +function assertMatchingRunnerReport(report, execution) { + const request = execution.runnerRequest; + if ( + !isPlainRecord(report) || + report.requestId !== request.requestId || + report.contractVersion !== request.contractVersion || + report.questId !== execution.problemId || + report.questRevision !== execution.problemRevision || + report.languageId !== request.languageId || + report.suite !== "public" + ) { + throw new Error("코딩테스트 실행 결과가 요청과 일치하지 않습니다."); + } +} + +export function adaptCodingTestRunnerReport(report, execution) { + if (!isPlainRecord(execution) || !isPlainRecord(execution.runnerRequest)) { + throw new TypeError("코딩테스트 실행 요청 정보가 필요합니다."); + } + if (!MODES.has(execution.mode)) { + throw new TypeError("코딩테스트 실행 요청의 mode가 올바르지 않습니다."); + } + assertMatchingRunnerReport(report, execution); + return { + requestId: report.requestId, + contractVersion: report.contractVersion, + problemId: execution.problemId, + problemRevision: execution.problemRevision, + languageId: report.languageId, + mode: execution.mode, + suite: "public", + outcome: report.outcome, + tests: report.tests, + summary: report.summary, + durationMs: report.durationMs, + limitsApplied: report.limitsApplied, + error: report.error, + }; +} + +export class CodingTestRunnerAdapter { + constructor(runner = new BrowserCodeQuestRunner()) { + if (!runner || typeof runner.run !== "function") { + throw new TypeError("run()을 제공하는 JavaScript 실행기가 필요합니다."); + } + this.runner = runner; + } + + async run(input, options = {}) { + const execution = createCodingTestRunnerRequest(input); + const report = await this.runner.run(execution.runnerRequest, options); + return adaptCodingTestRunnerReport(report, execution); + } +} diff --git a/src/repositories/progress-repository.js b/src/repositories/progress-repository.js index 2b27d59..81f3bf7 100644 --- a/src/repositories/progress-repository.js +++ b/src/repositories/progress-repository.js @@ -3,6 +3,9 @@ const MAX_QUIZ_ATTEMPTS = 20; const MAX_QUEST_DRAFTS = 20; const MAX_QUEST_ATTEMPTS = 50; const MAX_QUEST_SOURCE_BYTES = 20 * 1024; +const MAX_CODING_TEST_DRAFTS = 20; +const MAX_CODING_TEST_SUBMISSIONS = 50; +const MAX_CODING_TEST_SOURCE_BYTES = 20 * 1024; const STABLE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const LANGUAGE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; const OPTION_ID_PATTERN = /^[a-d]$/; @@ -36,6 +39,34 @@ const STORED_QUEST_ATTEMPT_FIELDS = new Set([ ...QUEST_ATTEMPT_INPUT_FIELDS, "completedAt", ]); +const CODING_TEST_DRAFT_INPUT_FIELDS = new Set([ + "problemId", + "problemRevision", + "languageId", + "source", +]); +const STORED_CODING_TEST_DRAFT_FIELDS = new Set([ + ...CODING_TEST_DRAFT_INPUT_FIELDS, + "updatedAt", +]); +const CODING_TEST_SUBMISSION_INPUT_FIELDS = new Set([ + "problemId", + "problemRevision", + "languageId", + "outcome", + "passed", + "total", +]); +const STORED_CODING_TEST_SUBMISSION_FIELDS = new Set([ + "id", + ...CODING_TEST_SUBMISSION_INPUT_FIELDS, + "completedAt", +]); +const COMPLETED_CODING_TEST_FIELDS = new Set([ + "problemId", + "problemRevision", + "completedAt", +]); const textEncoder = new TextEncoder(); export function createEmptyProgress() { @@ -48,6 +79,9 @@ export function createEmptyProgress() { questDrafts: [], questAttempts: [], completedQuestIds: [], + codingTestDrafts: [], + codingTestSubmissions: [], + completedCodingTestProblems: [], updatedAt: null, }; } @@ -86,6 +120,27 @@ export function normalizeProgress(value) { if (isCompletedQuestAttempt(attempt)) completedQuestIds.add(attempt.questId); } const retainedQuestAttempts = questAttempts.slice(-MAX_QUEST_ATTEMPTS); + const codingTestDrafts = normalizeCodingTestDrafts(value.codingTestDrafts); + const codingTestSubmissions = []; + const codingTestSubmissionIds = new Set(); + const completedCodingTestProblems = normalizeCompletedCodingTestProblems( + value.completedCodingTestProblems, + ); + for (const storedValue of Array.isArray(value.codingTestSubmissions) + ? value.codingTestSubmissions + : []) { + const submission = normalizeStoredCodingTestSubmission(storedValue); + if (!submission || codingTestSubmissionIds.has(submission.id)) continue; + codingTestSubmissionIds.add(submission.id); + codingTestSubmissions.push(submission); + if (isCompletedCodingTestSubmission(submission)) { + retainCompletedCodingTestProblem(completedCodingTestProblems, { + problemId: submission.problemId, + problemRevision: submission.problemRevision, + completedAt: submission.completedAt, + }); + } + } return { schemaVersion: 1, @@ -96,6 +151,9 @@ export function normalizeProgress(value) { questDrafts, questAttempts: retainedQuestAttempts, completedQuestIds: [...completedQuestIds], + codingTestDrafts, + codingTestSubmissions: codingTestSubmissions.slice(-MAX_CODING_TEST_SUBMISSIONS), + completedCodingTestProblems: [...completedCodingTestProblems.values()], updatedAt: isValidDateString(value.updatedAt) ? value.updatedAt : null, }; } @@ -116,6 +174,14 @@ function isQuestId(value) { return isStableId(value) && value.startsWith("quest-") && value.split("-").length >= 3; } +function isCodingTestProblemId(value) { + return ( + isStableId(value) && + value.startsWith("coding-test-") && + value.split("-").length >= 4 + ); +} + function isLanguageId(value) { return isNonEmptyString(value) && LANGUAGE_ID_PATTERN.test(value); } @@ -137,6 +203,10 @@ function questMatchesLanguage(questId, languageId) { return questId.startsWith(`quest-${languageId}-`); } +function codingTestProblemMatchesLanguage(problemId, languageId) { + return problemId.startsWith(`coding-test-${languageId}-`); +} + function snapshotPlainDataDto(value, allowedFields) { if (value === null || typeof value !== "object" || Array.isArray(value)) return null; @@ -197,6 +267,165 @@ function normalizeQuestDrafts(value) { .slice(-MAX_QUEST_DRAFTS); } +function isValidProblemRevision(value) { + return Number.isSafeInteger(value) && value > 0; +} + +function normalizeStoredCodingTestDraft(value) { + const draft = snapshotPlainDataDto(value, STORED_CODING_TEST_DRAFT_FIELDS); + if ( + !draft || + !isCodingTestProblemId(draft.problemId) || + !isValidProblemRevision(draft.problemRevision) || + !isLanguageId(draft.languageId) || + !codingTestProblemMatchesLanguage(draft.problemId, draft.languageId) || + typeof draft.source !== "string" || + utf8ByteLength(draft.source) > MAX_CODING_TEST_SOURCE_BYTES || + !isValidDateString(draft.updatedAt) + ) { + return null; + } + return { + problemId: draft.problemId, + problemRevision: draft.problemRevision, + languageId: draft.languageId, + source: draft.source, + updatedAt: draft.updatedAt, + }; +} + +function normalizeCodingTestDrafts(value) { + const latestByProblemId = new Map(); + for (const storedValue of Array.isArray(value) ? value : []) { + const draft = normalizeStoredCodingTestDraft(storedValue); + if (!draft) continue; + const previous = latestByProblemId.get(draft.problemId); + if (!previous || previous.updatedAt <= draft.updatedAt) { + latestByProblemId.set(draft.problemId, draft); + } + } + + return [...latestByProblemId.values()] + .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)) + .slice(-MAX_CODING_TEST_DRAFTS); +} + +function normalizeCodingTestDraftInput(value) { + const draft = snapshotPlainDataDto(value, CODING_TEST_DRAFT_INPUT_FIELDS); + if ( + !draft || + !isCodingTestProblemId(draft.problemId) || + !isValidProblemRevision(draft.problemRevision) || + !isLanguageId(draft.languageId) || + !codingTestProblemMatchesLanguage(draft.problemId, draft.languageId) + ) { + throw new TypeError("코딩테스트 초안의 문제 ID, 리비전과 언어가 올바르지 않습니다."); + } + if (typeof draft.source !== "string") { + throw new TypeError("코딩테스트 초안 source는 문자열이어야 합니다."); + } + if (utf8ByteLength(draft.source) > MAX_CODING_TEST_SOURCE_BYTES) { + throw new RangeError( + `코딩테스트 초안은 UTF-8 ${MAX_CODING_TEST_SOURCE_BYTES}바이트 이하여야 합니다.`, + ); + } + return { + problemId: draft.problemId, + problemRevision: draft.problemRevision, + languageId: draft.languageId, + source: draft.source, + }; +} + +function isValidCodingTestSubmissionSnapshot(submission, requireStoredFields) { + return ( + (!requireStoredFields || + getCodingTestSubmissionSuffix(submission.id, submission.completedAt) !== null) && + isCodingTestProblemId(submission.problemId) && + isValidProblemRevision(submission.problemRevision) && + isLanguageId(submission.languageId) && + codingTestProblemMatchesLanguage(submission.problemId, submission.languageId) && + QUEST_OUTCOMES.has(submission.outcome) && + Number.isSafeInteger(submission.passed) && + submission.passed >= 0 && + Number.isSafeInteger(submission.total) && + submission.total >= submission.passed && + isQuestOutcomeConsistent(submission.outcome, submission.passed, submission.total) && + (!requireStoredFields || isValidDateString(submission.completedAt)) + ); +} + +function normalizeStoredCodingTestSubmission(value) { + const submission = snapshotPlainDataDto(value, STORED_CODING_TEST_SUBMISSION_FIELDS); + if (!submission || !isValidCodingTestSubmissionSnapshot(submission, true)) return null; + return { + id: submission.id, + problemId: submission.problemId, + problemRevision: submission.problemRevision, + languageId: submission.languageId, + outcome: submission.outcome, + passed: submission.passed, + total: submission.total, + completedAt: submission.completedAt, + }; +} + +function normalizeCodingTestSubmissionInput(value) { + const submission = snapshotPlainDataDto(value, CODING_TEST_SUBMISSION_INPUT_FIELDS); + if (!submission || !isValidCodingTestSubmissionSnapshot(submission, false)) { + throw new TypeError("코딩테스트 제출 결과 형식이 올바르지 않습니다."); + } + return { + problemId: submission.problemId, + problemRevision: submission.problemRevision, + languageId: submission.languageId, + outcome: submission.outcome, + passed: submission.passed, + total: submission.total, + }; +} + +function isCompletedCodingTestSubmission(submission) { + return ( + submission.outcome === "passed" && + submission.total > 0 && + submission.passed === submission.total + ); +} + +function normalizeCompletedCodingTestProblem(value) { + const completed = snapshotPlainDataDto(value, COMPLETED_CODING_TEST_FIELDS); + if ( + !completed || + !isCodingTestProblemId(completed.problemId) || + !isValidProblemRevision(completed.problemRevision) || + !isValidDateString(completed.completedAt) + ) { + return null; + } + return { + problemId: completed.problemId, + problemRevision: completed.problemRevision, + completedAt: completed.completedAt, + }; +} + +function retainCompletedCodingTestProblem(completedByProblemId, completed) { + const previous = completedByProblemId.get(completed.problemId); + if (!previous || previous.completedAt <= completed.completedAt) { + completedByProblemId.set(completed.problemId, completed); + } +} + +function normalizeCompletedCodingTestProblems(value) { + const completedByProblemId = new Map(); + for (const storedValue of Array.isArray(value) ? value : []) { + const completed = normalizeCompletedCodingTestProblem(storedValue); + if (completed) retainCompletedCodingTestProblem(completedByProblemId, completed); + } + return completedByProblemId; +} + function isValidQuestAttemptSnapshot(attempt, requireStoredFields) { return ( (!requireStoredFields || getQuestAttemptSuffix(attempt.id, attempt.completedAt) !== null) && @@ -230,9 +459,9 @@ function normalizeStoredQuestAttempt(value) { }; } -function getQuestAttemptSuffix(id, completedAt) { +function getTimestampedRecordSuffix(namespace, id, completedAt) { if (!isNonEmptyString(id) || !isValidDateString(completedAt)) return null; - const prefix = `quest-${completedAt}-`; + const prefix = `${namespace}-${completedAt}-`; if (!id.startsWith(prefix)) return null; const suffixText = id.slice(prefix.length); if (!/^[1-9][0-9]*$/.test(suffixText)) return null; @@ -240,6 +469,14 @@ function getQuestAttemptSuffix(id, completedAt) { return Number.isSafeInteger(suffix) && suffix > 0 ? suffix : null; } +function getQuestAttemptSuffix(id, completedAt) { + return getTimestampedRecordSuffix("quest", id, completedAt); +} + +function getCodingTestSubmissionSuffix(id, completedAt) { + return getTimestampedRecordSuffix("coding-test", id, completedAt); +} + function isQuestOutcomeConsistent(outcome, passed, total) { if (outcome === "passed") return total > 0 && passed === total; if (outcome === "wrong_answer") return total > 0 && passed < total; @@ -454,6 +691,22 @@ export class ProgressRepository { throw new Error("recordQuestAttempt()를 구현해야 합니다."); } + getCodingTestDraft() { + throw new Error("getCodingTestDraft()를 구현해야 합니다."); + } + + saveCodingTestDraft() { + throw new Error("saveCodingTestDraft()를 구현해야 합니다."); + } + + clearCodingTestDraft() { + throw new Error("clearCodingTestDraft()를 구현해야 합니다."); + } + + recordCodingTestSubmission() { + throw new Error("recordCodingTestSubmission()을 구현해야 합니다."); + } + getPersistenceStatus() { throw new Error("getPersistenceStatus()를 구현해야 합니다."); } @@ -602,6 +855,83 @@ export class LocalStorageProgressRepository extends ProgressRepository { ); } + getCodingTestDraft(problemId, problemRevision) { + if (!isCodingTestProblemId(problemId) || !isValidProblemRevision(problemRevision)) { + throw new TypeError("유효한 코딩테스트 문제 ID와 리비전이 필요합니다."); + } + return ( + this.getProgress().codingTestDrafts.find( + (draft) => + draft.problemId === problemId && draft.problemRevision === problemRevision, + ) ?? null + ); + } + + saveCodingTestDraft(draftInput) { + const draft = normalizeCodingTestDraftInput(draftInput); + const progress = this.getProgress(); + const updatedAt = this.clock().toISOString(); + + return this.#save( + { + ...progress, + codingTestDrafts: [ + ...progress.codingTestDrafts.filter( + (stored) => stored.problemId !== draft.problemId, + ), + { ...draft, updatedAt }, + ].slice(-MAX_CODING_TEST_DRAFTS), + }, + updatedAt, + ); + } + + clearCodingTestDraft(problemId) { + if (!isCodingTestProblemId(problemId)) { + throw new TypeError("유효한 코딩테스트 문제 ID가 필요합니다."); + } + const progress = this.getProgress(); + return this.#save({ + ...progress, + codingTestDrafts: progress.codingTestDrafts.filter( + (draft) => draft.problemId !== problemId, + ), + }); + } + + recordCodingTestSubmission(submissionInput) { + const submission = normalizeCodingTestSubmissionInput(submissionInput); + const progress = this.getProgress(); + const completedAt = this.clock().toISOString(); + const storedSubmission = { + id: createCodingTestSubmissionId(completedAt, progress.codingTestSubmissions), + ...submission, + completedAt, + }; + const completedCodingTestProblems = normalizeCompletedCodingTestProblems( + progress.completedCodingTestProblems, + ); + if (isCompletedCodingTestSubmission(storedSubmission)) { + retainCompletedCodingTestProblem(completedCodingTestProblems, { + problemId: storedSubmission.problemId, + problemRevision: storedSubmission.problemRevision, + completedAt, + }); + } + + return this.#save( + { + ...progress, + codingTestSubmissions: [ + ...progress.codingTestSubmissions, + storedSubmission, + ].slice(-MAX_CODING_TEST_SUBMISSIONS), + completedCodingTestProblems: [...completedCodingTestProblems.values()], + }, + completedAt, + ); + } + #save(progress, updatedAt = this.clock().toISOString()) { const nextProgress = normalizeProgress({ ...progress, @@ -623,13 +953,13 @@ function createQuizAttemptId(completedAt, attempts) { return `${prefix}${largestSuffix + 1}`; } -function createQuestAttemptId(completedAt, attempts) { - const prefix = `quest-${completedAt}-`; +function createTimestampedRecordId(namespace, completedAt, records) { + const prefix = `${namespace}-${completedAt}-`; const usedSuffixes = new Set(); let largestSuffix = 0; - for (const attempt of attempts) { - const suffix = getQuestAttemptSuffix(attempt.id, attempt.completedAt); - if (suffix === null || !attempt.id.startsWith(prefix)) continue; + for (const record of records) { + const suffix = getTimestampedRecordSuffix(namespace, record.id, record.completedAt); + if (suffix === null || !record.id.startsWith(prefix)) continue; usedSuffixes.add(suffix); largestSuffix = Math.max(largestSuffix, suffix); } @@ -642,3 +972,11 @@ function createQuestAttemptId(completedAt, attempts) { while (usedSuffixes.has(availableSuffix)) availableSuffix += 1; return `${prefix}${availableSuffix}`; } + +function createQuestAttemptId(completedAt, attempts) { + return createTimestampedRecordId("quest", completedAt, attempts); +} + +function createCodingTestSubmissionId(completedAt, submissions) { + return createTimestampedRecordId("coding-test", completedAt, submissions); +} diff --git a/src/ui/coding-test-view.js b/src/ui/coding-test-view.js new file mode 100644 index 0000000..ac4f404 --- /dev/null +++ b/src/ui/coding-test-view.js @@ -0,0 +1,571 @@ +import { escapeHtml } from "./markdown.js"; + +const DIFFICULTY_LABELS = Object.freeze({ + beginner: "입문", + intermediate: "중급", + advanced: "심화", +}); + +const TYPE_LABELS = Object.freeze({ + array: "배열", + condition: "조건", + implementation: "구현", + object: "객체", + search: "탐색", + simulation: "시뮬레이션", + sorting: "정렬", + string: "문자열", +}); + +const OUTCOME_COPY = Object.freeze({ + passed: { label: "통과", tone: "success" }, + wrong_answer: { label: "기대값과 다름", tone: "danger" }, + syntax_error: { label: "문법 오류", tone: "danger" }, + runtime_error: { label: "실행 오류", tone: "danger" }, + timeout: { label: "시간 초과", tone: "warning" }, + output_limit: { label: "출력 한도 초과", tone: "warning" }, + cancelled: { label: "실행 취소", tone: "muted" }, + engine_error: { label: "실행기 오류", tone: "danger" }, + not_run: { label: "실행하지 않음", tone: "muted" }, +}); + +const DRAFT_STATUS_COPY = Object.freeze({ + starter: "초기 코드를 불러왔습니다. 편집하면 자동으로 저장됩니다.", + saved: "초안을 이 브라우저에 저장했습니다.", + memory: "초안이 현재 탭에만 저장되었습니다. 탭을 닫으면 사라질 수 있습니다.", + failed: "초안을 저장하지 못했습니다. 코드는 편집기에 그대로 유지됩니다.", +}); + +function safeInteger(value, fallback = 0) { + return Number.isSafeInteger(value) ? value : fallback; +} + +function toStringArray(value) { + return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; +} + +function toIdSet(value) { + if (value instanceof Set) return new Set([...value].filter((item) => typeof item === "string")); + return new Set(toStringArray(value)); +} + +function formatJsonValue(value) { + try { + const serialized = JSON.stringify(value, null, 2); + return serialized === undefined ? String(value) : serialized; + } catch { + return "값을 표시할 수 없습니다."; + } +} + +function getErrorMessage(error) { + if (!error || typeof error !== "object") return ""; + const message = error.learnerMessage ?? error.message; + return typeof message === "string" ? message : ""; +} + +function getDifficultyLabel(value) { + return DIFFICULTY_LABELS[value] ?? "연습"; +} + +function normalizeOption(option) { + if (typeof option === "string") { + return { value: option, label: TYPE_LABELS[option] ?? option }; + } + if (!option || typeof option !== "object") return null; + const value = typeof option.value === "string" ? option.value : option.id; + const label = typeof option.label === "string" ? option.label : option.name; + if (typeof value !== "string" || value.length === 0) return null; + return { + value, + label: typeof label === "string" && label.length > 0 ? label : TYPE_LABELS[value] ?? value, + }; +} + +function normalizeOptions(options) { + const normalized = []; + const seen = new Set(); + for (const option of Array.isArray(options) ? options : []) { + const item = normalizeOption(option); + if (!item || seen.has(item.value)) continue; + seen.add(item.value); + normalized.push(item); + } + return normalized; +} + +function renderSelectOptions(options, selectedValue, allLabel) { + const selected = typeof selectedValue === "string" ? selectedValue : "all"; + return [ + ``, + ...normalizeOptions(options).map( + (option) => + ``, + ), + ].join(""); +} + +function getHref(problemId, hrefByProblemId) { + let href = "#"; + if (hrefByProblemId instanceof Map) { + href = hrefByProblemId.get(problemId) ?? href; + } else if (hrefByProblemId && typeof hrefByProblemId === "object") { + const descriptor = Object.getOwnPropertyDescriptor(hrefByProblemId, problemId); + if (descriptor && "value" in descriptor) href = descriptor.value; + } + return typeof href === "string" ? href : "#"; +} + +function getTypeLabel(type, typeOptions = []) { + const match = normalizeOptions(typeOptions).find((option) => option.value === type); + return match?.label ?? TYPE_LABELS[type] ?? String(type || "유형 미지정"); +} + +function renderProblemCard(problem, index, options) { + const solvedIds = options.solvedIds; + const isSolved = solvedIds.has(problem?.id); + const tags = toStringArray(problem?.tags); + const conceptIds = toStringArray(problem?.conceptIds); + const visibleTags = tags.length > 0 ? tags : conceptIds; + const problemOrder = Math.max(1, safeInteger(problem?.order, index + 1)); + const href = getHref(problem?.id, options.hrefByProblemId); + + return ` +
  • +
    +
    + 문제 ${problemOrder} + ${escapeHtml(getDifficultyLabel(problem?.difficulty))} + ${escapeHtml(options.languageName)} + ${escapeHtml(getTypeLabel(problem?.type, options.typeOptions))} +
    +
    +
    +

    ${escapeHtml(problem?.title ?? "제목 없는 문제")}

    +

    ${escapeHtml(problem?.summary ?? "")}

    +
    + ${isSolved ? "풀이 완료" : "미풀이"} +
    + ${ + visibleTags.length > 0 + ? `
      ${visibleTags + .map((tag) => `
    • ${escapeHtml(tag)}
    • `) + .join("")}
    ` + : "" + } +

    예상 풀이 시간 ${Math.max(0, safeInteger(problem?.estimatedMinutes))}분

    +
    +
  • + `; +} + +function renderResultValue(label, display) { + return ` +
    +
    ${escapeHtml(label)}
    +
    ${escapeHtml(display)}
    +
    + `; +} + +function renderConsoleEntries(entries) { + if (!Array.isArray(entries) || entries.length === 0) return ""; + return ` +
    + console 출력 ${entries.length}개 +
      + ${entries + .map( + (entry) => + `
    1. ${escapeHtml(entry?.method ?? "log")}${escapeHtml(entry?.preview ?? "")}
    2. `, + ) + .join("")} +
    +
    + `; +} + +function getOutcomeTitle(outcome, mode) { + const submission = mode === "submit"; + const copy = { + passed: submission + ? "제출 채점을 통과했습니다." + : "실행 테스트를 모두 통과했습니다.", + wrong_answer: submission + ? "제출 채점에서 통과하지 못한 테스트가 있습니다." + : "일부 실행 테스트의 결과가 기대값과 다릅니다.", + syntax_error: "문법 오류로 테스트 실행을 멈췄습니다.", + runtime_error: "코드를 실행하는 중 오류가 발생했습니다.", + timeout: "테스트 실행 시간이 제한을 넘었습니다.", + output_limit: "코드가 출력할 수 있는 한도를 넘었습니다.", + cancelled: "테스트 실행을 취소했습니다.", + engine_error: "코드 실행기를 사용할 수 없습니다.", + not_run: "테스트가 실행되지 않았습니다.", + }; + return copy[outcome] ?? copy.engine_error; +} + +function renderTestResult(testResult, index, failureByTestId) { + const outcome = Object.hasOwn(OUTCOME_COPY, testResult?.outcome) + ? testResult.outcome + : "engine_error"; + const copy = OUTCOME_COPY[outcome]; + const errorMessage = getErrorMessage(testResult?.error); + const failureExplanation = + outcome === "wrong_answer" ? failureByTestId.get(testResult?.testId) : null; + const expectedDisplay = + typeof testResult?.expectedDisplay === "string" + ? testResult.expectedDisplay + : formatJsonValue(testResult?.expected); + const actualDisplay = + typeof testResult?.actualDisplay === "string" + ? testResult.actualDisplay + : formatJsonValue(testResult?.actual); + const duration = Math.max(0, Number(testResult?.durationMs) || 0); + + return ` +
    +
    +
    + ${copy.label} +

    ${escapeHtml(testResult?.label ?? `공개 테스트 ${index + 1}`)}

    +
    + ${duration}ms +
    +
    + ${renderResultValue("기대값", expectedDisplay)} + ${testResult?.hasActual === true ? renderResultValue("실제값", actualDisplay) : ""} +
    + ${errorMessage ? `

    실행 안내${escapeHtml(errorMessage)}

    ` : ""} + ${failureExplanation ? `

    확인할 점${escapeHtml(failureExplanation)}

    ` : ""} + ${renderConsoleEntries(testResult?.console)} +
    + `; +} + +function renderPersistenceStatus(status, mode) { + if (mode !== "submit" || !status) return ""; + const copy = + status === "failed" + ? "제출 결과는 화면에 유지되지만 시도 기록을 저장하지 못했습니다." + : status === "memory" + ? "이 제출은 현재 탭에만 저장되었습니다. 탭을 닫으면 기록이 사라질 수 있습니다." + : "이 제출 결과를 최근 코딩테스트 기록에 저장했습니다."; + const warning = status === "failed" || status === "memory"; + return `

    ${copy}

    `; +} + +function renderExecutionReport({ problem, report, mode, persistenceStatus, isRunning }) { + const runCount = toStringArray(problem?.runTestIds).length; + const submitCount = Array.isArray(problem?.publicTests) ? problem.publicTests.length : 0; + + if (isRunning) { + return `

    ${mode === "submit" ? "제출 테스트를 채점하고 있습니다…" : "실행 테스트를 확인하고 있습니다…"}

    `; + } + if (!report) { + return `

    코드를 실행하면 ${runCount}개 실행 테스트를 확인할 수 있습니다. 제출 시에는 브라우저에 포함된 공개 테스트 ${submitCount}개를 모두 채점합니다.

    `; + } + + const outcome = Object.hasOwn(OUTCOME_COPY, report?.outcome) + ? report.outcome + : "engine_error"; + const copy = OUTCOME_COPY[outcome]; + const passed = Math.max(0, safeInteger(report?.summary?.passed)); + const total = Math.max(0, safeInteger(report?.summary?.total)); + const reportError = getErrorMessage(report?.error); + const failureByTestId = new Map( + (Array.isArray(problem?.failureExplanations) ? problem.failureExplanations : []) + .filter((item) => item && typeof item.testId === "string") + .map((item) => [item.testId, item.message]), + ); + + return ` +
    +
    +
    +

    ${mode === "submit" ? "마지막 제출 채점 결과" : "마지막 실행 테스트 결과"}

    +

    ${escapeHtml(getOutcomeTitle(outcome, mode))}

    +
    + ${passed}/${total} 통과 +
    + ${reportError ? `

    실행 안내${escapeHtml(reportError)}

    ` : ""} +
    + ${(Array.isArray(report?.tests) ? report.tests : []) + .map((testResult, index) => renderTestResult(testResult, index, failureByTestId)) + .join("")} +
    + ${renderPersistenceStatus(persistenceStatus, mode)} +
    + `; +} + +function renderFunctionContract(problem) { + const contract = problem?.functionContract ?? {}; + const parameters = Array.isArray(contract.parameters) ? contract.parameters : []; + const constraints = toStringArray(contract.constraints); + const returns = contract.returns ?? {}; + + return ` +
    + +

    ${escapeHtml(problem?.entryPoint ?? "함수")} 함수 계약

    +
    + ${parameters + .map( + (parameter) => ` +
    +
    입력 ${escapeHtml(parameter?.name ?? "인수")} ${escapeHtml(parameter?.type ?? "")}
    +
    ${escapeHtml(parameter?.description ?? "")}
    +
    + `, + ) + .join("")} +
    +
    출력 ${escapeHtml(returns?.type ?? "")}
    +
    ${escapeHtml(returns?.description ?? "")}
    +
    +
    +

    제한사항

    +
      + ${constraints.map((constraint) => `
    • ${escapeHtml(constraint)}
    • `).join("")} +
    +
    +
    시간
    ${escapeHtml(contract.complexity?.time ?? "-")}
    +
    공간
    ${escapeHtml(contract.complexity?.space ?? "-")}
    +
    +
    + `; +} + +function renderExamples(examples) { + return ` +
    + +

    예제와 설명

    +
    + ${(Array.isArray(examples) ? examples : []) + .map( + (example, index) => ` +
    +

    예제 ${index + 1}

    +
    + ${renderResultValue("입력 인수", formatJsonValue(example?.args))} + ${renderResultValue("출력", formatJsonValue(example?.expected))} +
    +

    ${escapeHtml(example?.explanation ?? "")}

    +
    + `, + ) + .join("")} +
    +
    + `; +} + +export function getCodingTestDraftStatusMessage(status) { + return DRAFT_STATUS_COPY[status] ?? DRAFT_STATUS_COPY.starter; +} + +export function renderCodingTestNavigationLink({ + href, + isCurrent = false, + solvedCount = 0, + totalCount = 0, +} = {}) { + const solved = Math.max(0, safeInteger(solvedCount)); + const total = Math.max(solved, safeInteger(totalCount)); + return ` + + `; +} + +export function renderCodingTestLoadingView({ title = "코딩테스트" } = {}) { + return ` +
    +
    + +

    ${escapeHtml(title)} 문제를 준비하고 있습니다…

    +
    +
    + `; +} + +export function renderCodingTestListView({ + title = "JavaScript 코딩테스트", + problems = [], + totalCount, + filters = {}, + languageName = "JavaScript", + languageOptions = [{ value: "javascript", label: "JavaScript" }], + typeOptions = [], + solvedProblemIds = [], + hrefByProblemId = {}, +} = {}) { + const visibleProblems = Array.isArray(problems) ? problems : []; + const safeTotal = Math.max(visibleProblems.length, safeInteger(totalCount, visibleProblems.length)); + const query = typeof filters.query === "string" ? filters.query : ""; + const difficultyOptions = Object.entries(DIFFICULTY_LABELS).map(([value, label]) => ({ + value, + label, + })); + const derivedTypeOptions = + normalizeOptions(typeOptions).length > 0 + ? typeOptions + : [...new Set(visibleProblems.map((problem) => problem?.type).filter(Boolean))]; + const solvedIds = toIdSet(solvedProblemIds); + + return ` +
    +
    +
    +
    문제를 분석하고 직접 구현하기
    +

    ${escapeHtml(title)}

    +

    학습한 개념을 문제에 적용하고 실행 결과를 관찰해 보세요.

    +
    + +
    +

    코딩테스트 검색과 필터

    + +
    + + + + +
    + +
    + +
    +

    전체 ${safeTotal}문제 중 ${visibleProblems.length}문제

    +

    ${solvedIds.size}문제 풀이 완료

    +
    + + ${ + visibleProblems.length > 0 + ? `
      ${visibleProblems + .map((problem, index) => + renderProblemCard(problem, index, { + solvedIds, + hrefByProblemId, + languageName, + typeOptions: derivedTypeOptions, + }), + ) + .join("")}
    ` + : `

    조건에 맞는 문제가 없습니다.

    검색어나 필터를 바꾸거나 초기화해 보세요.

    ` + } +
    +
    + `; +} + +export function renderCodingTestView({ + languageName = "JavaScript", + collectionTitle = "JavaScript 코딩테스트", + listHref = "#/coding-tests", + problem, + source = "", + isRunning = false, + cancelRequested = false, + executionMode = null, + draftStatus = "starter", + uiError = null, + report = null, + reportPersistenceStatus = null, + isSolved = false, +} = {}) { + const mode = executionMode === "submit" ? "submit" : "run"; + const sourceIsEmpty = String(source).trim().length === 0; + const actionsDisabled = isRunning || sourceIsEmpty ? " disabled" : ""; + const editorReadonly = isRunning ? " readonly" : ""; + const typeLabel = getTypeLabel(problem?.type); + const publicTestCount = Array.isArray(problem?.publicTests) ? problem.publicTests.length : 0; + const runTestCount = toStringArray(problem?.runTestIds).length; + + return ` +
    +
    +
    + ← 문제 목록 +
    + ${escapeHtml(languageName)} + + ${escapeHtml(collectionTitle)} +
    +
    +
    +

    ${escapeHtml(getDifficultyLabel(problem?.difficulty))} · ${escapeHtml(typeLabel)} · 약 ${Math.max(0, safeInteger(problem?.estimatedMinutes))}분

    +

    ${escapeHtml(problem?.title ?? "코딩테스트 문제")}

    +
    + ${isSolved ? "풀이 완료" : "미풀이"} +
    +

    ${escapeHtml(problem?.summary ?? "")}

    +
    + +
    +
    +
    + +

    구현할 기능

    +

    ${escapeHtml(problem?.description ?? "")}

    +
    + ${renderFunctionContract(problem)} + ${renderExamples(problem?.examples)} +
    + +
    +
    +
    +
    + +

    ${escapeHtml(languageName)} 편집기

    +
    + 실행 ${runTestCount}개 · 제출 ${publicTestCount}개 +
    + + +

    실행과 제출 채점에 사용하는 모든 테스트는 이 브라우저에 포함된 공개 테스트입니다.

    +

    ${getCodingTestDraftStatusMessage(draftStatus)}

    +
    + + + ${isRunning ? `` : ''} +
    + +
    + +
    +
    + +

    실행 결과

    +
    + ${renderExecutionReport({ + problem, + report, + mode, + persistenceStatus: reportPersistenceStatus, + isRunning, + })} +
    +
    +
    +
    +
    + `; +} diff --git a/styles/app.css b/styles/app.css index 58d3352..bdf1a25 100644 --- a/styles/app.css +++ b/styles/app.css @@ -1057,6 +1057,72 @@ a.language-nav-link:hover { line-height: 1.3; } +.coding-test-nav { + padding: 18px 10px 24px; + border-top: 1px solid var(--color-border); +} + +.coding-test-nav-link { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 9px; + align-items: center; + padding: 9px 8px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--color-text-soft); + text-decoration: none; +} + +.coding-test-nav-link:hover { + background: rgba(255, 255, 255, 0.035); + color: #fff; +} + +.coding-test-nav-link.is-current { + border-color: rgba(245, 184, 61, 0.28); + background: rgba(245, 184, 61, 0.08); + color: #fff; +} + +.coding-test-nav-icon { + display: inline-grid; + width: 32px; + height: 26px; + place-items: center; + border: 1px solid rgba(245, 184, 61, 0.34); + border-radius: 8px; + background: rgba(245, 184, 61, 0.08); + color: var(--color-warning); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 800; +} + +.coding-test-nav-link span:last-child { + min-width: 0; +} + +.coding-test-nav-link strong, +.coding-test-nav-link small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.coding-test-nav-link strong { + font-size: 11px; + line-height: 1.4; +} + +.coding-test-nav-link small { + margin-top: 2px; + color: var(--color-text-muted); + font-size: 9px; + line-height: 1.3; +} + .review-link strong { font-size: 11px; line-height: 1.4; @@ -2231,155 +2297,944 @@ a.language-nav-link:hover { margin-top: 28px; } -.initial-loader, -.loading-page, -.empty-page, -.error-page { +.coding-test-state-message { display: grid; - min-height: 100vh; - min-height: 100dvh; - place-content: center; justify-items: center; - padding: 24px; - text-align: center; } -.state-message { - display: grid; - justify-items: center; +.coding-test-list-container, +.coding-test-container { + width: min(calc(100% - 48px), 1320px); + min-width: 0; + padding: 46px 0 52px; + margin: 0 auto; } -.initial-loader p, -.loading-page p { - margin: 14px 0 0; - color: var(--color-text-muted); - font-size: 13px; +.coding-test-list-header, +.coding-test-header { + padding-bottom: 28px; + border-bottom: 1px solid var(--color-border); } -.empty-page, -.error-page { - max-width: 660px; - margin: 0 auto; +.coding-test-list-header h1, +.coding-test-title-row h1 { + overflow-wrap: anywhere; + margin: 10px 0 8px; + color: #f5f8ff; + font-size: clamp(2rem, 4vw, 2.8rem); + letter-spacing: -0.045em; + line-height: 1.2; + scroll-margin-top: 84px; } -.empty-page .eyebrow, -.error-page .eyebrow { - margin: 18px 0 0; +.coding-test-list-header > p:last-child, +.coding-test-summary { + max-width: 780px; + margin: 0; + color: var(--color-text-soft); + font-size: 15px; } -.empty-page h1, -.error-page h1 { - margin: 8px 0 6px; - font-size: clamp(1.6rem, 5vw, 2.4rem); - line-height: 1.25; +.coding-test-filters { + padding: 22px; + margin-top: 24px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: rgba(15, 22, 41, 0.78); + box-shadow: var(--shadow-soft); } -.empty-page .state-message > p:not(.eyebrow), -.error-page .state-message > p:not(.eyebrow) { - margin: 0 0 20px; - color: var(--color-text-muted); +.coding-test-filters label, +.coding-test-search-label { + display: grid; + gap: 6px; + min-width: 0; + color: var(--color-text-soft); + font-size: 11px; + font-weight: 800; } -.noscript-message { - position: fixed; - z-index: 200; - right: 16px; - bottom: 16px; - left: 16px; - padding: 14px; - border: 1px solid rgba(239, 98, 98, 0.35); - border-radius: var(--radius-md); - background: #2a111a; - color: #ffd7df; - text-align: center; +.coding-test-filters input, +.coding-test-filters select { + width: 100%; + min-width: 0; + min-height: 44px; + padding: 9px 11px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: var(--color-code); + color: var(--color-text); } -@media (max-width: 820px) { - body.menu-open { - overflow: hidden; - } +.coding-test-filters input::placeholder { + color: var(--color-text-muted); +} - .mobile-header { - position: sticky; - z-index: 12; - top: 0; - display: flex; - height: 64px; - align-items: center; - justify-content: space-between; - padding: 0 16px; - border-bottom: 1px solid var(--color-border); - background: rgba(8, 12, 20, 0.9); - backdrop-filter: blur(16px); - } +.coding-test-filter-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} - .mobile-brand { - font-size: 14px; - font-weight: 800; - } +.coding-test-filter-reset { + margin-top: 14px; +} - .sidebar { - z-index: 30; - width: min(88vw, 300px); - border-right-color: var(--color-border-strong); - box-shadow: 24px 0 64px rgba(0, 0, 0, 0.45); - transform: translateX(-105%); - visibility: hidden; - transition: transform 180ms ease, visibility 0s linear 180ms; - } +.coding-test-list-summary { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + align-items: center; + justify-content: space-between; + margin: 22px 2px 12px; + color: var(--color-text-muted); + font-size: 12px; +} - .sidebar.is-open { - transform: translateX(0); - visibility: visible; - transition-delay: 0s; - } +.coding-test-list-summary p { + margin: 0; +} - .sidebar-close { - display: inline-grid; - } +.coding-test-list { + display: grid; + gap: 12px; + padding: 0; + margin: 0; + list-style: none; +} - .lesson-link strong { - font-size: 12px; - } +.coding-test-card { + min-width: 0; + padding: 20px 22px; + border: 1px solid var(--color-border); + border-left: 3px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: + linear-gradient(145deg, rgba(76, 139, 245, 0.035), transparent 48%), + rgba(15, 22, 41, 0.8); + box-shadow: var(--shadow-soft); +} - .review-link strong { - font-size: 12px; - } +.coding-test-card.is-solved { + border-left-color: var(--color-accent); +} - .quest-link strong { - font-size: 12px; - } +.coding-test-card-meta, +.coding-test-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0; + margin: 0; + list-style: none; +} - .sidebar-backdrop { - display: block; - pointer-events: none; - transition: opacity 180ms ease; - } +.coding-test-card-meta span, +.coding-test-tags li { + padding: 3px 8px; + border: 1px solid rgba(110, 163, 255, 0.2); + border-radius: 999px; + background: rgba(76, 139, 245, 0.055); + color: var(--color-primary-strong); + font-size: 9px; + font-weight: 800; +} - .sidebar-backdrop.is-visible { - pointer-events: auto; - opacity: 1; - } +.coding-test-card-heading { + display: flex; + gap: 18px; + align-items: flex-start; + justify-content: space-between; + margin-top: 12px; +} - .main-area { - margin-left: 0; - } +.coding-test-card-heading > div { + min-width: 0; +} - .lesson-container { - width: min(calc(100% - 32px), var(--content-width)); - padding-top: 34px; - } +.coding-test-card-heading h2 { + overflow-wrap: anywhere; + margin: 0; + font-size: 18px; + line-height: 1.4; +} - .review-container { - width: min(calc(100% - 32px), var(--content-width)); - padding-top: 34px; - } +.coding-test-card-heading h2 a { + color: var(--color-text); + text-decoration: none; +} - .quest-container { - width: min(calc(100% - 32px), var(--content-width)); +.coding-test-card-heading h2 a:hover { + color: var(--color-primary-strong); + text-decoration: underline; +} + +.coding-test-card-heading p, +.coding-test-card-time { + overflow-wrap: anywhere; + margin: 6px 0 0; + color: var(--color-text-muted); + font-size: 12px; +} + +.coding-test-status, +.coding-test-solved-badge { + flex: 0 0 auto; + padding: 5px 9px; + border: 1px solid var(--color-border-strong); + border-radius: 999px; + color: var(--color-text-muted); + font-size: 10px; + font-weight: 800; +} + +.coding-test-status.is-solved, +.coding-test-solved-badge.is-solved { + border-color: rgba(0, 207, 170, 0.34); + background: rgba(0, 207, 170, 0.08); + color: var(--color-accent); +} + +.coding-test-tags { + margin-top: 12px; +} + +.coding-test-tags li { + border-color: var(--color-border); + background: rgba(255, 255, 255, 0.025); + color: var(--color-text-soft); +} + +.coding-test-empty { + padding: 36px 24px; + border: 1px dashed var(--color-border-strong); + border-radius: var(--radius-lg); + text-align: center; +} + +.coding-test-empty h2 { + margin: 0; + color: var(--color-text); + font-size: 19px; +} + +.coding-test-empty p { + margin: 7px 0 0; + color: var(--color-text-muted); + font-size: 13px; +} + +.coding-test-back-link { + display: inline-block; + margin-bottom: 18px; + color: var(--color-text-soft); + font-size: 12px; + font-weight: 800; + text-decoration: none; +} + +.coding-test-back-link:hover { + color: #fff; +} + +.coding-test-title-row { + display: flex; + gap: 18px; + align-items: flex-start; + justify-content: space-between; + margin-top: 10px; +} + +.coding-test-title-row > div { + min-width: 0; +} + +.coding-test-title-row p { + margin: 0; + color: var(--color-text-muted); + font-size: 11px; + font-weight: 700; +} + +.coding-test-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 20px; + align-items: start; + margin-top: 24px; +} + +.coding-test-problem-panel, +.coding-test-editor-panel, +.coding-test-results-panel { + min-width: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: + linear-gradient(145deg, rgba(76, 139, 245, 0.03), transparent 46%), + rgba(15, 22, 41, 0.82); + box-shadow: var(--shadow-soft); +} + +.coding-test-problem-panel { + overflow: hidden; +} + +.coding-test-run-column { + display: grid; + min-width: 0; + gap: 20px; +} + +.coding-test-section, +.coding-test-editor-panel, +.coding-test-results-panel { + min-width: 0; + padding: clamp(20px, 2.5vw, 28px); +} + +.coding-test-section + .coding-test-section { + border-top: 1px solid var(--color-border); +} + +.coding-test-section-label, +.coding-test-result-mode { + margin: 0; + color: var(--color-primary-strong); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.coding-test-section h2, +.coding-test-editor-panel h2, +.coding-test-results-panel h2 { + overflow-wrap: anywhere; + margin: 5px 0 12px; + color: var(--color-text); + font-size: 20px; + line-height: 1.35; +} + +.coding-test-section h3 { + margin: 22px 0 9px; + color: var(--color-text); + font-size: 14px; +} + +.coding-test-description, +.coding-test-contract-list dd, +.coding-test-constraints, +.coding-test-examples p { + overflow-wrap: anywhere; + color: var(--color-text-soft); + font-size: 13px; + line-height: 1.7; +} + +.coding-test-contract-list { + display: grid; + gap: 10px; + margin: 0; +} + +.coding-test-contract-list > div { + padding: 12px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: rgba(8, 12, 20, 0.44); +} + +.coding-test-contract-list dt { + color: var(--color-text); + font-size: 12px; + font-weight: 800; +} + +.coding-test-contract-list dt span { + display: inline-block; + padding: 2px 6px; + margin-left: 5px; + border-radius: 5px; + background: rgba(76, 139, 245, 0.09); + color: var(--color-primary-strong); + font-family: var(--font-mono); + font-size: 9px; +} + +.coding-test-contract-list dd { + margin: 4px 0 0; +} + +.coding-test-constraints { + display: grid; + gap: 6px; + padding-left: 20px; + margin: 0; +} + +.coding-test-constraints li::marker { + color: var(--color-primary-strong); +} + +.coding-test-complexity { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 18px 0 0; +} + +.coding-test-complexity > div { + min-width: 0; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: rgba(0, 207, 170, 0.055); +} + +.coding-test-complexity dt { + color: var(--color-text-muted); + font-size: 9px; + font-weight: 800; + text-transform: uppercase; +} + +.coding-test-complexity dd { + overflow-wrap: anywhere; + margin: 3px 0 0; +} + +.coding-test-complexity code, +.coding-test-contract-list code, +.coding-test-section h2 code { + color: #d8e5ff; + font-family: var(--font-mono); + font-size: 0.9em; +} + +.coding-test-examples { + display: grid; + gap: 12px; +} + +.coding-test-examples > article { + min-width: 0; + padding: 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: rgba(8, 12, 20, 0.44); +} + +.coding-test-examples h3, +.coding-test-examples p { + margin: 0; +} + +.coding-test-examples p { + margin-top: 11px; +} + +.coding-test-editor-panel > header, +.coding-test-report > header, +.coding-test-case > header { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + align-items: flex-start; + justify-content: space-between; +} + +.coding-test-editor-panel > header > span { + padding: 5px 9px; + border: 1px solid rgba(110, 163, 255, 0.22); + border-radius: 999px; + background: rgba(76, 139, 245, 0.06); + color: var(--color-primary-strong); + font-size: 10px; + font-weight: 800; +} + +.coding-test-editor-label { + display: block; + margin: 18px 0 8px; + color: var(--color-text-soft); + font-size: 12px; + font-weight: 800; +} + +#coding-test-source { + display: block; + width: 100%; + max-width: 100%; + min-height: 420px; + resize: vertical; + overflow: auto; + padding: 16px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-code); + color: #d6e1f4; + caret-color: var(--color-accent); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.65; + tab-size: 2; + white-space: pre; +} + +#coding-test-source:hover { + border-color: rgba(110, 163, 255, 0.42); +} + +#coding-test-source:focus { + border-color: var(--color-primary-strong); +} + +#coding-test-source[readonly] { + cursor: wait; + opacity: 0.72; +} + +.coding-test-editor-help, +.coding-test-draft-status, +.coding-test-results-state, +.coding-test-card-time { + overflow-wrap: anywhere; + color: var(--color-text-muted); + font-size: 10px; + line-height: 1.5; +} + +.coding-test-editor-help, +.coding-test-draft-status, +.coding-test-results-state { + margin: 8px 0 0; +} + +.coding-test-draft-status { + color: #bde8de; +} + +.coding-test-draft-status.is-warning { + color: #f2d695; +} + +.coding-test-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.coding-test-actions .button { + flex: 1 1 150px; +} + +.coding-test-inline-error { + padding: 11px 13px; + margin-top: 14px; + border: 1px solid rgba(239, 98, 98, 0.34); + border-radius: var(--radius-sm); + background: rgba(239, 98, 98, 0.07); + color: #ffc0c0; + font-size: 12px; +} + +.coding-test-inline-error:empty { + display: none; +} + +.coding-test-results-heading { + padding-bottom: 14px; + border-bottom: 1px solid var(--color-border); +} + +.coding-test-report { + min-width: 0; + margin-top: 18px; +} + +.coding-test-report > header > div, +.coding-test-case > header > div { + min-width: 0; +} + +.coding-test-report > header h3 { + overflow-wrap: anywhere; + margin: 4px 0 0; + color: var(--color-text); + font-size: 18px; + line-height: 1.4; +} + +.coding-test-report > header > strong { + flex: 0 0 auto; + padding: 6px 9px; + border-radius: 999px; + background: rgba(76, 139, 245, 0.08); + color: var(--color-primary-strong); + font-size: 11px; +} + +.coding-test-report.is-success > header h3 { + color: var(--color-accent); +} + +.coding-test-report.is-danger > header h3 { + color: #ff9f9f; +} + +.coding-test-report.is-warning > header h3 { + color: var(--color-warning); +} + +.coding-test-case-list { + display: grid; + gap: 12px; + margin-top: 16px; +} + +.coding-test-case { + min-width: 0; + padding: 14px; + border: 1px solid var(--color-border); + border-left-width: 3px; + border-radius: var(--radius-md); + background: rgba(8, 12, 20, 0.48); +} + +.coding-test-case.is-success { + border-left-color: var(--color-accent); +} + +.coding-test-case.is-danger { + border-left-color: var(--color-danger); +} + +.coding-test-case.is-warning { + border-left-color: var(--color-warning); +} + +.coding-test-case.is-muted { + border-left-color: var(--color-text-muted); +} + +.coding-test-case > header h4 { + overflow-wrap: anywhere; + margin: 4px 0 0; + color: var(--color-text); + font-size: 13px; +} + +.coding-test-case > header > span { + flex: 0 0 auto; + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: 9px; +} + +.coding-test-outcome { + color: var(--color-text-muted); + font-size: 9px; + font-weight: 800; +} + +.coding-test-case.is-success .coding-test-outcome { + color: var(--color-accent); +} + +.coding-test-case.is-danger .coding-test-outcome { + color: #ff9f9f; +} + +.coding-test-case.is-warning .coding-test-outcome { + color: var(--color-warning); +} + +.coding-test-result-values { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + min-width: 0; + margin: 12px 0 0; +} + +.coding-test-result-value, +.coding-test-result-value dd { + min-width: 0; + margin: 0; +} + +.coding-test-result-value dt { + margin-bottom: 4px; + color: var(--color-text-muted); + font-size: 9px; + font-weight: 800; +} + +.coding-test-result-value pre { + max-width: 100%; + max-height: 220px; + overflow: auto; + padding: 10px; + margin: 0; + border-radius: 8px; + background: var(--color-code); +} + +.coding-test-result-value code { + display: block; + width: max-content; + min-width: 100%; + color: #d4def0; + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.5; +} + +.coding-test-report-error, +.coding-test-test-error, +.coding-test-failure-explanation, +.coding-test-persistence { + overflow-wrap: anywhere; + padding: 10px 12px; + margin: 12px 0 0; + border-radius: var(--radius-sm); + color: var(--color-text-soft); + font-size: 11px; +} + +.coding-test-report-error, +.coding-test-test-error { + background: rgba(239, 98, 98, 0.07); + color: #ffc0c0; +} + +.coding-test-failure-explanation { + background: rgba(245, 184, 61, 0.07); + color: #f2d695; +} + +.coding-test-report-error strong, +.coding-test-test-error strong, +.coding-test-failure-explanation strong { + display: block; + margin-bottom: 2px; + color: var(--color-text); +} + +.coding-test-persistence { + background: rgba(0, 207, 170, 0.07); + color: #bde8de; +} + +.coding-test-persistence.is-warning { + border: 1px solid rgba(245, 184, 61, 0.24); + background: rgba(245, 184, 61, 0.07); + color: #f2d695; +} + +.coding-test-console { + margin-top: 12px; + color: var(--color-text-muted); + font-size: 11px; +} + +.coding-test-console summary { + cursor: pointer; + color: var(--color-text-soft); + font-weight: 700; +} + +.coding-test-console ol { + display: grid; + gap: 6px; + padding-left: 22px; +} + +.coding-test-console strong, +.coding-test-console code { + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 10px; +} + +.coding-test-console strong { + margin-right: 8px; + color: var(--color-primary-strong); +} + +@media (min-width: 1120px) { + .coding-test-workspace { + grid-template-columns: minmax(320px, 0.9fr) minmax(420px, 1.1fr); + gap: 24px; + } +} + +.initial-loader, +.loading-page, +.empty-page, +.error-page { + display: grid; + min-height: 100vh; + min-height: 100dvh; + place-content: center; + justify-items: center; + padding: 24px; + text-align: center; +} + +.state-message { + display: grid; + justify-items: center; +} + +.initial-loader p, +.loading-page p { + margin: 14px 0 0; + color: var(--color-text-muted); + font-size: 13px; +} + +.empty-page, +.error-page { + max-width: 660px; + margin: 0 auto; +} + +.empty-page .eyebrow, +.error-page .eyebrow { + margin: 18px 0 0; +} + +.empty-page h1, +.error-page h1 { + margin: 8px 0 6px; + font-size: clamp(1.6rem, 5vw, 2.4rem); + line-height: 1.25; +} + +.empty-page .state-message > p:not(.eyebrow), +.error-page .state-message > p:not(.eyebrow) { + margin: 0 0 20px; + color: var(--color-text-muted); +} + +.noscript-message { + position: fixed; + z-index: 200; + right: 16px; + bottom: 16px; + left: 16px; + padding: 14px; + border: 1px solid rgba(239, 98, 98, 0.35); + border-radius: var(--radius-md); + background: #2a111a; + color: #ffd7df; + text-align: center; +} + +@media (max-width: 820px) { + body.menu-open { + overflow: hidden; + } + + .mobile-header { + position: sticky; + z-index: 12; + top: 0; + display: flex; + height: 64px; + align-items: center; + justify-content: space-between; + padding: 0 16px; + border-bottom: 1px solid var(--color-border); + background: rgba(8, 12, 20, 0.9); + backdrop-filter: blur(16px); + } + + .mobile-brand { + font-size: 14px; + font-weight: 800; + } + + .sidebar { + z-index: 30; + width: min(88vw, 300px); + border-right-color: var(--color-border-strong); + box-shadow: 24px 0 64px rgba(0, 0, 0, 0.45); + transform: translateX(-105%); + visibility: hidden; + transition: transform 180ms ease, visibility 0s linear 180ms; + } + + .sidebar.is-open { + transform: translateX(0); + visibility: visible; + transition-delay: 0s; + } + + .sidebar-close { + display: inline-grid; + } + + .lesson-link strong { + font-size: 12px; + } + + .review-link strong { + font-size: 12px; + } + + .quest-link strong { + font-size: 12px; + } + + .coding-test-nav-link strong { + font-size: 12px; + } + + .sidebar-backdrop { + display: block; + pointer-events: none; + transition: opacity 180ms ease; + } + + .sidebar-backdrop.is-visible { + pointer-events: auto; + opacity: 1; + } + + .main-area { + margin-left: 0; + } + + .lesson-container { + width: min(calc(100% - 32px), var(--content-width)); + padding-top: 34px; + } + + .review-container { + width: min(calc(100% - 32px), var(--content-width)); + padding-top: 34px; + } + + .quest-container { + width: min(calc(100% - 32px), var(--content-width)); + padding-top: 34px; + } + + .coding-test-list-container, + .coding-test-container { + width: min(calc(100% - 32px), var(--content-width)); padding-top: 34px; } + .coding-test-filter-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .quest-workspace { grid-template-columns: minmax(0, 1fr); } @@ -2565,6 +3420,59 @@ a.language-nav-link:hover { .quest-result-values { grid-template-columns: minmax(0, 1fr); } + + .coding-test-list-container, + .coding-test-container { + width: calc(100% - 24px); + padding-top: 26px; + } + + .coding-test-list-header h1, + .coding-test-title-row h1 { + font-size: 1.9rem; + } + + .coding-test-filters, + .coding-test-card, + .coding-test-section, + .coding-test-editor-panel, + .coding-test-results-panel { + padding: 18px; + } + + .coding-test-filter-grid { + grid-template-columns: minmax(0, 1fr); + } + + .coding-test-title-row, + .coding-test-card-heading { + align-items: stretch; + flex-direction: column; + } + + .coding-test-status, + .coding-test-solved-badge { + align-self: flex-start; + } + + #coding-test-source { + min-height: 340px; + padding: 13px; + font-size: 12px; + } + + .coding-test-actions { + align-items: stretch; + flex-direction: column; + } + + .coding-test-actions .button { + width: 100%; + } + + .coding-test-result-values { + grid-template-columns: minmax(0, 1fr); + } } @media (max-width: 360px) { @@ -2597,6 +3505,23 @@ a.language-nav-link:hover { .quest-complexity { grid-template-columns: minmax(0, 1fr); } + + .coding-test-list-container, + .coding-test-container { + width: calc(100% - 16px); + } + + .coding-test-filters, + .coding-test-card, + .coding-test-section, + .coding-test-editor-panel, + .coding-test-results-panel { + padding: 15px; + } + + .coding-test-complexity { + grid-template-columns: minmax(0, 1fr); + } } @media (prefers-reduced-motion: reduce) { diff --git a/tests/accessibility.test.js b/tests/accessibility.test.js index daa9799..6cfcf0c 100644 --- a/tests/accessibility.test.js +++ b/tests/accessibility.test.js @@ -6,6 +6,7 @@ import { renderCodeQuestNavigationLink, renderCodeQuestView, } from "../src/ui/code-quest-view.js"; +import { renderCodingTestNavigationLink } from "../src/ui/coding-test-view.js"; function relativeLuminance(hex) { const channels = hex @@ -63,7 +64,7 @@ test("교안 로딩·빈 결과·오류 상태도 같은 본문 바로가기 대 assert.doesNotMatch(appSource, /aria-busy=/); }); -test("Phase 3 학습·객관식·Code Quest 내비게이션을 실제 링크로 노출한다", async () => { +test("Phase 4 학습·복습·Quest·코딩테스트 내비게이션을 실제 링크로 노출한다", async () => { const appSource = await readFile(new URL("../src/app.js", import.meta.url), "utf8"); const quizViewSource = await readFile( new URL("../src/ui/quiz-view.js", import.meta.url), @@ -73,6 +74,10 @@ test("Phase 3 학습·객관식·Code Quest 내비게이션을 실제 링크로 new URL("../src/ui/code-quest-view.js", import.meta.url), "utf8", ); + const codingTestViewSource = await readFile( + new URL("../src/ui/coding-test-view.js", import.meta.url), + "utf8", + ); const reviewNavigation = renderReviewNavigationLink({ href: "#/review/javascript", isCurrent: true, @@ -83,11 +88,21 @@ test("Phase 3 학습·객관식·Code Quest 내비게이션을 실제 링크로 completedCount: 2, totalCount: 5, }); + const codingTestNavigation = renderCodingTestNavigationLink({ + href: "#/coding-tests", + isCurrent: true, + solvedCount: 1, + totalCount: 6, + }); - assert.equal((appSource.match(/\$\{renderReviewNavigationLink\(\{/g) ?? []).length, 3); + assert.equal((appSource.match(/\$\{renderReviewNavigationLink\(\{/g) ?? []).length, 4); assert.equal( (appSource.match(/renderCodeQuestNavigationLink\(\{/g) ?? []).length, - 3, + 4, + ); + assert.equal( + (appSource.match(/renderCodingTestNavigationLink\(\{/g) ?? []).length, + 4, ); assert.match(reviewNavigation, /