diff --git a/apps/server/src/content.ts b/apps/server/src/content.ts index 0376b89..ad66532 100644 --- a/apps/server/src/content.ts +++ b/apps/server/src/content.ts @@ -1,3 +1,4 @@ +import { seedFocusedConcepts } from "./seed-focused-concepts.js"; import type { DB } from "./db.js"; import { seedCurriculum } from "./curriculum.js"; import { seedPractice } from "./practice.js"; @@ -268,5 +269,6 @@ export async function seed(db: DB) { } await seedPractice(tx); await seedExtendedPractice(tx); + await seedFocusedConcepts(tx); }); } diff --git a/apps/server/src/event-catalog.ts b/apps/server/src/event-catalog.ts index 69a4073..999096c 100644 --- a/apps/server/src/event-catalog.ts +++ b/apps/server/src/event-catalog.ts @@ -14,6 +14,8 @@ const screen = z.enum([ "history", "support", "admin", + "plans", + "checkout", ]); export const eventProperties = { USER_REGISTERED: z.object({ client: z.enum(["web", "android"]) }), @@ -51,6 +53,10 @@ export const eventProperties = { CONTENT_PUBLISHED: z.object({ topics: small, problems: small }), SCREEN_VIEWED: z.object({ screen, topic: code.optional() }), EXAMPLE_SELECTED: z.object({ topic: code, index: small }), + EXAMPLE_MODE_SELECTED: z.object({ + topic: code, + mode: z.enum(["animation", "manual"]), + }), EXAMPLE_APPLIED: z.object({ topic: code, count: small, @@ -103,6 +109,7 @@ export type BusinessEventType = keyof typeof eventProperties; export const clientEventTypes = [ "SCREEN_VIEWED", "EXAMPLE_SELECTED", + "EXAMPLE_MODE_SELECTED", "EXAMPLE_APPLIED", "VISUALIZATION_CONTROL", "PREDICTION_CHECKED", diff --git a/apps/server/src/focused-concepts.ts b/apps/server/src/focused-concepts.ts new file mode 100644 index 0000000..6ce09b0 --- /dev/null +++ b/apps/server/src/focused-concepts.ts @@ -0,0 +1,166 @@ +/** Focused lessons share the parent algorithm's executable demonstration and coding exercise. + * The worked cases below are separate, explicitly labelled illustrations of each principle. */ +const source = ` +array|index|0부터 시작하는 인덱스|배열의 첫 원소 위치는 0이고 마지막 위치는 길이에서 1을 뺀 값입니다.|월·화·수 기온 [18,21,19]에서 화요일은 a[1]=21입니다. a[3]은 존재하지 않습니다. +array|update|배열의 원소 갱신|위치를 알고 있으면 다른 원소를 이동하지 않고 해당 칸의 값만 바꿉니다.|점수 [70,80,90]에서 두 번째 점수를 85로 정정하면 [70,85,90]이며 인덱스는 그대로입니다. +array|traverse|배열 순회와 누적|인덱스를 하나씩 증가시키며 각 원소를 정확히 한 번 처리합니다.|매출 [3,5,2]를 읽으며 합은 0→3→8→10으로 변합니다. 반복 종료 후 총매출은 10입니다. +linked-list|head|연결 리스트의 head|head는 첫 노드의 참조이며 빈 리스트에서는 null입니다.|작업 목록 A→B 앞에 긴급 작업 X를 넣을 때 X.next=A를 먼저 설정하고 head=X로 바꾸면 X→A→B입니다. +linked-list|splice|연결 리스트 중간 삽입|새 노드가 뒤 노드를 가리키게 한 뒤 앞 노드의 next를 새 노드로 바꿉니다.|A→C 사이에 B를 삽입할 때 B.next=C, A.next=B 순으로 연결하면 C를 잃지 않고 A→B→C가 됩니다. +linked-list|delete|연결 리스트 노드 삭제|삭제할 노드의 이전 노드를 알면 next를 한 칸 건너 연결합니다.|A→B→C에서 B를 제거하려면 A.next=C로 바꿉니다. B를 찾는 비용과 연결 변경 비용은 구분해야 합니다. +stack|push-pop|스택의 push와 pop|끝에 넣고 같은 끝에서 꺼내므로 마지막으로 넣은 항목이 먼저 나옵니다.|편집 작업 A, B, C를 저장하고 되돌리기를 두 번 누르면 C와 B가 제거되어 [A]가 남습니다. +stack|parentheses|스택으로 괄호 검사|여는 괄호는 저장하고 닫는 괄호는 가장 최근 여는 괄호와 짝을 맞춥니다.|문자열 (())를 읽으면 스택 크기는 0→1→2→1→0입니다. )(는 첫 문자에서 비어 있는 스택을 꺼내려 하므로 실패합니다. +stack|underflow|스택의 빈 상태 처리|pop 전에 비었는지 확인해야 아직 없는 원소를 읽지 않습니다.|뒤로 가기 기록이 []일 때 pop은 실행하지 않습니다. [홈,검색]에서는 검색을 꺼낸 뒤 [홈]이 남습니다. +queue|fifo|큐의 선입선출|앞에서 꺼내고 뒤에 넣어 도착 순서를 유지하므로 먼저 도착한 작업을 먼저 처리합니다.|접수 순서 [A,B,C]에서 A를 처리하고 D가 도착하면 대기열은 [B,C,D]입니다. +queue|circular|원형 큐의 인덱스|배열 끝 다음 위치를 나머지 연산으로 처음에 연결합니다.|크기 4인 큐에서 다음 삽입 위치가 3이면 삽입 후 위치는 (3+1)%4=0입니다. 가득 참은 별도 개수로 판별할 수 있습니다. +queue|deque|덱의 양방향 연산|덱은 양쪽 끝 삽입과 삭제를 지원하며 큐와 스택 동작을 모두 표현합니다.|[B,C]에 앞쪽 A, 뒤쪽 D를 넣으면 [A,B,C,D]입니다. 뒤에서 꺼내면 D가 나옵니다. +hash|frequency|해시 맵으로 빈도 세기|키마다 출현 횟수를 누적하여 같은 항목을 묶고, 처음 등장한 키의 횟수는 1로 저장합니다.|주문 [차,물,차]를 처리하면 {차:1}→{차:1,물:1}→{차:2,물:1}이 됩니다. +hash|collision|해시 충돌과 키 비교|서로 다른 키가 같은 버킷을 쓸 수 있으므로 원래 키를 비교해야 합니다.|h(x)=x%5일 때 고객 7과 12는 모두 버킷 2입니다. 버킷 안에서 키 12를 비교해야 고객 7의 정보를 잘못 반환하지 않습니다. +hash|set|해시 집합으로 중복 제거|집합은 키의 존재 여부를 저장하며 반복해서 넣어도 항목 수가 늘지 않습니다.|방문 ID [4,2,4,7]을 집합에 넣으면 {2,4,7}이 되어 고유 방문자는 3명입니다. 순서는 별도 보장이 필요합니다. +heap|sift-up|힙 삽입과 위로 올리기|새 값을 끝에 넣고 부모보다 작으면 부모와 교환합니다.|최소 힙 [2,5,4]에 1을 넣으면 [2,5,4,1]→[2,1,4,5]→[1,2,4,5]입니다. +heap|sift-down|힙 삭제와 아래로 내리기|루트를 제거한 자리에 마지막 값을 놓고 더 작은 자식과 교환합니다.|[1,3,2,7]에서 1을 꺼내면 [7,3,2]가 되고 2와 교환해 [2,3,7]이 됩니다. +heap|top-k|힙으로 상위 K개 유지|큰 값 K개만 필요하면 크기 K의 최소 힙에서 작은 후보를 버립니다.|점수 [5,1,9,7]에서 상위 2개를 유지하면 {1,5}→{5,9}→{7,9}가 되어 결과는 7과 9입니다. +union-find|representative|서로소 집합의 대표|같은 대표를 갖는 원소들은 같은 집합에 속하며, 부모 참조를 루트까지 따라가 대표를 찾습니다.|친구 관계 0–1을 합치면 find(0)=find(1)입니다. 아직 연결하지 않은 2는 다른 대표이므로 0과 같은 모임이 아닙니다. +union-find|compression|경로 압축|find로 만난 노드들을 루트에 직접 연결하여 이후 탐색을 줄입니다.|부모 연결 3→2→1→0에서 find(3)을 하면 3,2,1의 부모를 0으로 바꿀 수 있습니다. +union-find|size|크기 기준 집합 합치기|작은 집합의 루트를 큰 집합 루트 밑에 붙여 깊이 증가를 억제합니다.|크기 2인 집합 {0,1}과 크기 3인 {2,3,4}를 합치면 첫 집합 루트를 두 번째 루트에 붙이고 크기를 5로 갱신합니다. +trie|prefix|트라이의 접두사 공유|같은 앞부분을 가진 문자열은 같은 경로를 함께 사용합니다.|car와 cat을 넣으면 c→a까지 공유하고 r과 t로 갈라집니다. ca로 시작하는 후보는 두 단어입니다. +trie|terminal|트라이의 단어 끝 표시|경로가 존재하는 것과 완성된 단어로 등록된 것은 다릅니다.|apple만 등록하면 app 경로는 있어도 app 자체는 단어가 아닙니다. app를 추가하면 p 노드의 종료 표시를 켭니다. +trie|lookup|트라이 검색 실패 조건|문자에 해당하는 자식이 없으면 그 시점에서 검색을 종료합니다.|cat만 있는 사전에서 car를 찾으면 c와 a는 통과하지만 r 자식이 없어서 실패합니다. +linear-search|first|첫 일치 위치 찾기|앞에서부터 검사하다 첫 일치를 찾으면 즉시 종료합니다.|상품 번호 [8,3,8]에서 8을 찾으면 인덱스 0을 반환하며 뒤의 8까지 검사할 필요가 없습니다. +linear-search|missing|탐색 실패와 센티널 값|끝까지 일치하지 않을 때 정상 인덱스와 겹치지 않는 값을 반환합니다.|[2,4,6]에서 5를 찾으면 세 칸을 모두 검사한 뒤 -1을 반환합니다. 0은 첫 칸이므로 실패 표시로 쓰면 안 됩니다. +linear-search|max|순회 중 최댓값 유지|지금까지 본 값의 최댓값을 보관하고 더 큰 값을 만나면 갱신합니다.|기록 [-4,-2,-7]의 초기 최댓값을 첫 값 -4로 잡으면 -2가 됩니다. 0으로 초기화하면 존재하지 않는 답이 됩니다. +binary-search|bounds|이진 탐색의 닫힌 구간|left와 right를 모두 포함하는 구간에서는 left≤right일 동안 검사합니다.|[2,5,8]에서 8을 찾으면 mid=1 이후 left=2가 됩니다. left=right=2에서도 마지막 한 칸을 검사해야 합니다. +binary-search|lower-bound|lower bound 경계 탐색|정렬된 배열에서 목표 이상인 값이 처음 나오는 위치를 찾으며, 그런 값이 없으면 배열 길이를 반환합니다.|정렬 점수 [2,5,5,9]에서 5의 lower bound는 1입니다. 값 6의 lower bound는 3입니다. +binary-search|upper-bound|upper bound와 중복 개수|목표보다 큰 첫 위치와 목표 이상 첫 위치의 차이가 출현 횟수입니다.|[2,5,5,9]에서 5의 upper bound는 3, lower bound는 1이므로 개수는 3−1=2입니다. +brute-force|pairs|모든 쌍 열거|i보다 큰 j만 검사하면 순서 없는 쌍을 중복 없이 생성합니다.|세 사람 A,B,C의 악수 쌍은 AB,AC,BC로 3개입니다. BA를 다시 세지 않습니다. +brute-force|subsets|비트 마스크로 부분집합|각 비트는 해당 원소를 고르는지 나타내며 n개 원소에는 2의 n제곱 개 부분집합이 있습니다.|물품 [A,B,C]의 마스크 101은 A,C를 선택합니다. 000은 빈 선택이며 전체 후보는 8개입니다. +brute-force|oracle|완전 탐색으로 정답 검증|작은 입력의 모든 후보를 검사한 결과를 빠른 알고리즘과 비교합니다.|[1,4,6]에서 합이 5인 쌍은 (1,4)뿐입니다. 최적화 풀이가 이를 놓치면 작은 반례로 오류를 재현할 수 있습니다. +backtracking|undo|선택과 되돌리기|한 선택의 탐색이 끝나면 공유 상태를 원래대로 복구해야 다른 가지를 올바르게 탐색합니다.|[A] 뒤에 B를 골라 [A,B]를 검사한 다음 B를 제거해야 [A,C]를 만들 수 있습니다. +backtracking|pruning|조건 위반 가지치기|현재 상태에서 어떤 선택을 더해도 유효해질 수 없으면 그 가지를 중단합니다.|양수 [2,4,7]로 합 5를 만들 때 부분합이 6이면 더 탐색하지 않습니다. 음수가 있다면 이 가지치기는 안전하지 않습니다. +backtracking|used|순열의 사용 여부 배열|같은 위치의 원소를 한 경로에서 두 번 고르지 않도록 표시합니다.|[1,2,3]에서 1을 골랐다면 다음 자리는 2 또는 3입니다. 재귀에서 돌아오면 1의 사용 표시를 해제합니다. +two-pointer|ends|양끝 포인터로 두 수 합|정렬 배열에서 합이 작으면 왼쪽, 크면 오른쪽 포인터를 안쪽으로 움직입니다.|[1,3,5,8]에서 합 9는 첫 비교 1+8=9로 찾습니다. 합 8을 찾으면 8을 제외한 뒤 3+5를 찾습니다. +two-pointer|compact|읽기·쓰기 포인터|읽기 위치는 모든 원소를 보고 쓰기 위치는 남길 원소가 들어갈 다음 칸을 가리킵니다.|[1,1,2,2,3]의 중복을 제거하면 앞의 세 칸은 [1,2,3]입니다. 뒤의 값은 결과에 포함하지 않습니다. +two-pointer|merge|두 정렬 배열 합치기|각 배열의 아직 쓰지 않은 가장 작은 값 중 더 작은 것을 선택합니다.|[1,4]와 [2,3]을 합치면 1→2→3→4 순으로 출력하며 어느 배열이 끝나면 나머지를 복사합니다. +sliding-window|fixed|고정 길이 구간 합|이전 합에서 나가는 값을 빼고 들어오는 값을 더합니다.|매출 [2,5,1,4]의 길이 2 합은 7→6→5입니다. 두 번째 합은 7−2+1=6으로 구합니다. +sliding-window|variable|가변 길이 구간|조건이 깨질 때 왼쪽을 이동하며 유효한 구간을 유지합니다.|양수 [2,1,3]에서 합이 3 이하인 구간을 유지하면 3을 넣은 뒤 2와 1을 빼서 [3]이 됩니다. +sliding-window|counts|구간 안의 빈도 관리|원소가 들어오면 빈도를 늘리고 나가면 줄여 구간의 종류 수를 갱신합니다.|문자열 abac의 길이 3 구간은 aba→bac입니다. a 빈도는 2→1, c는 0→1이 되어 종류 수는 2→3입니다. +sorting|stable|정렬의 안정성|키가 같은 항목들의 원래 상대 순서를 유지하는 성질입니다.|입력 [(2,A),(1,B),(2,C)]를 키로 안정 정렬하면 [(1,B),(2,A),(2,C)]로 A가 C보다 앞입니다. +sorting|comparator|정렬 비교 기준|비교 함수는 일관된 순서를 정의해야 하며 여러 키를 순서대로 비교할 수 있습니다.|점수 내림차순, 이름 오름차순이면 (90,가), (90,나), (80,다) 순입니다. 동점일 때만 이름을 비교합니다. +sorting|deduplicate|정렬 후 중복 묶기|정렬하면 같은 값이 이웃하므로 이전 값과 비교해 서로 다른 값만 남깁니다.|[3,1,3,2]→[1,2,3,3]에서 첫 값과 달라질 때만 기록하면 [1,2,3]입니다. +bubble-sort|pass|버블 정렬 한 번의 순회|인접한 두 값을 비교·교환하면 가장 큰 값이 오른쪽 끝으로 이동합니다.|[4,1,3,2]는 한 번의 순회에서 [1,3,2,4]가 됩니다. 마지막 4는 최종 위치입니다. +bubble-sort|early-exit|버블 정렬 조기 종료|한 순회 동안 교환이 없으면 이미 정렬되어 있으므로 종료할 수 있습니다.|[1,2,3]에서는 두 번 비교해도 교환이 없습니다. 다음 순회를 시작하지 않아도 됩니다. +bubble-sort|range|확정된 정렬 구간 제외|오른쪽에 확정된 원소는 이후 순회에서 다시 비교하지 않습니다.|[3,2,1]의 첫 순회 후 [2,1,3]이므로 다음에는 앞의 두 칸만 비교해 [1,2,3]을 만듭니다. +selection-sort|minimum|남은 구간 최솟값 선택|각 단계에서 아직 정렬하지 않은 구간의 최솟값 위치를 찾습니다.|[7,2,5,1]에서 최솟값 인덱스는 3입니다. 첫 값과 바꾸면 [1,2,5,7]이 됩니다. +selection-sort|swaps|선택 정렬의 교환 횟수|최솟값을 찾는 동안 비교만 하고 한 단계가 끝나면 한 번 교환합니다.|[4,3,2,1]의 첫 단계는 세 번 비교하고 4와 1을 한 번 교환합니다. 비교 수와 교환 수는 다릅니다. +selection-sort|instability|선택 정렬과 동점 순서|멀리 있는 값과 교환하면 같은 키의 원래 순서가 바뀔 수 있습니다.|[(2,A),(2,B),(1,C)]에서 첫 값과 마지막 값을 교환하면 [(1,C),(2,B),(2,A)]로 동점 A,B 순서가 뒤집힙니다. +insertion-sort|prefix|정렬된 앞 구간 유지|현재 원소 앞의 구간은 이미 정렬되었다는 불변식을 유지합니다.|[2,5,3]에서 앞의 [2,5]는 정렬 상태입니다. 3을 둘 사이에 넣으면 [2,3,5]입니다. +insertion-sort|shift|삽입 위치까지 밀기|현재 값보다 큰 앞 원소들을 오른쪽으로 옮긴 뒤 빈 칸에 현재 값을 넣습니다.|[2,6,8,3]에서 8과 6을 한 칸씩 밀고 3을 넣으면 [2,3,6,8]입니다. +insertion-sort|nearly|거의 정렬된 입력|이미 순서가 맞는 값은 이동하지 않아 실제 작업량이 줄어듭니다.|[1,2,4,3,5]에서는 3을 삽입할 때 4만 한 칸 이동합니다. 역순 입력에서는 이동이 훨씬 많습니다. +merge-sort|split|병합 정렬의 분할|구간을 둘로 나누고 길이가 1 이하이면 더 나누지 않습니다.|[4,1,3,2]는 [4,1]과 [3,2], 다시 네 개의 한 원소 구간으로 나뉩니다. +merge-sort|merge-step|병합의 두 포인터|두 정렬된 구간의 앞 원소를 비교해 작은 것을 결과에 넣습니다.|[1,4]와 [2,3]에서 1을 선택한 뒤 4와 2, 다시 4와 3을 비교하여 [1,2,3,4]를 만듭니다. +merge-sort|inversions|병합으로 역전쌍 세기|오른쪽 값이 왼쪽 값보다 작으면 남은 왼쪽 원소 수만큼 역전쌍을 더합니다.|[2,4]와 [1,3]을 병합할 때 1은 2,4와 두 쌍, 3은 4와 한 쌍을 만들어 총 3쌍입니다. +quick-sort|partition|퀵 정렬의 분할|피벗보다 작은 값과 큰 값을 서로 다른 구간으로 모읍니다.|[5,2,4,1]에서 피벗 4를 기준으로 [2,1]과 [5]로 나눕니다. 각 구간 내부는 아직 정렬되지 않았습니다. +quick-sort|pivot|피벗 선택과 편향|매번 최솟값이나 최댓값을 고르면 한쪽 구간만 줄어 성능이 나빠집니다.|[1,2,3,4]에서 첫 원소를 피벗으로 고르면 구간 크기가 0과 3, 다시 0과 2가 됩니다. +quick-sort|three-way|같은 값의 3방향 분할|피벗보다 작은 값·같은 값·큰 값을 나누고 같은 값 구간은 재귀에서 제외합니다.|[3,1,3,4,3]에서 피벗 3이면 [1], [3,3,3], [4]로 나뉘며 가운데는 이미 완료됩니다. +tree|children|배열로 이진 트리 표현|인덱스 i의 왼쪽 자식은 2i+1, 오른쪽 자식은 2i+2입니다.|[8,4,12,2,6]에서 노드 4의 인덱스는 1이며 자식 인덱스는 3과 4, 값은 2와 6입니다. +tree|inorder|중위 순회의 방문 시점|왼쪽 서브트리를 처리한 뒤 현재 노드, 마지막으로 오른쪽 서브트리를 처리합니다.|루트 2에 왼쪽 1, 오른쪽 3이 있으면 방문 결과는 [1,2,3]입니다. +tree|postorder|후위 순회와 하위 결과|두 자식의 처리가 끝난 다음 부모를 처리하므로 하위 결과를 합칠 수 있습니다.|루트 2에 자식 1,3이 있으면 후위 순서는 [1,3,2]입니다. 폴더 삭제에서는 하위 항목을 먼저 지웁니다. +bst|search-path|BST 탐색 경로|찾는 값이 현재 값보다 작으면 왼쪽, 크면 오른쪽만 탐색합니다.|루트 8, 왼쪽 4, 오른쪽 12에서 12를 찾으면 8→12이며 4는 검사하지 않습니다. +bst|degenerate|편향된 이진 탐색 트리|정렬된 순서로 삽입하면 높이가 원소 수만큼 커질 수 있습니다.|1,2,3,4를 순서대로 삽입하면 오른쪽으로만 이어집니다. 4를 찾으려면 네 노드를 방문합니다. +bst|successor|BST의 다음 큰 값|오른쪽 서브트리가 있으면 그 안에서 가장 작은 값이 중위 후속자입니다.|노드 8의 오른쪽 서브트리에 12와 그 왼쪽 10이 있으면 8의 다음 큰 값은 10입니다. +balanced-tree|height|AVL 트리의 균형 인수|왼쪽과 오른쪽 서브트리 높이 차이의 절댓값을 1 이하로 유지합니다.|왼쪽 높이 2, 오른쪽 높이 0인 노드는 차이가 2이므로 삽입 경로에 따라 회전이 필요합니다. +balanced-tree|rotation|AVL 단일 회전|한쪽으로 연속 기울어진 세 노드를 회전하면 검색 순서를 유지하며 높이를 줄입니다.|3→왼쪽 2→왼쪽 1을 오른쪽 회전하면 루트 2와 자식 1,3이 됩니다. +balanced-tree|double|AVL 이중 회전|기울어진 방향이 중간에 바뀌면 자식 회전 후 부모 회전을 수행합니다.|3의 왼쪽이 1이고 1의 오른쪽이 2인 경우 1을 왼쪽 회전한 뒤 3을 오른쪽 회전하여 루트 2를 만듭니다. +segment-tree|combine|세그먼트 트리 구간 결합|부모 구간의 값은 두 자식 구간의 값을 결합한 결과입니다.|배열 [2,1,4,3]의 왼쪽 합은 3, 오른쪽 합은 7이므로 전체 합은 10입니다. +segment-tree|point|세그먼트 트리 점 갱신|한 칸을 바꾸면 그 칸을 포함하는 조상 구간만 갱신합니다.|[2,1,4,3]의 인덱스 1을 5로 바꾸면 왼쪽 합 3→7, 전체 합 10→14이며 오른쪽 합은 7 그대로입니다. +segment-tree|overlap|구간 질의의 겹침 분류|구간이 안 겹치면 항등값, 완전히 포함되면 저장값, 일부 겹치면 자식 결과를 사용합니다.|[2,1,4,3]에서 인덱스 [1,2]의 합은 1+4=5입니다. 범위 밖 칸은 합에 0을 기여합니다. +fenwick|lowbit|Fenwick 트리의 lowbit|i & -i는 이진 표현에서 가장 낮은 1비트의 값을 구합니다.|i=12는 이진수 1100이므로 lowbit(12)=4입니다. 누적 합 질의의 다음 인덱스는 12−4=8입니다. +fenwick|query|Fenwick 누적 합 질의|인덱스에서 lowbit를 빼면서 서로 겹치지 않는 구간 합을 더합니다.|1기반 인덱스 7의 누적 합은 트리 칸 7→6→4의 값을 더한 뒤 0에서 종료합니다. +fenwick|update|Fenwick 차이값 갱신|원소가 바뀐 차이만큼 해당 원소를 포함하는 트리 칸에 더합니다.|길이 8에서 인덱스 3에 2를 더하면 트리 칸 3→4→8에 각각 2를 더합니다. +lca|depth|LCA를 위한 깊이 맞추기|두 정점의 깊이를 같게 만든 후 공통 조상을 찾습니다.|루트 A, 자식 B, B의 자식 C에서 LCA(B,C)를 구할 때 C를 한 칸 올리면 B와 같아져 답은 B입니다. +lca|jump|이진 점프 테이블|각 정점의 1,2,4,8칸 위 조상을 저장해 여러 칸을 빠르게 이동합니다.|13칸 위로 이동하려면 13=8+4+1이므로 세 번의 점프를 사용합니다. +lca|distance|LCA로 트리 거리 계산|두 정점 깊이의 합에서 공통 조상 깊이의 두 배를 뺍니다.|깊이가 4와 3인 정점들의 LCA 깊이가 1이면 거리는 4+3−2×1=5개 간선입니다. +graph-list|undirected|무방향 간선의 양쪽 등록|무방향 관계는 양쪽 정점의 이웃 목록에 모두 저장합니다.|도로 0–2를 추가하면 g[0]에 2, g[2]에 0을 넣어 어느 쪽에서도 이동할 수 있게 합니다. +graph-list|degree|인접 리스트와 차수|정점의 이웃 수가 차수이며 무방향 단순 그래프의 차수 합은 간선 수의 두 배입니다.|간선 0–1,0–2가 있으면 차수는 [2,1,1]이고 합 4는 간선 2개의 두 배입니다. +graph-list|isolated|고립 정점과 연결 요소|간선이 없는 정점도 그래프의 정점이며 탐색 시작점으로 고려해야 합니다.|정점 0,1,2와 간선 0–1만 있으면 {0,1}과 {2}의 두 연결 요소가 있습니다. +graph-matrix|lookup|인접 행렬의 간선 조회|행은 출발점, 열은 도착점으로 사용하여 두 정점의 연결을 바로 읽습니다.|0→2가 있으면 matrix[0][2]=1입니다. 방향 그래프에서는 matrix[2][0]은 0일 수 있습니다. +graph-matrix|symmetry|무방향 행렬의 대칭|무방향 간선은 행과 열을 바꾼 두 칸에 같은 값을 저장합니다.|0–1의 비용이 7이면 matrix[0][1]=matrix[1][0]=7입니다. +graph-matrix|infinity|거리 행렬의 초기값|자기 자신까지는 0, 간선이 없는 쌍은 도달 불가 값으로 초기화합니다.|0→1 비용 4만 있으면 d[0][0]=0, d[0][1]=4, d[1][0]=∞입니다. 없는 간선을 0으로 두면 잘못된 무료 경로가 됩니다. +bfs|enqueue|BFS 방문 표시 시점|큐에 넣을 때 방문 표시해야 다른 이웃이 같은 정점을 중복 삽입하지 않습니다.|0에서 1,2를 방문하고 두 정점 모두 3과 연결되어 있어도 1이 3을 넣을 때 표시하면 2는 다시 넣지 않습니다. +bfs|levels|BFS의 거리 계층|간선 비용이 같으면 시작점부터 간선 수가 작은 정점부터 처리됩니다.|0–1–2 경로에서 시작점 0의 거리는 0, 1은 1, 2는 2입니다. 먼저 발견한 거리가 최단 거리입니다. +bfs|multi-source|여러 시작점 BFS|여러 시작점을 거리 0으로 큐에 넣으면 가장 가까운 시작점까지의 거리를 구합니다.|일렬 칸 0,1,2,3,4에서 0과 4가 출발점이면 거리는 [0,1,2,1,0]입니다. +dfs|call-stack|DFS와 호출 스택|한 이웃의 탐색이 끝날 때까지 그 아래로 진행하고 돌아온 뒤 다음 이웃을 처리합니다.|0의 이웃이 1,2이고 1의 이웃이 3이면 방문 순서는 0→1→3→2가 될 수 있습니다. +dfs|components|DFS로 연결 요소 세기|아직 방문하지 않은 정점에서 탐색을 시작할 때마다 요소 수를 늘립니다.|간선 0–1,2–3과 고립 정점 4가 있으면 탐색 시작은 0,2,4에서 세 번이며 요소는 3개입니다. +dfs|colors|DFS의 세 가지 방문 상태|미방문·탐색 중·완료 상태를 나누면 방향 그래프의 되돌아가는 간선을 구별합니다.|A→B→C→A에서 C가 아직 탐색 중인 A를 만나면 방향 사이클이 있음을 알 수 있습니다. +dijkstra|relax|다익스트라 거리 완화|현재 정점을 거치는 비용이 기존 거리보다 작을 때만 갱신합니다.|d[B]=10, d[A]=3, A→B 비용 2라면 후보 3+2=5가 더 작으므로 d[B]=5입니다. +dijkstra|stale|우선순위 큐의 오래된 항목|꺼낸 거리와 현재 최단 거리 배열이 다르면 갱신 전 항목이므로 건너뜁니다.|B의 거리 10을 넣은 뒤 5로 개선했다면 나중에 나온 (10,B)는 현재 d[B]=5와 달라 무시합니다. +dijkstra|nonnegative|다익스트라의 음수 간선 제약|가장 가까운 정점을 확정하는 논리는 이후 경로가 음수 간선으로 줄지 않아야 성립합니다.|S→A=2, S→B=5, B→A=−4이면 A를 2로 확정한 뒤 실제 거리 1을 놓칠 수 있습니다. +bellman-ford|rounds|Bellman–Ford의 반복 완화|모든 간선을 반복 검사하면 간선 수가 더 긴 경로의 개선을 반영할 수 있습니다.|S→A=2, A→B=−1이면 도달 가능한 B의 거리는 1입니다. V개 정점의 단순 경로는 최대 V−1개 간선입니다. +bellman-ford|negative-cycle|도달 가능한 음수 사이클|V−1회 이후에도 도달 가능한 거리가 개선되면 음수 사이클이 존재합니다.|A→B=1, B→A=−2를 반복하면 합이 −1씩 줄어 최소 비용이 유한하지 않습니다. +bellman-ford|unreachable|도달 불가 정점의 완화 방지|출발점에서 닿지 않는 정점의 거리로는 덧셈과 갱신을 하지 않습니다.|S와 단절된 A→B=−5가 있어도 A의 거리가 ∞이면 B도 ∞로 유지해야 합니다. +floyd-warshall|via|Floyd–Warshall의 경유점|i에서 j로 직접 가는 비용과 k를 경유하는 비용을 비교합니다.|A→C=9, A→B=2, B→C=3이면 B를 경유해 A→C가 9에서 5로 줄어듭니다. +floyd-warshall|loop-order|Floyd–Warshall 반복문 순서|허용된 중간 정점 집합을 확장하는 k 반복을 가장 바깥에 둡니다.|k=B 단계에서는 모든 i,j 쌍에 B 경유를 허용합니다. 일부 쌍만 갱신하고 다음 k로 가면 같은 단계의 의미가 달라집니다. +floyd-warshall|diagonal|음수 대각선과 음수 사이클|최단 거리 갱신 후 d[i][i]가 음수이면 i에서 돌아오는 음수 경로가 있습니다.|A→B=1, B→A=−3이면 d[A][A]가 −2가 될 수 있어 음수 사이클을 탐지합니다. +mst|kruskal|Kruskal의 간선 선택|간선을 비용 오름차순으로 보며 서로 다른 연결 요소를 잇는 간선만 고릅니다.|AB=1, BC=2, AC=4이면 AB와 BC를 골라 세 정점을 총비용 3으로 연결합니다. +mst|cycle|MST에서 사이클 제외|이미 연결된 두 정점을 잇는 간선은 트리 연결에 필요하지 않습니다.|AB와 BC를 선택한 상태에서 AC를 더하면 삼각형이 됩니다. 세 정점의 신장 트리에는 두 간선만 필요합니다. +mst|disconnected|연결되지 않은 그래프의 신장 숲|모든 정점을 잇는 경로가 없으면 하나의 신장 트리는 존재하지 않습니다.|간선 AB와 CD만 있는 네 정점에서는 두 개의 트리로 된 숲을 얻으며 단일 MST라고 부를 수 없습니다. +topological-sort|indegree|위상 정렬과 진입 차수|아직 처리하지 않은 선행 작업 수가 0인 정점을 선택합니다.|A→C, B→C이면 C의 진입 차수는 2입니다. A를 완료하면 1, B까지 완료하면 0입니다. +topological-sort|cycle-check|위상 정렬의 사이클 판별|진입 차수 0인 정점 처리가 끝났는데 남은 정점이 있으면 방향 사이클이 있습니다.|A→B→A에서는 둘 다 진입 차수 1이므로 시작할 정점이 없고 전체 순서를 만들 수 없습니다. +topological-sort|multiple|가능한 위상 순서가 여러 개일 때|독립된 작업들의 상대 순서는 바뀌어도 선행 조건을 만족합니다.|A→C, B→C에서는 A,B,C와 B,A,C가 모두 유효합니다. 정답 순서를 하나로 제한하려면 별도 기준이 필요합니다. +scc|mutual|강한 연결의 상호 도달성|같은 강한 연결 요소 안에서는 모든 정점 쌍이 서로 도달할 수 있습니다.|A→B, B→A, B→C만 있으면 {A,B}는 한 SCC지만 C는 돌아갈 길이 없어 별도입니다. +scc|condensation|SCC를 축약한 DAG|각 강한 연결 요소를 한 정점으로 묶으면 방향 사이클이 사라집니다.|A↔B와 B→C가 있으면 {A,B}→{C}로 축약합니다. 요소 사이 의존 순서를 분석할 수 있습니다. +scc|transpose|역방향 그래프|모든 간선 방향을 뒤집어 도달 관계를 반대로 탐색합니다.|A→B와 B→C의 역방향은 B→A와 C→B입니다. Kosaraju에서는 완료 순서와 역방향 탐색을 함께 사용합니다. +flow|capacity|용량과 유량 보존|간선 유량은 용량을 넘지 않고 중간 정점의 들어온 양과 나간 양이 같습니다.|S→A 용량 3, A→T 용량 2이면 이 경로로는 최대 2만 보냅니다. A에 3을 보내고 2만 내보낼 수 없습니다. +flow|residual|잔여 용량과 역간선|앞으로 더 보낼 용량과 이미 보낸 유량을 취소할 역방향 용량을 유지합니다.|용량 5인 A→B에 3을 보내면 정방향 잔여는 2, 역방향 잔여는 3입니다. +flow|augment|증가 경로의 병목|소스에서 싱크까지 경로의 잔여 용량 중 가장 작은 값만큼 유량을 늘립니다.|잔여 용량이 4,2,5인 경로는 한 번에 2를 보내고 잔여가 2,0,3이 됩니다. +recursion|base|재귀의 종료 조건|더 작은 문제로 내려가다 직접 답을 반환하는 조건을 반드시 둡니다.|factorial(3)=3×factorial(2), factorial(2)=2×factorial(1), factorial(1)=1이므로 결과는 6입니다. +recursion|unwind|재귀 호출의 복귀|가장 깊은 호출이 끝난 뒤 미뤄 둔 계산을 역순으로 수행합니다.|sum(3)=3+sum(2), sum(2)=2+sum(1), sum(1)=1이면 반환값은 1→3→6입니다. +recursion|depth|재귀 깊이와 스택 공간|호출이 끝나기 전에 다음 호출을 쌓으므로 동시에 열린 호출 수만큼 공간이 필요합니다.|n부터 1씩 줄이는 재귀는 n=4일 때 네 호출이 겹칩니다. 큰 n에서는 반복문으로 바꿀지 고려합니다. +prefix-sum|zero|누적 합의 0번 칸|원소 앞의 빈 구간 합을 0으로 두면 첫 구간도 같은 식으로 계산할 수 있습니다.|[3,5,2]의 누적 합은 [0,3,8,10]입니다. 첫 두 원소 합은 p[2]−p[0]=8입니다. +prefix-sum|range|누적 합으로 구간 빼기|오른쪽 끝까지의 합에서 왼쪽 바로 전까지의 합을 뺍니다.|일별 매출 [3,5,2]에서 둘째~셋째 날 합은 10−3=7입니다. 양 끝 포함 여부를 먼저 정합니다. +prefix-sum|difference|차분 배열로 구간 증가|구간 시작에서 더하고 끝 다음에서 빼면 누적 합으로 변경량을 복원합니다.|길이 4에서 인덱스 1~2에 3을 더할 때 차분은 [0,3,0,−3], 복원한 증가량은 [0,3,3,0]입니다. +greedy|interval|종료 시각 기준 회의 선택|일찍 끝나는 회의를 고르면 뒤에 다른 회의를 배치할 여지가 가장 많이 남습니다.|회의 [1,3],[2,5],[3,4]에서 [1,3] 후 [3,4]를 골라 두 회의를 배치합니다. +greedy|counterexample|그리디 선택의 반례|당장 좋아 보이는 선택이 전체 최적해를 보장하는지 작은 반례로 확인합니다.|동전 [1,3,4]로 6을 만들 때 큰 동전부터 고르면 4+1+1 세 개지만 최적은 3+3 두 개입니다. +greedy|exchange|교환 논증|최적해의 첫 선택을 그리디 선택으로 바꿔도 나빠지지 않음을 보이면 정당성을 뒷받침합니다.|첫 회의가 [1,5]인 일정에서 더 일찍 끝나는 [2,4]로 바꾸면 5 이후 회의들을 그대로 유지할 수 있습니다. +dp|state|DP 상태의 의미|각 칸이 어떤 부분 문제의 답인지 입력 범위와 조건까지 명확히 정의합니다.|dp[i]를 i번째 계단에 도달하는 방법 수로 두면 dp[0]=1, dp[1]=1, dp[2]=2입니다. +dp|transition|DP 점화식|마지막 선택을 나눠 이미 계산한 작은 상태에서 현재 답을 구성합니다.|1칸 또는 2칸 이동하는 계단에서 dp[4]=dp[3]+dp[2]=3+2=5입니다. +dp|initial|DP 초기값과 불가능 상태|가능한 빈 상태와 도달 불가능한 상태를 구별해야 전이가 올바릅니다.|최소 동전 수에서 dp[0]=0, 나머지는 ∞로 시작합니다. 동전 2만 있을 때 dp[1]은 ∞로 남습니다. +memoization|key|메모이제이션 캐시 키|답에 영향을 미치는 상태를 모두 키에 넣어야 다른 문제를 같은 것으로 취급하지 않습니다.|배낭 재귀에서 물품 위치가 같아도 남은 용량 2와 5의 답은 다를 수 있으므로 키는 (위치,용량)입니다. +memoization|hit|캐시 적중과 중복 계산|이미 계산한 상태를 만나면 재귀를 확장하지 않고 저장된 답을 반환합니다.|fib(5)를 계산하다 fib(3)을 다시 만나면 저장된 2를 반환하여 fib(2),fib(1)을 다시 계산하지 않습니다. +memoization|scope|캐시의 유효 범위|입력 데이터가 바뀌면 같은 상태 키의 답도 달라질 수 있으므로 캐시를 초기화하거나 입력을 키에 포함합니다.|동전 [1,3]의 최소 개수 캐시를 [2,5] 문제에 그대로 쓰면 금액 3의 답을 잘못 재사용합니다. +tabulation|order|상향식 DP 계산 순서|현재 칸이 의존하는 칸을 먼저 계산하는 순서로 반복합니다.|격자에서 위와 왼쪽 경로 수를 더할 때 행을 위에서 아래로, 열을 왼쪽에서 오른쪽으로 채웁니다. +tabulation|rolling|DP 배열의 공간 압축|다음 상태에 필요한 직전 상태만 남기면 전체 표를 저장하지 않아도 됩니다.|피보나치에서 2와 3만 보관하면 다음 5를 계산할 수 있으며 이전의 0,1,1은 더 필요하지 않습니다. +tabulation|knapsack-order|0/1 배낭의 역방향 갱신|한 물품을 한 번만 사용하려면 용량을 큰 쪽부터 갱신합니다.|무게 2, 가치 3인 물품 하나로 용량 4를 갱신할 때 정방향이면 같은 물품을 두 번 써서 가치 6으로 잘못 계산할 수 있습니다. +advanced-dp|monotone|DP 최적 분할점의 단조성|최적 분할점이 역행하지 않는 조건이 증명될 때만 후보 범위를 좁힐 수 있습니다.|세 상태의 최적 분할점이 1,2,2라면 다음 상태는 2 이상만 탐색할 수 있는 성질을 별도로 증명해야 합니다. +advanced-dp|cost|구간 비용의 빠른 계산|DP 전이에서 반복 사용하는 구간 값을 전처리하여 후보 평가 비용을 줄입니다.|비용이 구간 합의 제곱이면 [2,3,1]의 구간 [1,2] 비용은 (6−2)²=16으로 누적 합에서 구합니다. +advanced-dp|divide|분할 정복 DP의 후보 제한|중간 상태의 최적 분할점을 구하고 양쪽 상태에 허용할 후보 범위를 나눕니다.|허용 후보가 0~6이고 중간 상태의 최적점이 3이면 단조성 아래 왼쪽은 0~3, 오른쪽은 3~6으로 제한합니다. +kmp|prefix-function|KMP 접두사 함수|각 접두사에서 자기 자신을 제외하고 접두사이자 접미사인 최대 길이를 저장합니다.|패턴 abab의 접두사 함수는 [0,0,1,2]입니다. 마지막 ab가 시작의 ab와 같아 길이 2입니다. +kmp|fallback|KMP 불일치 시 이동|불일치가 나면 이미 일치한 접두사의 경계 길이로 돌아가 비교를 이어갑니다.|패턴 abab의 네 글자가 맞은 뒤 다음 문자가 맞지 않으면 전체를 버리기보다 경계 ab 길이 2를 재사용할 수 있습니다. +kmp|overlapping|겹치는 문자열 일치|한 번 찾은 뒤 접두사 함수로 돌아가면 겹쳐 있는 다음 일치를 놓치지 않습니다.|문자열 ababa에서 패턴 aba의 시작 위치는 0과 2입니다. 첫 일치 뒤 길이 1인 a를 남깁니다. +geometry|cross|외적으로 회전 방향 판별|두 방향 벡터의 외적 부호로 좌회전·우회전·일직선을 구분합니다.|A=(0,0), B=(2,0), C=(1,1)이면 외적은 2로 양수여서 좌회전입니다. +geometry|collinear|일직선 점과 경계 처리|외적이 0인 점을 경계에 포함할지 문제 조건에 따라 정해야 합니다.|(0,0),(1,0),(2,0)은 일직선입니다. 볼록 껍질의 꼭짓점만 원하면 가운데 (1,0)을 제외합니다. +geometry|hull|볼록 껍질의 내부 점 제외|경계에 맞지 않는 회전을 만드는 마지막 점을 제거하며 바깥 윤곽을 유지합니다.|정사각형 네 꼭짓점과 중심 (1,1)이 주어지면 중심은 내부에 있어 껍질에 포함되지 않습니다. +fft|convolution|다항식 곱과 합성곱|결과의 k번째 계수는 인덱스 합이 k인 입력 계수 곱의 합입니다.|[1,2]와 [3,4]의 곱은 [3,10,8]입니다. 가운데 계수는 1×4+2×3=10입니다. +fft|padding|FFT의 0 패딩|선형 합성곱 길이 이상으로 배열을 늘려 순환하며 겹치는 결과를 방지합니다.|길이 3과 2의 곱 길이는 4이므로 길이 4로 맞출 수 있습니다. 길이 3과 3이면 radix-2 FFT는 길이 8을 사용합니다. +fft|rounding|FFT의 수치 오차|부동소수점 역변환은 작은 오차를 가질 수 있어 정수 계수 문제에서 적절히 반올림합니다.|정답 계수 10이 계산 중 9.999999999로 나올 수 있습니다. 단순 정수 절삭은 9가 되므로 오차 한계를 고려해야 합니다. +array|insert|배열 중간 삽입의 이동|중간에 빈 칸을 만들려면 뒤 원소를 오른쪽부터 한 칸씩 이동합니다.|[2,5,8]의 인덱스 1에 3을 넣으면 8과 5를 뒤로 옮겨 [2,3,5,8]이 됩니다. +binary-search|monotonic|매개변수 탐색의 단조 조건|조건의 참·거짓이 한 번만 바뀌면 값 대신 조건 경계를 이진 탐색할 수 있습니다.|용량 3에서는 불가, 4에서는 가능, 5 이상도 가능하다면 최소 가능 용량은 4입니다. 가능성이 다시 사라지면 적용할 수 없습니다. +hash|load-factor|해시 테이블의 적재율|저장 항목 수를 버킷 수로 나눈 값이 커지면 충돌 처리 비용이 증가할 수 있습니다.|항목 6개, 버킷 8개의 적재율은 0.75입니다. 버킷을 16개로 늘리고 재배치하면 0.375가 됩니다. +tree|preorder|전위 순회|부모를 먼저 처리하고 왼쪽과 오른쪽 서브트리를 방문합니다.|루트 2, 왼쪽 1, 오른쪽 3의 전위 순서는 [2,1,3]입니다. 부모 정보를 먼저 전달하는 작업에 사용합니다. +graph-list|directed|방향 그래프의 간선|단방향 관계는 출발 정점의 이웃 목록에만 등록하며, 도착 정점에서 반대로 이동할 수 있다고 가정하지 않습니다.|팔로우 A→B가 있다고 B→A를 추가하면 서로 팔로우한다고 잘못 해석합니다. B의 목록은 그대로 둡니다. +bfs|path|BFS 경로 복원|처음 발견할 때 이전 정점을 저장하고 도착점에서 역으로 따라갑니다.|parent[3]=1, parent[1]=0이면 3→1→0을 뒤집어 경로 [0,1,3]을 복원합니다. +dfs|entry-exit|DFS 진입과 종료 시각|정점에 들어갈 때와 모든 자식 처리가 끝날 때 시각을 기록합니다.|A의 구간이 [1,6], B가 [2,5]이면 B의 탐색이 A 안에 포함되어 DFS 트리에서 B는 A의 후손입니다. +dp|reconstruction|DP 최적해 복원|최적값을 갱신할 때 선택한 이전 상태를 함께 저장해 실제 선택을 되짚습니다.|최소 동전 수에서 금액 6의 마지막 동전이 3이면 6→3→0을 따라가 [3,3]을 복원합니다. +prefix-sum|two-dimensional|2차원 누적 합|직사각형 합은 큰 영역에서 위·왼쪽을 빼고 두 번 뺀 겹친 영역을 한 번 더합니다.|행렬 [[1,2],[3,4]]의 전체 합은 10입니다. 오른쪽 열 합은 전체 10에서 왼쪽 열 4를 빼서 6입니다. +stack|monotonic|단조 스택|새 값과 순서가 맞지 않는 끝 원소를 꺼내며 단조로운 후보만 유지합니다.|[2,1,3]에서 오른쪽의 더 큰 값을 찾을 때 3이 들어오면 1과 2를 꺼내 두 원소의 답을 3으로 정합니다. +queue|zero-one|0–1 BFS와 덱|간선 비용이 0이면 덱 앞, 1이면 뒤에 넣어 거리 순으로 처리합니다.|S→A 비용 1, S→B 비용 0, B→A 비용 0이면 B를 먼저 처리해 A의 거리를 0으로 개선합니다. +sorting|compression|좌표 압축|정렬한 서로 다른 값에 작은 연속 인덱스를 부여해 대소 관계를 보존합니다.|좌표 [1000,−5,1000,70]은 정렬 고유값 [−5,70,1000]을 기준으로 [2,0,2,1]이 됩니다. +`.trim(); + +export const focusedConcepts = source.split("\n").map((line) => { + const [parent, slug, title, principle, example] = line.split("|"); + return { id: `${parent}-${slug}`, parent, title, principle, example }; +}); +export const focusedById = Object.fromEntries( + focusedConcepts.map((c) => [c.id, c]), +); +export const practiceTopic = (id: string) => focusedById[id]?.parent ?? id; diff --git a/apps/server/src/quiz-bank.ts b/apps/server/src/quiz-bank.ts index d9ee982..a36026a 100644 --- a/apps/server/src/quiz-bank.ts +++ b/apps/server/src/quiz-bank.ts @@ -1,3 +1,4 @@ +import { practiceTopic } from "./focused-concepts.js"; // Authored, versioned questions. Answers stay on the server until submission. type Entry = [string, string, string, string, string]; const bank: Record = { @@ -757,7 +758,7 @@ const bank: Record = { export function topicQuizzes(topic: { id: string; quiz: any }) { return [ { ...topic.quiz, id: "core" }, - ...(bank[topic.id] ?? []).map( + ...(bank[practiceTopic(topic.id)] ?? []).map( ([question, correct, b, c, explanation], i) => { const choices = [correct, b, c], offset = (i + topic.id.length) % 3; diff --git a/apps/server/src/seed-focused-concepts.ts b/apps/server/src/seed-focused-concepts.ts new file mode 100644 index 0000000..891cde9 --- /dev/null +++ b/apps/server/src/seed-focused-concepts.ts @@ -0,0 +1,46 @@ +import type { DB } from "./db.js"; +import { focusedConcepts } from "./focused-concepts.js"; + +export async function seedFocusedConcepts(db: DB) { + const parents = new Map( + ( + await db.query("SELECT id,category,priority,complexity FROM topics") + ).rows.map((t) => [t.id, t]), + ); + const rows = focusedConcepts.map((c) => { + const parent = parents.get(c.parent); + if (!parent) throw new Error(`Missing curriculum parent: ${c.parent}`); + const options = [ + c.principle, + "입력 조건이나 경계는 결과에 영향을 주지 않습니다.", + "모든 입력에서 같은 결과를 반환합니다.", + ]; + const shift = c.id.length % 3; + const choices = [...options.slice(shift), ...options.slice(0, shift)]; + return [ + c.id, + c.title, + parent.category, + parent.priority, + c.principle, + parent.complexity, + JSON.stringify({ + question: `${c.title}에 대한 설명으로 옳은 것은?`, + options: choices, + answer: choices.indexOf(c.principle), + explanation: c.example, + }), + ]; + }); + // Two round trips regardless of lesson count; important for remote DB cold starts. + const placeholders = rows + .map( + (_, i) => + `(${Array.from({ length: 7 }, (_, j) => `$${i * 7 + j + 1}`).join(",")})`, + ) + .join(","); + await db.query( + `INSERT INTO topics(id,title,category,priority,body,complexity,quiz) VALUES ${placeholders} ON CONFLICT DO NOTHING`, + rows.flat(), + ); +} diff --git a/apps/server/test/learning-flow.test.ts b/apps/server/test/learning-flow.test.ts index 8e6e277..e48b662 100644 --- a/apps/server/test/learning-flow.test.ts +++ b/apps/server/test/learning-flow.test.ts @@ -1,3 +1,6 @@ +import { safeEventProperties } from "../src/event-catalog.js"; +import { conceptCost } from "../../web/src/concept-costs.js"; +import { focusedConcepts, practiceTopic } from "../src/focused-concepts.js"; import test from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; @@ -9,8 +12,10 @@ import { import { conceptUses } from "../../web/src/concept-uses.js"; import { readRoute } from "../../web/src/router.js"; test("every published concept has answerable prediction checkpoints and animation assets", () => { - assert.equal(Object.keys(conceptUses).length, 47); + assert.equal(Object.keys(conceptUses).length, 200); for (const topic of Object.keys(conceptUses)) { + assert.equal(conceptCost(topic).length, 3); + assert.ok(conceptCost(topic).every((s) => s.length > 0)); const checks = checkpoints(topic); assert.ok(checks.length, topic); for (const check of checks) { @@ -25,7 +30,9 @@ test("every published concept has answerable prediction checkpoints and animatio for (const theme of ["light", "dark"]) for (const ext of ["png", "gif"]) assert.ok( - existsSync(`apps/web/public/learning/${topic}-${theme}.${ext}`), + existsSync( + `apps/web/public/learning/${practiceTopic(topic)}-${theme}.${ext}`, + ), ); } }); @@ -50,3 +57,35 @@ test("routes reject malformed steps, extra segments and unknown admin sections", assert.equal(readRoute("/records/abc").recordId, "abc"); assert.equal(readRoute("/learn/tree/code?record=abc").recordId, "abc"); }); + +test("200 curriculum entries are distinct and focused lessons have concrete worked cases", () => { + assert.equal(focusedConcepts.length, 153); + assert.equal(new Set(focusedConcepts.map((c) => c.id)).size, 153); + assert.equal(new Set(focusedConcepts.map((c) => c.title)).size, 153); + for (const c of focusedConcepts) { + assert.ok(conceptUses[c.parent], c.id); + assert.ok(c.principle.length > 25, c.id); + assert.ok(c.example.length > 35, c.id); + assert.ok(/[0-9A-Z가-힣]/.test(c.example), c.id); + } +}); + +test("billing screens and example-mode events fit the closed analytics vocabulary", () => { + for (const screen of ["plans", "checkout"]) + assert.deepEqual(safeEventProperties("SCREEN_VIEWED", { screen }), { + screen, + }); + assert.deepEqual( + safeEventProperties("EXAMPLE_MODE_SELECTED", { + topic: "array", + mode: "manual", + }), + { topic: "array", mode: "manual" }, + ); + assert.throws(() => + safeEventProperties("EXAMPLE_MODE_SELECTED", { + topic: "array", + mode: "untrusted", + }), + ); +}); diff --git a/apps/server/test/platform.test.ts b/apps/server/test/platform.test.ts index d6b8b16..45da5ad 100644 --- a/apps/server/test/platform.test.ts +++ b/apps/server/test/platform.test.ts @@ -458,7 +458,7 @@ test("quiz hides answer until submission and repeated request returns original r test("every topic has three distinct quizzes and extra answers remain server-only", async () => { const u = await login("expanded-quiz"); const topics = (await request(u, "GET", "/api/topics")).json().topics; - assert.equal(topics.length, 47); + assert.equal(topics.length, 200); for (const topic of topics) { assert.equal(topic.quizzes.length, 3, topic.id); assert.equal(new Set(topic.quizzes.map((q: any) => q.question)).size, 3); diff --git a/apps/server/test/practice-catalog.test.ts b/apps/server/test/practice-catalog.test.ts index 06d082d..e21fad5 100644 --- a/apps/server/test/practice-catalog.test.ts +++ b/apps/server/test/practice-catalog.test.ts @@ -1,20 +1,21 @@ +import { practiceTopic } from "../src/focused-concepts.js"; import test from "node:test"; import assert from "node:assert/strict"; import { database } from "../src/db.js"; import { seed } from "../src/content.js"; import { catalog } from "../src/content-admin.js"; -test("all 47 concepts have complete coding problems and reseeding preserves published content", async () => { +test("all 200 concepts link to complete coding problems and reseeding preserves published content", async () => { const db = await database(); try { await seed(db); const topics = (await db.query("SELECT * FROM topics")).rows; const problems = (await db.query("SELECT * FROM problems")).rows; - assert.equal(topics.length, 47); + assert.equal(topics.length, 200); assert.equal(problems.length, 47); catalog.parse({ topics, problems }); assert.deepEqual( new Set(problems.map((p) => p.topic_id)), - new Set(topics.map((t) => t.id)), + new Set(topics.map((t) => practiceTopic(t.id))), ); for (const p of problems) { assert.deepEqual(Object.keys(p.references_code).sort(), [ diff --git a/apps/web/public/learning/advanced-dp-dark.gif b/apps/web/public/learning/advanced-dp-dark.gif index d113d23..17fa886 100644 Binary files a/apps/web/public/learning/advanced-dp-dark.gif and b/apps/web/public/learning/advanced-dp-dark.gif differ diff --git a/apps/web/public/learning/advanced-dp-dark.png b/apps/web/public/learning/advanced-dp-dark.png index a027822..1a386d8 100644 Binary files a/apps/web/public/learning/advanced-dp-dark.png and b/apps/web/public/learning/advanced-dp-dark.png differ diff --git a/apps/web/public/learning/advanced-dp-light.gif b/apps/web/public/learning/advanced-dp-light.gif index bf44c5b..f1381dd 100644 Binary files a/apps/web/public/learning/advanced-dp-light.gif and b/apps/web/public/learning/advanced-dp-light.gif differ diff --git a/apps/web/public/learning/advanced-dp-light.png b/apps/web/public/learning/advanced-dp-light.png index 4cb1b6a..ce9d404 100644 Binary files a/apps/web/public/learning/advanced-dp-light.png and b/apps/web/public/learning/advanced-dp-light.png differ diff --git a/apps/web/public/learning/array-dark.gif b/apps/web/public/learning/array-dark.gif index 4bc1e10..060e0f0 100644 Binary files a/apps/web/public/learning/array-dark.gif and b/apps/web/public/learning/array-dark.gif differ diff --git a/apps/web/public/learning/array-dark.png b/apps/web/public/learning/array-dark.png index a7d6980..42a9398 100644 Binary files a/apps/web/public/learning/array-dark.png and b/apps/web/public/learning/array-dark.png differ diff --git a/apps/web/public/learning/array-light.gif b/apps/web/public/learning/array-light.gif index 3a2d760..c8158c0 100644 Binary files a/apps/web/public/learning/array-light.gif and b/apps/web/public/learning/array-light.gif differ diff --git a/apps/web/public/learning/array-light.png b/apps/web/public/learning/array-light.png index d9685a4..33bce1c 100644 Binary files a/apps/web/public/learning/array-light.png and b/apps/web/public/learning/array-light.png differ diff --git a/apps/web/public/learning/backtracking-dark.gif b/apps/web/public/learning/backtracking-dark.gif index 8acbb52..dfc942f 100644 Binary files a/apps/web/public/learning/backtracking-dark.gif and b/apps/web/public/learning/backtracking-dark.gif differ diff --git a/apps/web/public/learning/backtracking-dark.png b/apps/web/public/learning/backtracking-dark.png index 8d701b8..49ca7ae 100644 Binary files a/apps/web/public/learning/backtracking-dark.png and b/apps/web/public/learning/backtracking-dark.png differ diff --git a/apps/web/public/learning/backtracking-light.gif b/apps/web/public/learning/backtracking-light.gif index 2ea1914..94506fb 100644 Binary files a/apps/web/public/learning/backtracking-light.gif and b/apps/web/public/learning/backtracking-light.gif differ diff --git a/apps/web/public/learning/backtracking-light.png b/apps/web/public/learning/backtracking-light.png index 7e140cd..a551658 100644 Binary files a/apps/web/public/learning/backtracking-light.png and b/apps/web/public/learning/backtracking-light.png differ diff --git a/apps/web/public/learning/balanced-tree-dark.gif b/apps/web/public/learning/balanced-tree-dark.gif index af59965..faa72b1 100644 Binary files a/apps/web/public/learning/balanced-tree-dark.gif and b/apps/web/public/learning/balanced-tree-dark.gif differ diff --git a/apps/web/public/learning/balanced-tree-light.gif b/apps/web/public/learning/balanced-tree-light.gif index 3a9d62f..4f05937 100644 Binary files a/apps/web/public/learning/balanced-tree-light.gif and b/apps/web/public/learning/balanced-tree-light.gif differ diff --git a/apps/web/public/learning/bellman-ford-dark.gif b/apps/web/public/learning/bellman-ford-dark.gif index 3c01887..a1b6e57 100644 Binary files a/apps/web/public/learning/bellman-ford-dark.gif and b/apps/web/public/learning/bellman-ford-dark.gif differ diff --git a/apps/web/public/learning/bellman-ford-dark.png b/apps/web/public/learning/bellman-ford-dark.png index a20fa5a..b71c1ad 100644 Binary files a/apps/web/public/learning/bellman-ford-dark.png and b/apps/web/public/learning/bellman-ford-dark.png differ diff --git a/apps/web/public/learning/bellman-ford-light.gif b/apps/web/public/learning/bellman-ford-light.gif index 440b925..1f9259f 100644 Binary files a/apps/web/public/learning/bellman-ford-light.gif and b/apps/web/public/learning/bellman-ford-light.gif differ diff --git a/apps/web/public/learning/bellman-ford-light.png b/apps/web/public/learning/bellman-ford-light.png index fbd50ef..6213957 100644 Binary files a/apps/web/public/learning/bellman-ford-light.png and b/apps/web/public/learning/bellman-ford-light.png differ diff --git a/apps/web/public/learning/bfs-dark.gif b/apps/web/public/learning/bfs-dark.gif index 703e684..a6f09cb 100644 Binary files a/apps/web/public/learning/bfs-dark.gif and b/apps/web/public/learning/bfs-dark.gif differ diff --git a/apps/web/public/learning/bfs-dark.png b/apps/web/public/learning/bfs-dark.png index bf9b075..c32bca5 100644 Binary files a/apps/web/public/learning/bfs-dark.png and b/apps/web/public/learning/bfs-dark.png differ diff --git a/apps/web/public/learning/bfs-light.gif b/apps/web/public/learning/bfs-light.gif index 5af2a6c..4a3f86a 100644 Binary files a/apps/web/public/learning/bfs-light.gif and b/apps/web/public/learning/bfs-light.gif differ diff --git a/apps/web/public/learning/bfs-light.png b/apps/web/public/learning/bfs-light.png index d7bf1de..06990c4 100644 Binary files a/apps/web/public/learning/bfs-light.png and b/apps/web/public/learning/bfs-light.png differ diff --git a/apps/web/public/learning/binary-search-dark.gif b/apps/web/public/learning/binary-search-dark.gif index 9e4b0fb..9b47611 100644 Binary files a/apps/web/public/learning/binary-search-dark.gif and b/apps/web/public/learning/binary-search-dark.gif differ diff --git a/apps/web/public/learning/binary-search-dark.png b/apps/web/public/learning/binary-search-dark.png index c891cd4..83f3f1e 100644 Binary files a/apps/web/public/learning/binary-search-dark.png and b/apps/web/public/learning/binary-search-dark.png differ diff --git a/apps/web/public/learning/binary-search-light.gif b/apps/web/public/learning/binary-search-light.gif index 094e7e6..fc9c289 100644 Binary files a/apps/web/public/learning/binary-search-light.gif and b/apps/web/public/learning/binary-search-light.gif differ diff --git a/apps/web/public/learning/binary-search-light.png b/apps/web/public/learning/binary-search-light.png index cff5258..acc38a8 100644 Binary files a/apps/web/public/learning/binary-search-light.png and b/apps/web/public/learning/binary-search-light.png differ diff --git a/apps/web/public/learning/brute-force-dark.gif b/apps/web/public/learning/brute-force-dark.gif index d1a308d..798d8d0 100644 Binary files a/apps/web/public/learning/brute-force-dark.gif and b/apps/web/public/learning/brute-force-dark.gif differ diff --git a/apps/web/public/learning/brute-force-dark.png b/apps/web/public/learning/brute-force-dark.png index a9a2f9d..ab75b2c 100644 Binary files a/apps/web/public/learning/brute-force-dark.png and b/apps/web/public/learning/brute-force-dark.png differ diff --git a/apps/web/public/learning/brute-force-light.gif b/apps/web/public/learning/brute-force-light.gif index 7ef5c78..b801a34 100644 Binary files a/apps/web/public/learning/brute-force-light.gif and b/apps/web/public/learning/brute-force-light.gif differ diff --git a/apps/web/public/learning/brute-force-light.png b/apps/web/public/learning/brute-force-light.png index 2720641..11a4ced 100644 Binary files a/apps/web/public/learning/brute-force-light.png and b/apps/web/public/learning/brute-force-light.png differ diff --git a/apps/web/public/learning/bst-dark.gif b/apps/web/public/learning/bst-dark.gif index 2263970..5ebdd84 100644 Binary files a/apps/web/public/learning/bst-dark.gif and b/apps/web/public/learning/bst-dark.gif differ diff --git a/apps/web/public/learning/bst-dark.png b/apps/web/public/learning/bst-dark.png index 83a5b14..6b4f2a4 100644 Binary files a/apps/web/public/learning/bst-dark.png and b/apps/web/public/learning/bst-dark.png differ diff --git a/apps/web/public/learning/bst-light.gif b/apps/web/public/learning/bst-light.gif index 276bb27..da6b4f9 100644 Binary files a/apps/web/public/learning/bst-light.gif and b/apps/web/public/learning/bst-light.gif differ diff --git a/apps/web/public/learning/bst-light.png b/apps/web/public/learning/bst-light.png index 82dd728..102a516 100644 Binary files a/apps/web/public/learning/bst-light.png and b/apps/web/public/learning/bst-light.png differ diff --git a/apps/web/public/learning/bubble-sort-dark.gif b/apps/web/public/learning/bubble-sort-dark.gif index dde586d..88c03db 100644 Binary files a/apps/web/public/learning/bubble-sort-dark.gif and b/apps/web/public/learning/bubble-sort-dark.gif differ diff --git a/apps/web/public/learning/bubble-sort-dark.png b/apps/web/public/learning/bubble-sort-dark.png index 4db216a..561e586 100644 Binary files a/apps/web/public/learning/bubble-sort-dark.png and b/apps/web/public/learning/bubble-sort-dark.png differ diff --git a/apps/web/public/learning/bubble-sort-light.gif b/apps/web/public/learning/bubble-sort-light.gif index 41ec8bc..f86b27d 100644 Binary files a/apps/web/public/learning/bubble-sort-light.gif and b/apps/web/public/learning/bubble-sort-light.gif differ diff --git a/apps/web/public/learning/bubble-sort-light.png b/apps/web/public/learning/bubble-sort-light.png index ecf6841..1c43a12 100644 Binary files a/apps/web/public/learning/bubble-sort-light.png and b/apps/web/public/learning/bubble-sort-light.png differ diff --git a/apps/web/public/learning/dfs-dark.gif b/apps/web/public/learning/dfs-dark.gif index d70fcc4..39fb763 100644 Binary files a/apps/web/public/learning/dfs-dark.gif and b/apps/web/public/learning/dfs-dark.gif differ diff --git a/apps/web/public/learning/dfs-dark.png b/apps/web/public/learning/dfs-dark.png index bf9b075..c32bca5 100644 Binary files a/apps/web/public/learning/dfs-dark.png and b/apps/web/public/learning/dfs-dark.png differ diff --git a/apps/web/public/learning/dfs-light.gif b/apps/web/public/learning/dfs-light.gif index 25c7fcd..6cf80a9 100644 Binary files a/apps/web/public/learning/dfs-light.gif and b/apps/web/public/learning/dfs-light.gif differ diff --git a/apps/web/public/learning/dfs-light.png b/apps/web/public/learning/dfs-light.png index d7bf1de..06990c4 100644 Binary files a/apps/web/public/learning/dfs-light.png and b/apps/web/public/learning/dfs-light.png differ diff --git a/apps/web/public/learning/dijkstra-dark.gif b/apps/web/public/learning/dijkstra-dark.gif index d8ee2de..c32ce32 100644 Binary files a/apps/web/public/learning/dijkstra-dark.gif and b/apps/web/public/learning/dijkstra-dark.gif differ diff --git a/apps/web/public/learning/dijkstra-dark.png b/apps/web/public/learning/dijkstra-dark.png index 6df81ca..6942920 100644 Binary files a/apps/web/public/learning/dijkstra-dark.png and b/apps/web/public/learning/dijkstra-dark.png differ diff --git a/apps/web/public/learning/dijkstra-light.gif b/apps/web/public/learning/dijkstra-light.gif index 40a2955..a7819b6 100644 Binary files a/apps/web/public/learning/dijkstra-light.gif and b/apps/web/public/learning/dijkstra-light.gif differ diff --git a/apps/web/public/learning/dijkstra-light.png b/apps/web/public/learning/dijkstra-light.png index b03ff05..5be281c 100644 Binary files a/apps/web/public/learning/dijkstra-light.png and b/apps/web/public/learning/dijkstra-light.png differ diff --git a/apps/web/public/learning/dp-dark.gif b/apps/web/public/learning/dp-dark.gif index 3095ee9..4aa0044 100644 Binary files a/apps/web/public/learning/dp-dark.gif and b/apps/web/public/learning/dp-dark.gif differ diff --git a/apps/web/public/learning/dp-dark.png b/apps/web/public/learning/dp-dark.png index cad3af1..39a6f7a 100644 Binary files a/apps/web/public/learning/dp-dark.png and b/apps/web/public/learning/dp-dark.png differ diff --git a/apps/web/public/learning/dp-light.gif b/apps/web/public/learning/dp-light.gif index 11f74ce..c27f5a7 100644 Binary files a/apps/web/public/learning/dp-light.gif and b/apps/web/public/learning/dp-light.gif differ diff --git a/apps/web/public/learning/dp-light.png b/apps/web/public/learning/dp-light.png index fd1d981..df327cf 100644 Binary files a/apps/web/public/learning/dp-light.png and b/apps/web/public/learning/dp-light.png differ diff --git a/apps/web/public/learning/fenwick-dark.gif b/apps/web/public/learning/fenwick-dark.gif index f029db0..a39a484 100644 Binary files a/apps/web/public/learning/fenwick-dark.gif and b/apps/web/public/learning/fenwick-dark.gif differ diff --git a/apps/web/public/learning/fenwick-dark.png b/apps/web/public/learning/fenwick-dark.png index 38ab9e7..ec35586 100644 Binary files a/apps/web/public/learning/fenwick-dark.png and b/apps/web/public/learning/fenwick-dark.png differ diff --git a/apps/web/public/learning/fenwick-light.gif b/apps/web/public/learning/fenwick-light.gif index ca06176..6c1cabf 100644 Binary files a/apps/web/public/learning/fenwick-light.gif and b/apps/web/public/learning/fenwick-light.gif differ diff --git a/apps/web/public/learning/fenwick-light.png b/apps/web/public/learning/fenwick-light.png index 95e3fc1..70777a9 100644 Binary files a/apps/web/public/learning/fenwick-light.png and b/apps/web/public/learning/fenwick-light.png differ diff --git a/apps/web/public/learning/fft-dark.gif b/apps/web/public/learning/fft-dark.gif index 8f4defb..3d24725 100644 Binary files a/apps/web/public/learning/fft-dark.gif and b/apps/web/public/learning/fft-dark.gif differ diff --git a/apps/web/public/learning/fft-dark.png b/apps/web/public/learning/fft-dark.png index 1a7e711..a738c4e 100644 Binary files a/apps/web/public/learning/fft-dark.png and b/apps/web/public/learning/fft-dark.png differ diff --git a/apps/web/public/learning/fft-light.gif b/apps/web/public/learning/fft-light.gif index d57f81a..13fc612 100644 Binary files a/apps/web/public/learning/fft-light.gif and b/apps/web/public/learning/fft-light.gif differ diff --git a/apps/web/public/learning/fft-light.png b/apps/web/public/learning/fft-light.png index cb0820b..434c9e0 100644 Binary files a/apps/web/public/learning/fft-light.png and b/apps/web/public/learning/fft-light.png differ diff --git a/apps/web/public/learning/flow-dark.gif b/apps/web/public/learning/flow-dark.gif index 13cda12..caa0295 100644 Binary files a/apps/web/public/learning/flow-dark.gif and b/apps/web/public/learning/flow-dark.gif differ diff --git a/apps/web/public/learning/flow-dark.png b/apps/web/public/learning/flow-dark.png index 31576fc..1b226d5 100644 Binary files a/apps/web/public/learning/flow-dark.png and b/apps/web/public/learning/flow-dark.png differ diff --git a/apps/web/public/learning/flow-light.gif b/apps/web/public/learning/flow-light.gif index 0ad4342..cb96464 100644 Binary files a/apps/web/public/learning/flow-light.gif and b/apps/web/public/learning/flow-light.gif differ diff --git a/apps/web/public/learning/flow-light.png b/apps/web/public/learning/flow-light.png index 54ed649..c951d71 100644 Binary files a/apps/web/public/learning/flow-light.png and b/apps/web/public/learning/flow-light.png differ diff --git a/apps/web/public/learning/floyd-warshall-dark.gif b/apps/web/public/learning/floyd-warshall-dark.gif index abc7359..0f610f2 100644 Binary files a/apps/web/public/learning/floyd-warshall-dark.gif and b/apps/web/public/learning/floyd-warshall-dark.gif differ diff --git a/apps/web/public/learning/floyd-warshall-dark.png b/apps/web/public/learning/floyd-warshall-dark.png index fb9a473..c40bd60 100644 Binary files a/apps/web/public/learning/floyd-warshall-dark.png and b/apps/web/public/learning/floyd-warshall-dark.png differ diff --git a/apps/web/public/learning/floyd-warshall-light.gif b/apps/web/public/learning/floyd-warshall-light.gif index 6ee4a5b..e1a5781 100644 Binary files a/apps/web/public/learning/floyd-warshall-light.gif and b/apps/web/public/learning/floyd-warshall-light.gif differ diff --git a/apps/web/public/learning/floyd-warshall-light.png b/apps/web/public/learning/floyd-warshall-light.png index b196ffa..9b1add1 100644 Binary files a/apps/web/public/learning/floyd-warshall-light.png and b/apps/web/public/learning/floyd-warshall-light.png differ diff --git a/apps/web/public/learning/geometry-dark.gif b/apps/web/public/learning/geometry-dark.gif index f50dd26..38eb417 100644 Binary files a/apps/web/public/learning/geometry-dark.gif and b/apps/web/public/learning/geometry-dark.gif differ diff --git a/apps/web/public/learning/geometry-dark.png b/apps/web/public/learning/geometry-dark.png index 3dc1bab..8529523 100644 Binary files a/apps/web/public/learning/geometry-dark.png and b/apps/web/public/learning/geometry-dark.png differ diff --git a/apps/web/public/learning/geometry-light.gif b/apps/web/public/learning/geometry-light.gif index 4f29e10..1c98d3f 100644 Binary files a/apps/web/public/learning/geometry-light.gif and b/apps/web/public/learning/geometry-light.gif differ diff --git a/apps/web/public/learning/geometry-light.png b/apps/web/public/learning/geometry-light.png index 4695935..c69c002 100644 Binary files a/apps/web/public/learning/geometry-light.png and b/apps/web/public/learning/geometry-light.png differ diff --git a/apps/web/public/learning/graph-list-dark.gif b/apps/web/public/learning/graph-list-dark.gif index 703e684..a6f09cb 100644 Binary files a/apps/web/public/learning/graph-list-dark.gif and b/apps/web/public/learning/graph-list-dark.gif differ diff --git a/apps/web/public/learning/graph-list-dark.png b/apps/web/public/learning/graph-list-dark.png index bf9b075..c32bca5 100644 Binary files a/apps/web/public/learning/graph-list-dark.png and b/apps/web/public/learning/graph-list-dark.png differ diff --git a/apps/web/public/learning/graph-list-light.gif b/apps/web/public/learning/graph-list-light.gif index 5af2a6c..4a3f86a 100644 Binary files a/apps/web/public/learning/graph-list-light.gif and b/apps/web/public/learning/graph-list-light.gif differ diff --git a/apps/web/public/learning/graph-list-light.png b/apps/web/public/learning/graph-list-light.png index d7bf1de..06990c4 100644 Binary files a/apps/web/public/learning/graph-list-light.png and b/apps/web/public/learning/graph-list-light.png differ diff --git a/apps/web/public/learning/graph-matrix-dark.gif b/apps/web/public/learning/graph-matrix-dark.gif index 703e684..a6f09cb 100644 Binary files a/apps/web/public/learning/graph-matrix-dark.gif and b/apps/web/public/learning/graph-matrix-dark.gif differ diff --git a/apps/web/public/learning/graph-matrix-dark.png b/apps/web/public/learning/graph-matrix-dark.png index bf9b075..c32bca5 100644 Binary files a/apps/web/public/learning/graph-matrix-dark.png and b/apps/web/public/learning/graph-matrix-dark.png differ diff --git a/apps/web/public/learning/graph-matrix-light.gif b/apps/web/public/learning/graph-matrix-light.gif index 5af2a6c..4a3f86a 100644 Binary files a/apps/web/public/learning/graph-matrix-light.gif and b/apps/web/public/learning/graph-matrix-light.gif differ diff --git a/apps/web/public/learning/graph-matrix-light.png b/apps/web/public/learning/graph-matrix-light.png index d7bf1de..06990c4 100644 Binary files a/apps/web/public/learning/graph-matrix-light.png and b/apps/web/public/learning/graph-matrix-light.png differ diff --git a/apps/web/public/learning/greedy-dark.gif b/apps/web/public/learning/greedy-dark.gif index 537f9c8..52bc9d2 100644 Binary files a/apps/web/public/learning/greedy-dark.gif and b/apps/web/public/learning/greedy-dark.gif differ diff --git a/apps/web/public/learning/greedy-dark.png b/apps/web/public/learning/greedy-dark.png index 70b3c13..aceaff0 100644 Binary files a/apps/web/public/learning/greedy-dark.png and b/apps/web/public/learning/greedy-dark.png differ diff --git a/apps/web/public/learning/greedy-light.gif b/apps/web/public/learning/greedy-light.gif index cd254b4..31eded7 100644 Binary files a/apps/web/public/learning/greedy-light.gif and b/apps/web/public/learning/greedy-light.gif differ diff --git a/apps/web/public/learning/greedy-light.png b/apps/web/public/learning/greedy-light.png index 0d7c990..aa6a1ba 100644 Binary files a/apps/web/public/learning/greedy-light.png and b/apps/web/public/learning/greedy-light.png differ diff --git a/apps/web/public/learning/hash-dark.gif b/apps/web/public/learning/hash-dark.gif index 46700c5..92433c0 100644 Binary files a/apps/web/public/learning/hash-dark.gif and b/apps/web/public/learning/hash-dark.gif differ diff --git a/apps/web/public/learning/hash-dark.png b/apps/web/public/learning/hash-dark.png index 0aa9898..20e7ebe 100644 Binary files a/apps/web/public/learning/hash-dark.png and b/apps/web/public/learning/hash-dark.png differ diff --git a/apps/web/public/learning/hash-light.gif b/apps/web/public/learning/hash-light.gif index b9287a0..020887b 100644 Binary files a/apps/web/public/learning/hash-light.gif and b/apps/web/public/learning/hash-light.gif differ diff --git a/apps/web/public/learning/hash-light.png b/apps/web/public/learning/hash-light.png index 077f7dc..a02f2d9 100644 Binary files a/apps/web/public/learning/hash-light.png and b/apps/web/public/learning/hash-light.png differ diff --git a/apps/web/public/learning/heap-dark.gif b/apps/web/public/learning/heap-dark.gif index dfc302d..6d52237 100644 Binary files a/apps/web/public/learning/heap-dark.gif and b/apps/web/public/learning/heap-dark.gif differ diff --git a/apps/web/public/learning/heap-dark.png b/apps/web/public/learning/heap-dark.png index da8fd8a..dac3519 100644 Binary files a/apps/web/public/learning/heap-dark.png and b/apps/web/public/learning/heap-dark.png differ diff --git a/apps/web/public/learning/heap-light.gif b/apps/web/public/learning/heap-light.gif index 7b59fc9..3d0bc05 100644 Binary files a/apps/web/public/learning/heap-light.gif and b/apps/web/public/learning/heap-light.gif differ diff --git a/apps/web/public/learning/heap-light.png b/apps/web/public/learning/heap-light.png index a9b0240..cf00215 100644 Binary files a/apps/web/public/learning/heap-light.png and b/apps/web/public/learning/heap-light.png differ diff --git a/apps/web/public/learning/insertion-sort-dark.gif b/apps/web/public/learning/insertion-sort-dark.gif index 31684f2..289ef99 100644 Binary files a/apps/web/public/learning/insertion-sort-dark.gif and b/apps/web/public/learning/insertion-sort-dark.gif differ diff --git a/apps/web/public/learning/insertion-sort-dark.png b/apps/web/public/learning/insertion-sort-dark.png index 4db216a..561e586 100644 Binary files a/apps/web/public/learning/insertion-sort-dark.png and b/apps/web/public/learning/insertion-sort-dark.png differ diff --git a/apps/web/public/learning/insertion-sort-light.gif b/apps/web/public/learning/insertion-sort-light.gif index ed3b73b..09fd45c 100644 Binary files a/apps/web/public/learning/insertion-sort-light.gif and b/apps/web/public/learning/insertion-sort-light.gif differ diff --git a/apps/web/public/learning/insertion-sort-light.png b/apps/web/public/learning/insertion-sort-light.png index ecf6841..1c43a12 100644 Binary files a/apps/web/public/learning/insertion-sort-light.png and b/apps/web/public/learning/insertion-sort-light.png differ diff --git a/apps/web/public/learning/kmp-dark.gif b/apps/web/public/learning/kmp-dark.gif index d458751..5182240 100644 Binary files a/apps/web/public/learning/kmp-dark.gif and b/apps/web/public/learning/kmp-dark.gif differ diff --git a/apps/web/public/learning/kmp-dark.png b/apps/web/public/learning/kmp-dark.png index e415c5e..a2e864b 100644 Binary files a/apps/web/public/learning/kmp-dark.png and b/apps/web/public/learning/kmp-dark.png differ diff --git a/apps/web/public/learning/kmp-light.gif b/apps/web/public/learning/kmp-light.gif index 403a28c..416fae6 100644 Binary files a/apps/web/public/learning/kmp-light.gif and b/apps/web/public/learning/kmp-light.gif differ diff --git a/apps/web/public/learning/kmp-light.png b/apps/web/public/learning/kmp-light.png index 23f18ed..396d059 100644 Binary files a/apps/web/public/learning/kmp-light.png and b/apps/web/public/learning/kmp-light.png differ diff --git a/apps/web/public/learning/lca-dark.gif b/apps/web/public/learning/lca-dark.gif index af2d8c7..ffb54bc 100644 Binary files a/apps/web/public/learning/lca-dark.gif and b/apps/web/public/learning/lca-dark.gif differ diff --git a/apps/web/public/learning/lca-dark.png b/apps/web/public/learning/lca-dark.png index bbc679e..e1f99b3 100644 Binary files a/apps/web/public/learning/lca-dark.png and b/apps/web/public/learning/lca-dark.png differ diff --git a/apps/web/public/learning/lca-light.gif b/apps/web/public/learning/lca-light.gif index b035cda..aecfead 100644 Binary files a/apps/web/public/learning/lca-light.gif and b/apps/web/public/learning/lca-light.gif differ diff --git a/apps/web/public/learning/lca-light.png b/apps/web/public/learning/lca-light.png index ffece60..d84b4b1 100644 Binary files a/apps/web/public/learning/lca-light.png and b/apps/web/public/learning/lca-light.png differ diff --git a/apps/web/public/learning/linear-search-dark.gif b/apps/web/public/learning/linear-search-dark.gif index b738a3a..1b30687 100644 Binary files a/apps/web/public/learning/linear-search-dark.gif and b/apps/web/public/learning/linear-search-dark.gif differ diff --git a/apps/web/public/learning/linear-search-dark.png b/apps/web/public/learning/linear-search-dark.png index 0f956ac..6ebcd4c 100644 Binary files a/apps/web/public/learning/linear-search-dark.png and b/apps/web/public/learning/linear-search-dark.png differ diff --git a/apps/web/public/learning/linear-search-light.gif b/apps/web/public/learning/linear-search-light.gif index 4fac049..751620c 100644 Binary files a/apps/web/public/learning/linear-search-light.gif and b/apps/web/public/learning/linear-search-light.gif differ diff --git a/apps/web/public/learning/linear-search-light.png b/apps/web/public/learning/linear-search-light.png index d55f4bd..6d48bb8 100644 Binary files a/apps/web/public/learning/linear-search-light.png and b/apps/web/public/learning/linear-search-light.png differ diff --git a/apps/web/public/learning/linked-list-dark.gif b/apps/web/public/learning/linked-list-dark.gif index cadc580..14ea6bd 100644 Binary files a/apps/web/public/learning/linked-list-dark.gif and b/apps/web/public/learning/linked-list-dark.gif differ diff --git a/apps/web/public/learning/linked-list-dark.png b/apps/web/public/learning/linked-list-dark.png index 83a5b14..6b4f2a4 100644 Binary files a/apps/web/public/learning/linked-list-dark.png and b/apps/web/public/learning/linked-list-dark.png differ diff --git a/apps/web/public/learning/linked-list-light.gif b/apps/web/public/learning/linked-list-light.gif index 5533ccf..d0941d7 100644 Binary files a/apps/web/public/learning/linked-list-light.gif and b/apps/web/public/learning/linked-list-light.gif differ diff --git a/apps/web/public/learning/linked-list-light.png b/apps/web/public/learning/linked-list-light.png index 82dd728..102a516 100644 Binary files a/apps/web/public/learning/linked-list-light.png and b/apps/web/public/learning/linked-list-light.png differ diff --git a/apps/web/public/learning/memoization-dark.gif b/apps/web/public/learning/memoization-dark.gif index dce5b61..9c3cf18 100644 Binary files a/apps/web/public/learning/memoization-dark.gif and b/apps/web/public/learning/memoization-dark.gif differ diff --git a/apps/web/public/learning/memoization-dark.png b/apps/web/public/learning/memoization-dark.png index 9f9d618..d7c1e0b 100644 Binary files a/apps/web/public/learning/memoization-dark.png and b/apps/web/public/learning/memoization-dark.png differ diff --git a/apps/web/public/learning/memoization-light.gif b/apps/web/public/learning/memoization-light.gif index ed3cc8e..1e030c8 100644 Binary files a/apps/web/public/learning/memoization-light.gif and b/apps/web/public/learning/memoization-light.gif differ diff --git a/apps/web/public/learning/memoization-light.png b/apps/web/public/learning/memoization-light.png index f890017..f46f972 100644 Binary files a/apps/web/public/learning/memoization-light.png and b/apps/web/public/learning/memoization-light.png differ diff --git a/apps/web/public/learning/merge-sort-dark.gif b/apps/web/public/learning/merge-sort-dark.gif index aa94030..8c262dc 100644 Binary files a/apps/web/public/learning/merge-sort-dark.gif and b/apps/web/public/learning/merge-sort-dark.gif differ diff --git a/apps/web/public/learning/merge-sort-dark.png b/apps/web/public/learning/merge-sort-dark.png index 2df953d..d015127 100644 Binary files a/apps/web/public/learning/merge-sort-dark.png and b/apps/web/public/learning/merge-sort-dark.png differ diff --git a/apps/web/public/learning/merge-sort-light.gif b/apps/web/public/learning/merge-sort-light.gif index 000d940..d2fb0d5 100644 Binary files a/apps/web/public/learning/merge-sort-light.gif and b/apps/web/public/learning/merge-sort-light.gif differ diff --git a/apps/web/public/learning/merge-sort-light.png b/apps/web/public/learning/merge-sort-light.png index 9759130..ede67f4 100644 Binary files a/apps/web/public/learning/merge-sort-light.png and b/apps/web/public/learning/merge-sort-light.png differ diff --git a/apps/web/public/learning/mst-dark.gif b/apps/web/public/learning/mst-dark.gif index adda8e1..5dae064 100644 Binary files a/apps/web/public/learning/mst-dark.gif and b/apps/web/public/learning/mst-dark.gif differ diff --git a/apps/web/public/learning/mst-dark.png b/apps/web/public/learning/mst-dark.png index 5d725cb..fa71e6a 100644 Binary files a/apps/web/public/learning/mst-dark.png and b/apps/web/public/learning/mst-dark.png differ diff --git a/apps/web/public/learning/mst-light.gif b/apps/web/public/learning/mst-light.gif index 4d1741e..7973ce4 100644 Binary files a/apps/web/public/learning/mst-light.gif and b/apps/web/public/learning/mst-light.gif differ diff --git a/apps/web/public/learning/mst-light.png b/apps/web/public/learning/mst-light.png index b54bf39..454c0fb 100644 Binary files a/apps/web/public/learning/mst-light.png and b/apps/web/public/learning/mst-light.png differ diff --git a/apps/web/public/learning/prefix-sum-dark.gif b/apps/web/public/learning/prefix-sum-dark.gif index 661f09e..eeddb2a 100644 Binary files a/apps/web/public/learning/prefix-sum-dark.gif and b/apps/web/public/learning/prefix-sum-dark.gif differ diff --git a/apps/web/public/learning/prefix-sum-dark.png b/apps/web/public/learning/prefix-sum-dark.png index 0aa9898..20e7ebe 100644 Binary files a/apps/web/public/learning/prefix-sum-dark.png and b/apps/web/public/learning/prefix-sum-dark.png differ diff --git a/apps/web/public/learning/prefix-sum-light.gif b/apps/web/public/learning/prefix-sum-light.gif index 3c2416f..9b4be5b 100644 Binary files a/apps/web/public/learning/prefix-sum-light.gif and b/apps/web/public/learning/prefix-sum-light.gif differ diff --git a/apps/web/public/learning/prefix-sum-light.png b/apps/web/public/learning/prefix-sum-light.png index 077f7dc..a02f2d9 100644 Binary files a/apps/web/public/learning/prefix-sum-light.png and b/apps/web/public/learning/prefix-sum-light.png differ diff --git a/apps/web/public/learning/queue-dark.gif b/apps/web/public/learning/queue-dark.gif index 504251d..ebc6467 100644 Binary files a/apps/web/public/learning/queue-dark.gif and b/apps/web/public/learning/queue-dark.gif differ diff --git a/apps/web/public/learning/queue-dark.png b/apps/web/public/learning/queue-dark.png index a7d6980..42a9398 100644 Binary files a/apps/web/public/learning/queue-dark.png and b/apps/web/public/learning/queue-dark.png differ diff --git a/apps/web/public/learning/queue-light.gif b/apps/web/public/learning/queue-light.gif index 2c2c2ea..62b0b5f 100644 Binary files a/apps/web/public/learning/queue-light.gif and b/apps/web/public/learning/queue-light.gif differ diff --git a/apps/web/public/learning/queue-light.png b/apps/web/public/learning/queue-light.png index d9685a4..33bce1c 100644 Binary files a/apps/web/public/learning/queue-light.png and b/apps/web/public/learning/queue-light.png differ diff --git a/apps/web/public/learning/quick-sort-dark.gif b/apps/web/public/learning/quick-sort-dark.gif index 396a590..65d1270 100644 Binary files a/apps/web/public/learning/quick-sort-dark.gif and b/apps/web/public/learning/quick-sort-dark.gif differ diff --git a/apps/web/public/learning/quick-sort-dark.png b/apps/web/public/learning/quick-sort-dark.png index 0b7a60c..8484e33 100644 Binary files a/apps/web/public/learning/quick-sort-dark.png and b/apps/web/public/learning/quick-sort-dark.png differ diff --git a/apps/web/public/learning/quick-sort-light.gif b/apps/web/public/learning/quick-sort-light.gif index a104c99..35fa3d2 100644 Binary files a/apps/web/public/learning/quick-sort-light.gif and b/apps/web/public/learning/quick-sort-light.gif differ diff --git a/apps/web/public/learning/quick-sort-light.png b/apps/web/public/learning/quick-sort-light.png index 25f8099..27c3dc1 100644 Binary files a/apps/web/public/learning/quick-sort-light.png and b/apps/web/public/learning/quick-sort-light.png differ diff --git a/apps/web/public/learning/recursion-dark.gif b/apps/web/public/learning/recursion-dark.gif index b00a6f3..9e1b15a 100644 Binary files a/apps/web/public/learning/recursion-dark.gif and b/apps/web/public/learning/recursion-dark.gif differ diff --git a/apps/web/public/learning/recursion-dark.png b/apps/web/public/learning/recursion-dark.png index 6bd85ac..6b283ff 100644 Binary files a/apps/web/public/learning/recursion-dark.png and b/apps/web/public/learning/recursion-dark.png differ diff --git a/apps/web/public/learning/recursion-light.gif b/apps/web/public/learning/recursion-light.gif index 12ecfc1..d7dd52a 100644 Binary files a/apps/web/public/learning/recursion-light.gif and b/apps/web/public/learning/recursion-light.gif differ diff --git a/apps/web/public/learning/recursion-light.png b/apps/web/public/learning/recursion-light.png index 21f9b09..160b473 100644 Binary files a/apps/web/public/learning/recursion-light.png and b/apps/web/public/learning/recursion-light.png differ diff --git a/apps/web/public/learning/scc-dark.gif b/apps/web/public/learning/scc-dark.gif index 00a6812..4f5abf4 100644 Binary files a/apps/web/public/learning/scc-dark.gif and b/apps/web/public/learning/scc-dark.gif differ diff --git a/apps/web/public/learning/scc-dark.png b/apps/web/public/learning/scc-dark.png index d547e37..e8e1e63 100644 Binary files a/apps/web/public/learning/scc-dark.png and b/apps/web/public/learning/scc-dark.png differ diff --git a/apps/web/public/learning/scc-light.gif b/apps/web/public/learning/scc-light.gif index 66df03a..64e3545 100644 Binary files a/apps/web/public/learning/scc-light.gif and b/apps/web/public/learning/scc-light.gif differ diff --git a/apps/web/public/learning/scc-light.png b/apps/web/public/learning/scc-light.png index c50e57d..c82e533 100644 Binary files a/apps/web/public/learning/scc-light.png and b/apps/web/public/learning/scc-light.png differ diff --git a/apps/web/public/learning/segment-tree-dark.gif b/apps/web/public/learning/segment-tree-dark.gif index b33b9db..79451b6 100644 Binary files a/apps/web/public/learning/segment-tree-dark.gif and b/apps/web/public/learning/segment-tree-dark.gif differ diff --git a/apps/web/public/learning/segment-tree-dark.png b/apps/web/public/learning/segment-tree-dark.png index 9bec426..d36fd66 100644 Binary files a/apps/web/public/learning/segment-tree-dark.png and b/apps/web/public/learning/segment-tree-dark.png differ diff --git a/apps/web/public/learning/segment-tree-light.gif b/apps/web/public/learning/segment-tree-light.gif index 3ba18cc..d458e12 100644 Binary files a/apps/web/public/learning/segment-tree-light.gif and b/apps/web/public/learning/segment-tree-light.gif differ diff --git a/apps/web/public/learning/segment-tree-light.png b/apps/web/public/learning/segment-tree-light.png index 2793d77..db0f1f7 100644 Binary files a/apps/web/public/learning/segment-tree-light.png and b/apps/web/public/learning/segment-tree-light.png differ diff --git a/apps/web/public/learning/selection-sort-dark.gif b/apps/web/public/learning/selection-sort-dark.gif index 02e7279..8d8d721 100644 Binary files a/apps/web/public/learning/selection-sort-dark.gif and b/apps/web/public/learning/selection-sort-dark.gif differ diff --git a/apps/web/public/learning/selection-sort-dark.png b/apps/web/public/learning/selection-sort-dark.png index 570bbee..9ed29a9 100644 Binary files a/apps/web/public/learning/selection-sort-dark.png and b/apps/web/public/learning/selection-sort-dark.png differ diff --git a/apps/web/public/learning/selection-sort-light.gif b/apps/web/public/learning/selection-sort-light.gif index a21cd27..9b73501 100644 Binary files a/apps/web/public/learning/selection-sort-light.gif and b/apps/web/public/learning/selection-sort-light.gif differ diff --git a/apps/web/public/learning/selection-sort-light.png b/apps/web/public/learning/selection-sort-light.png index 90bd160..bc0829a 100644 Binary files a/apps/web/public/learning/selection-sort-light.png and b/apps/web/public/learning/selection-sort-light.png differ diff --git a/apps/web/public/learning/sliding-window-dark.gif b/apps/web/public/learning/sliding-window-dark.gif index 0bb59d8..6a359d9 100644 Binary files a/apps/web/public/learning/sliding-window-dark.gif and b/apps/web/public/learning/sliding-window-dark.gif differ diff --git a/apps/web/public/learning/sliding-window-dark.png b/apps/web/public/learning/sliding-window-dark.png index a2c0647..985c5e4 100644 Binary files a/apps/web/public/learning/sliding-window-dark.png and b/apps/web/public/learning/sliding-window-dark.png differ diff --git a/apps/web/public/learning/sliding-window-light.gif b/apps/web/public/learning/sliding-window-light.gif index 01a1cf9..1ca86fd 100644 Binary files a/apps/web/public/learning/sliding-window-light.gif and b/apps/web/public/learning/sliding-window-light.gif differ diff --git a/apps/web/public/learning/sliding-window-light.png b/apps/web/public/learning/sliding-window-light.png index 7004d48..829a3c7 100644 Binary files a/apps/web/public/learning/sliding-window-light.png and b/apps/web/public/learning/sliding-window-light.png differ diff --git a/apps/web/public/learning/sorting-dark.gif b/apps/web/public/learning/sorting-dark.gif index dde586d..88c03db 100644 Binary files a/apps/web/public/learning/sorting-dark.gif and b/apps/web/public/learning/sorting-dark.gif differ diff --git a/apps/web/public/learning/sorting-dark.png b/apps/web/public/learning/sorting-dark.png index 4db216a..561e586 100644 Binary files a/apps/web/public/learning/sorting-dark.png and b/apps/web/public/learning/sorting-dark.png differ diff --git a/apps/web/public/learning/sorting-light.gif b/apps/web/public/learning/sorting-light.gif index 41ec8bc..f86b27d 100644 Binary files a/apps/web/public/learning/sorting-light.gif and b/apps/web/public/learning/sorting-light.gif differ diff --git a/apps/web/public/learning/sorting-light.png b/apps/web/public/learning/sorting-light.png index ecf6841..1c43a12 100644 Binary files a/apps/web/public/learning/sorting-light.png and b/apps/web/public/learning/sorting-light.png differ diff --git a/apps/web/public/learning/stack-dark.gif b/apps/web/public/learning/stack-dark.gif index 4bc1e10..060e0f0 100644 Binary files a/apps/web/public/learning/stack-dark.gif and b/apps/web/public/learning/stack-dark.gif differ diff --git a/apps/web/public/learning/stack-dark.png b/apps/web/public/learning/stack-dark.png index a7d6980..42a9398 100644 Binary files a/apps/web/public/learning/stack-dark.png and b/apps/web/public/learning/stack-dark.png differ diff --git a/apps/web/public/learning/stack-light.gif b/apps/web/public/learning/stack-light.gif index 3a2d760..c8158c0 100644 Binary files a/apps/web/public/learning/stack-light.gif and b/apps/web/public/learning/stack-light.gif differ diff --git a/apps/web/public/learning/stack-light.png b/apps/web/public/learning/stack-light.png index d9685a4..33bce1c 100644 Binary files a/apps/web/public/learning/stack-light.png and b/apps/web/public/learning/stack-light.png differ diff --git a/apps/web/public/learning/tabulation-dark.gif b/apps/web/public/learning/tabulation-dark.gif index 3095ee9..4aa0044 100644 Binary files a/apps/web/public/learning/tabulation-dark.gif and b/apps/web/public/learning/tabulation-dark.gif differ diff --git a/apps/web/public/learning/tabulation-dark.png b/apps/web/public/learning/tabulation-dark.png index cad3af1..39a6f7a 100644 Binary files a/apps/web/public/learning/tabulation-dark.png and b/apps/web/public/learning/tabulation-dark.png differ diff --git a/apps/web/public/learning/tabulation-light.gif b/apps/web/public/learning/tabulation-light.gif index 11f74ce..c27f5a7 100644 Binary files a/apps/web/public/learning/tabulation-light.gif and b/apps/web/public/learning/tabulation-light.gif differ diff --git a/apps/web/public/learning/tabulation-light.png b/apps/web/public/learning/tabulation-light.png index fd1d981..df327cf 100644 Binary files a/apps/web/public/learning/tabulation-light.png and b/apps/web/public/learning/tabulation-light.png differ diff --git a/apps/web/public/learning/topological-sort-dark.gif b/apps/web/public/learning/topological-sort-dark.gif index 59cf852..9df0489 100644 Binary files a/apps/web/public/learning/topological-sort-dark.gif and b/apps/web/public/learning/topological-sort-dark.gif differ diff --git a/apps/web/public/learning/topological-sort-dark.png b/apps/web/public/learning/topological-sort-dark.png index ced50d5..4d9de34 100644 Binary files a/apps/web/public/learning/topological-sort-dark.png and b/apps/web/public/learning/topological-sort-dark.png differ diff --git a/apps/web/public/learning/topological-sort-light.gif b/apps/web/public/learning/topological-sort-light.gif index f70d6bd..a9d2f8c 100644 Binary files a/apps/web/public/learning/topological-sort-light.gif and b/apps/web/public/learning/topological-sort-light.gif differ diff --git a/apps/web/public/learning/topological-sort-light.png b/apps/web/public/learning/topological-sort-light.png index 9b3f844..aae88a0 100644 Binary files a/apps/web/public/learning/topological-sort-light.png and b/apps/web/public/learning/topological-sort-light.png differ diff --git a/apps/web/public/learning/tree-dark.gif b/apps/web/public/learning/tree-dark.gif index a750be8..1fe0b83 100644 Binary files a/apps/web/public/learning/tree-dark.gif and b/apps/web/public/learning/tree-dark.gif differ diff --git a/apps/web/public/learning/tree-dark.png b/apps/web/public/learning/tree-dark.png index 3093f87..939517d 100644 Binary files a/apps/web/public/learning/tree-dark.png and b/apps/web/public/learning/tree-dark.png differ diff --git a/apps/web/public/learning/tree-light.gif b/apps/web/public/learning/tree-light.gif index b91b31c..0973b3b 100644 Binary files a/apps/web/public/learning/tree-light.gif and b/apps/web/public/learning/tree-light.gif differ diff --git a/apps/web/public/learning/tree-light.png b/apps/web/public/learning/tree-light.png index 3b070d5..85b56c1 100644 Binary files a/apps/web/public/learning/tree-light.png and b/apps/web/public/learning/tree-light.png differ diff --git a/apps/web/public/learning/trie-dark.gif b/apps/web/public/learning/trie-dark.gif index 206271e..e88f979 100644 Binary files a/apps/web/public/learning/trie-dark.gif and b/apps/web/public/learning/trie-dark.gif differ diff --git a/apps/web/public/learning/trie-dark.png b/apps/web/public/learning/trie-dark.png index 517d599..64af46f 100644 Binary files a/apps/web/public/learning/trie-dark.png and b/apps/web/public/learning/trie-dark.png differ diff --git a/apps/web/public/learning/trie-light.gif b/apps/web/public/learning/trie-light.gif index f4c85bd..5a8602e 100644 Binary files a/apps/web/public/learning/trie-light.gif and b/apps/web/public/learning/trie-light.gif differ diff --git a/apps/web/public/learning/trie-light.png b/apps/web/public/learning/trie-light.png index c2da289..a9bb53e 100644 Binary files a/apps/web/public/learning/trie-light.png and b/apps/web/public/learning/trie-light.png differ diff --git a/apps/web/public/learning/two-pointer-dark.gif b/apps/web/public/learning/two-pointer-dark.gif index f985526..8fc6bf6 100644 Binary files a/apps/web/public/learning/two-pointer-dark.gif and b/apps/web/public/learning/two-pointer-dark.gif differ diff --git a/apps/web/public/learning/two-pointer-dark.png b/apps/web/public/learning/two-pointer-dark.png index 7bcf64e..28c3c3f 100644 Binary files a/apps/web/public/learning/two-pointer-dark.png and b/apps/web/public/learning/two-pointer-dark.png differ diff --git a/apps/web/public/learning/two-pointer-light.gif b/apps/web/public/learning/two-pointer-light.gif index 8195565..7baabdc 100644 Binary files a/apps/web/public/learning/two-pointer-light.gif and b/apps/web/public/learning/two-pointer-light.gif differ diff --git a/apps/web/public/learning/two-pointer-light.png b/apps/web/public/learning/two-pointer-light.png index e268917..6b1ad95 100644 Binary files a/apps/web/public/learning/two-pointer-light.png and b/apps/web/public/learning/two-pointer-light.png differ diff --git a/apps/web/public/learning/union-find-dark.gif b/apps/web/public/learning/union-find-dark.gif index f69e143..f61f78f 100644 Binary files a/apps/web/public/learning/union-find-dark.gif and b/apps/web/public/learning/union-find-dark.gif differ diff --git a/apps/web/public/learning/union-find-dark.png b/apps/web/public/learning/union-find-dark.png index 7c13764..461fc4c 100644 Binary files a/apps/web/public/learning/union-find-dark.png and b/apps/web/public/learning/union-find-dark.png differ diff --git a/apps/web/public/learning/union-find-light.gif b/apps/web/public/learning/union-find-light.gif index d587ca5..61c3fc1 100644 Binary files a/apps/web/public/learning/union-find-light.gif and b/apps/web/public/learning/union-find-light.gif differ diff --git a/apps/web/public/learning/union-find-light.png b/apps/web/public/learning/union-find-light.png index 85477d3..a4e86fa 100644 Binary files a/apps/web/public/learning/union-find-light.png and b/apps/web/public/learning/union-find-light.png differ diff --git a/apps/web/src/BillingScreens.tsx b/apps/web/src/BillingScreens.tsx new file mode 100644 index 0000000..3e2a1e9 --- /dev/null +++ b/apps/web/src/BillingScreens.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { go } from "./router.js"; + +/** Presentation only. Never collects payment credentials or grants review credits. */ +export function BillingScreens({ + checkout, + usage, +}: { + checkout: boolean; + usage: any; +}) { + return ( +
+
+ + {checkout ? "CHECKOUT PREVIEW" : "MEMBERSHIP"} + +

+ {checkout ? "구독 결제 안내" : "배움은 무료로, 리뷰는 필요할 때"} +

+

+ {checkout + ? "상품과 결제 조건을 확인하는 화면이에요. 아직 결제할 수 없어요." + : "개념 학습부터 코드 실행·복잡도 분석까지 무료로 이용하세요."} +

+
+ {checkout ? ( +
+
+

1. 구독 상품

+

Algostep 리뷰 구독

+

LLM이 논리 오류, 효율 개선, 대안 코드를 설명해요.

+
+
월 구독료
+
확정 전
+
월 리뷰 제공량
+
확정 전
+
정기 결제일
+
상품 출시 시 안내
+
환불·해지 조건
+
상품 출시 시 안내
+
+
+

2. 결제 수단

+
결제 수단 준비 중
+

+ PG 연동 후 지원 수단을 안내할 예정이에요. 지금은 카드 정보나 결제 + 정보를 입력받지 않아요. +

+
+ +
+ ) : ( + <> +
+
+ 기본 이용 +

무료

+

+ ₩0 / 계속 무료 +

+
    +
  • 200개 개념과 시각화 학습
  • +
  • 단계별 상태 예측
  • +
  • 웹에서 코드 작성·실행
  • +
  • 시간·공간 복잡도 분석
  • +
  • LLM 코드 리뷰 월 3건
  • +
  • 기록 저장과 PDF 내보내기
  • +
+ +
+
+ 출시 준비 중 +

리뷰 구독

+

가격 준비 중

+

+ 무료 리뷰를 모두 사용한 뒤에도 코드에 대한 설명을 받을 수 있는 + 상품을 준비하고 있어요. +

+
    +
  • 논리 오류와 원인 설명
  • +
  • 시간·공간 효율 개선 제안
  • +
  • 대안 코드와 접근 방법
  • +
+

+ 가격, 월 제공량, 정기 결제 및 환불 조건은 아직 정해지지 + 않았어요. +

+ +
+
+
+

무료 리뷰 이용 안내

+ {usage && ( +

+ 이번 달 남은 무료 리뷰:{" "} + + {Math.max( + 0, + 3 - (usage.consumed ?? 0) - (usage.reserved ?? 0), + )} + 건 + +

+ )} +

+ 매월 1일 00:00 KST에 3건이 새로 제공되고, 남은 횟수는 이월되지 + 않아요. 코드 작성·실행과 기본 분석에는 리뷰 횟수가 차감되지 + 않아요. +

+

+ 모바일에서는 개념과 예측 학습을 이용하고, 코드 작성과 실행은 + 웹에서 진행하세요. +

+

현재 결제 기능은 준비 중이며 유료 구독이 활성화되지 않아요.

+
+ + )} +
+ ); +} diff --git a/apps/web/src/DiagramCanvas.tsx b/apps/web/src/DiagramCanvas.tsx index 9289115..09e763b 100644 --- a/apps/web/src/DiagramCanvas.tsx +++ b/apps/web/src/DiagramCanvas.tsx @@ -389,7 +389,19 @@ export function DiagramCanvas({ sum + (c.charCodeAt(0) > 255 ? 1 : 0.62), + 0, + ), + ), + ), + }} > {n.label.length > 22 ? n.label.slice(0, 21) + "…" : n.label} diff --git a/apps/web/src/LearningFlow.tsx b/apps/web/src/LearningFlow.tsx index 60ee047..c5bf504 100644 --- a/apps/web/src/LearningFlow.tsx +++ b/apps/web/src/LearningFlow.tsx @@ -1,10 +1,14 @@ +import { + focusedById, + practiceTopic, +} from "../../server/src/focused-concepts.js"; import React, { useEffect, useMemo, useState } from "react"; import { checkpoints, matchesAnswer, type LearningFrame, } from "./prediction-model.js"; -import { Structure } from "./visualization.js"; +import { Structure, LessonDiagram } from "./visualization.js"; import { ObjectGraph } from "./ObjectGraph.js"; import { DiagramCanvas, @@ -62,43 +66,111 @@ export function ExampleAnimation({ ); const [version, setVersion] = useState(0); const mode = theme === "dark" ? "dark" : "light"; + const [modeTab, setModeTab] = useState("animation"); + const selectMode = (mode: string) => { + setModeTab(mode); + track("EXAMPLE_MODE_SELECTED", { topic, mode }); + }; return (

움직임으로 살펴보기

예시 입력에서 상태가 바뀌는 과정을 살펴보세요.

- {`${title} -
- - + ))} +
+
- 처음부터 보기 - + {modeTab === "manual" ? ( + + ) : ( + <> +
+ ● 현재 비교·변경 중 + ○ 나머지 값 +
+ {`${title} +
+ + +
+

+ 자동 재생을 끄면 첫 장면을 표시해요. 세로 탭의 단계별 예제에서 + 각 상태를 자세히 확인할 수 있어요. +

+ + )} +
-

- 자동 재생을 끄면 첫 장면을 표시해요. 아래 단계별 예제에서 각 상태를 - 자세히 확인할 수 있어요. -

); } @@ -235,7 +307,7 @@ export function Prediction({ answerEdges = vars.graph.flatMap((row: number[], i: number) => row.filter((j) => i < j).map((j) => ({ from: String(i), to: String(j) })), ); - } else if (topic.id === "tree" && q.choices) { + } else if (practiceTopic(topic.id) === "tree" && q.choices) { answerNodes = q.choices.map((c, i) => { const depth = Math.floor(Math.log2(i + 1)); return { @@ -247,12 +319,10 @@ export function Prediction({ state: state.answer === String(i) ? "active" : "", }; }); - answerEdges = answerNodes - .slice(1) - .map((n) => ({ - from: String(Math.floor((Number(n.id) - 1) / 2)), - to: n.id, - })); + answerEdges = answerNodes.slice(1).map((n) => ({ + from: String(Math.floor((Number(n.id) - 1) / 2)), + to: n.id, + })); } const correct = state.result === "correct" && matchesAnswer(state.answer, q.answer); @@ -268,12 +338,12 @@ export function Prediction({ 현재 상태 · {step} / {questions.length}

{q.current.note}

- +

다음 단계에서 바뀔 {label}을 예측해 보세요.

내가 만드는 다음 상태

-

{label}은 어떻게 될까요?

+

다음 단계의 {label}: 어떤 값이 될까요?

{q.field === "active" && (

{q.current.advanced?.nodes @@ -363,7 +433,7 @@ export function Prediction({ {correct && (

다음 상태 확인 - +
)}
diff --git a/apps/web/src/concept-costs.ts b/apps/web/src/concept-costs.ts new file mode 100644 index 0000000..4ffae89 --- /dev/null +++ b/apps/web/src/concept-costs.ts @@ -0,0 +1,186 @@ +import { + focusedById, + practiceTopic, +} from "../../server/src/focused-concepts.js"; +type Cost = [time: string, space: string, scope: string]; +const base: Record = { + array: ["O(1)", "O(1)", "인덱스를 아는 칸의 조회·갱신"], + "linked-list": ["O(1)", "O(1)", "앞 노드 참조를 아는 삽입·삭제"], + stack: ["O(1)", "O(1)", "연결 구조의 push·pop 한 번"], + queue: ["O(1)", "O(1)", "양끝 참조가 있는 큐의 삽입·삭제"], + hash: ["평균 O(1), 최악 O(n)", "O(1)", "해시 테이블 조회 한 번"], + heap: ["O(log n)", "O(1)", "반복문으로 구현한 힙 삽입·삭제"], + "union-find": [ + "상각 O(α(n))", + "O(n)", + "크기 병합 + 경로 압축, 전체 부모 배열 포함", + ], + trie: ["O(L)", "O(L)", "길이 L 문자열 삽입, 새 노드의 최악 추가 공간"], + "linear-search": ["O(n)", "O(1)", "정렬되지 않은 배열 탐색"], + "binary-search": ["O(log n)", "O(1)", "이미 정렬된 배열의 반복 이진 탐색"], + "brute-force": ["O(n²)", "O(1)", "모든 쌍을 검사하고 최선 값만 보관"], + backtracking: [ + "O(n·2ⁿ)", + "O(n)", + "부분집합을 하나씩 생성, 전체 결과 저장 제외", + ], + "two-pointer": ["O(n)", "O(1)", "이미 정렬된 배열의 양끝 포인터 탐색"], + "sliding-window": ["O(n)", "O(1)", "고정 길이 구간 합, 최댓값만 보관"], + sorting: ["O(n log n)", "O(n)", "배열 병합 정렬을 선택한 경우"], + "bubble-sort": ["O(n²)", "O(1)", "버블 정렬의 최악 입력"], + "selection-sort": ["O(n²)", "O(1)", "선택 정렬 전체 과정"], + "insertion-sort": ["O(n²)", "O(1)", "삽입 정렬의 역순 입력"], + "merge-sort": ["O(n log n)", "O(n)", "배열 병합 정렬, 보조 배열 포함"], + "quick-sort": [ + "평균 O(n log n), 최악 O(n²)", + "평균 O(log n), 최악 O(n)", + "제자리 분할, 재귀 스택 포함", + ], + tree: ["O(n)", "O(h)", "트리 재귀 순회, 결과 저장 제외; h는 높이"], + bst: ["O(h), 최악 O(n)", "O(1)", "높이 h인 BST의 반복 탐색"], + "balanced-tree": ["O(log n)", "O(log n)", "AVL 삽입, 재귀 스택 포함"], + "segment-tree": [ + "O(log n)", + "O(n)", + "점 갱신·구간 질의 한 번, 트리 저장 포함", + ], + fenwick: ["O(log n)", "O(n)", "점 갱신·누적 합 질의 한 번, 트리 저장 포함"], + lca: [ + "전처리 O(n log n), 질의 O(log n)", + "O(n log n)", + "이진 점프 테이블 포함", + ], + "graph-list": ["O(V+E)", "O(V+E)", "인접 리스트 전체 구성, 그래프 저장 포함"], + "graph-matrix": ["O(V²)", "O(V²)", "인접 행렬 초기화, 행렬 저장 포함"], + bfs: ["O(V+E)", "O(V)", "인접 리스트 BFS, 그래프 입력 저장 제외"], + dfs: ["O(V+E)", "O(V)", "인접 리스트 DFS, 방문 배열·스택 포함"], + dijkstra: [ + "O((V+E) log V)", + "O(V+E)", + "단순 그래프·음이 아닌 간선, 이진 힙의 중복 항목 포함", + ], + "bellman-ford": ["O(VE)", "O(V)", "간선 입력 저장 제외, 거리 배열 포함"], + "floyd-warshall": ["O(V³)", "O(V²)", "거리 행렬 포함"], + mst: ["O(E log E)", "O(V+E)", "Kruskal 정렬·부모 배열 포함"], + "topological-sort": [ + "O(V+E)", + "O(V)", + "Kahn 알고리즘, 그래프 입력 저장 제외", + ], + scc: ["O(V+E)", "O(V+E)", "Kosaraju, 역방향 그래프 포함"], + flow: ["O(VE²)", "O(V+E)", "Edmonds–Karp, 잔여 인접 리스트 포함"], + recursion: ["O(n)", "O(n)", "팩토리얼 재귀, 정수 연산을 상수 비용으로 가정"], + "prefix-sum": ["전처리 O(n), 질의 O(1)", "O(n)", "누적 합 배열 포함"], + greedy: ["O(n log n)", "O(n)", "종료 시각 기준 회의 선택, 정렬 복사 포함"], + dp: ["O(n)", "O(n)", "표를 사용하는 피보나치 예제"], + memoization: ["O(n)", "O(n)", "피보나치 캐시·재귀 스택 포함"], + tabulation: ["O(n)", "O(n)", "피보나치 상향식 전체 표"], + "advanced-dp": [ + "O(k n log n)", + "O(k n)", + "단조 최적점 가정, k구간 분할 DP 전체 표", + ], + kmp: ["O(n+m)", "O(m)", "본문 길이 n, 패턴 길이 m; 일치 위치 저장 제외"], + geometry: ["O(n log n)", "O(n)", "단조 사슬 볼록 껍질, 정렬 포함"], + fft: ["O(N log N)", "O(N)", "0 패딩 후 변환 길이 N, 복소수 연산 기준"], +}; +const special: Record = { + "array-insert": ["O(n)", "O(1)", "남는 용량이 있는 배열의 중간 삽입"], + "array-traverse": ["O(n)", "O(1)", "배열 합 누적"], + "stack-parentheses": ["O(n)", "O(n)", "문자열 전체 괄호 검사"], + "stack-monotonic": ["O(n)", "O(n)", "모든 원소의 다음 큰 값 찾기"], + "queue-zero-one": [ + "O(V+E)", + "O(V)", + "0·1 가중치 그래프, 입력 간선 저장 제외", + ], + "hash-frequency": ["평균 O(n)", "O(k)", "서로 다른 키 k개의 빈도 집계"], + "hash-set": ["평균 O(n)", "O(k)", "n개 입력 중 고유한 k개 키 저장"], + "hash-load-factor": [ + "O(1)", + "O(1)", + "항목·버킷 수를 알 때 적재율 계산; 재해싱 제외", + ], + "heap-top-k": ["O(n log k)", "O(k)", "n개 입력에서 상위 k개 유지"], + "trie-lookup": ["O(L)", "O(1)", "길이 L 문자열 반복 검색, 기존 트라이 제외"], + "trie-terminal": ["O(1)", "O(1)", "도착 노드의 종료 표시 확인"], + "brute-force-subsets": [ + "O(n·2ⁿ)", + "O(n)", + "부분집합 하나씩 생성, 전체 결과 보관 제외", + ], + "two-pointer-merge": ["O(n+m)", "O(n+m)", "두 입력을 새 결과 배열로 병합"], + "sliding-window-counts": [ + "평균 O(n)", + "O(k)", + "해시 빈도표, 구간의 서로 다른 문자 k개", + ], + "sorting-compression": ["O(n log n)", "O(n)", "정렬과 순위 매핑 포함"], + "sorting-deduplicate": ["O(n log n)", "O(n)", "정렬 및 고유 결과 배열 생성"], + "bubble-sort-early-exit": [ + "최선 O(n), 최악 O(n²)", + "O(1)", + "교환 없는 순회에서 조기 종료", + ], + "bubble-sort-pass": ["O(n)", "O(1)", "한 번의 순회만 계산"], + "selection-sort-minimum": ["O(n)", "O(1)", "남은 구간 최솟값 찾기 한 번"], + "insertion-sort-shift": ["O(n)", "O(1)", "한 원소를 앞 구간에 삽입"], + "insertion-sort-nearly": [ + "최선 O(n), 최악 O(n²)", + "O(1)", + "이동 횟수는 역전쌍 수에 비례", + ], + "merge-sort-merge-step": ["O(n+m)", "O(n+m)", "두 구간을 보조 배열로 병합"], + "quick-sort-partition": ["O(n)", "O(1)", "제자리 분할 한 번"], + "quick-sort-three-way": ["O(n)", "O(1)", "제자리 3방향 분할 한 번"], + "tree-children": ["O(1)", "O(1)", "자식 인덱스 계산 한 번"], + "balanced-tree-height": ["O(1)", "O(1)", "저장된 높이에서 균형 인수 계산"], + "balanced-tree-rotation": ["O(1)", "O(1)", "높이 갱신 포함 단일 회전 한 번"], + "balanced-tree-double": ["O(1)", "O(1)", "두 번의 단일 회전"], + "segment-tree-combine": ["O(1)", "O(1)", "두 자식의 합을 결합하는 한 연산"], + "fenwick-lowbit": ["O(1)", "O(1)", "고정 폭 정수의 비트 연산"], + "lca-distance": ["O(1)", "O(1)", "깊이와 LCA를 이미 구한 후 거리 계산"], + "graph-matrix-lookup": ["O(1)", "O(1)", "이미 구성한 행렬의 한 칸 조회"], + "graph-matrix-symmetry": ["O(1)", "O(1)", "무방향 간선 한 개 등록"], + "dijkstra-relax": [ + "O(1)", + "O(1)", + "거리 후보 비교·배열 갱신만; 힙 삽입 제외", + ], + "dijkstra-stale": ["O(1)", "O(1)", "이미 꺼낸 큐 항목의 유효성 비교"], + "tabulation-rolling": ["O(n)", "O(1)", "피보나치 두 상태만 유지"], + "tabulation-knapsack-order": ["O(nW)", "O(W)", "물품 n개, 용량 W인 0/1 배낭"], + "prefix-sum-two-dimensional": [ + "전처리 O(RC), 질의 O(1)", + "O(RC)", + "R행 C열 누적 합", + ], + "prefix-sum-difference": [ + "구간 갱신 O(1), 복원 O(n)", + "O(n)", + "차분 배열 저장 포함", + ], + "kmp-prefix-function": ["O(m)", "O(m)", "길이 m 패턴의 접두사 함수 생성"], + "geometry-cross": ["O(1)", "O(1)", "고정 폭 좌표 세 점의 외적"], + "geometry-collinear": ["O(1)", "O(1)", "세 점의 일직선 여부 확인"], + "fft-convolution": [ + "O(nm)", + "O(n+m)", + "계수 쌍을 직접 곱하는 정의 그대로의 구현", + ], + "fft-padding": ["O(N)", "O(N)", "새 길이 N 배열에 복사하고 0 채우기"], + "fft-rounding": ["O(N)", "O(1)", "역변환 결과 N개를 제자리 반올림"], +}; +export function conceptCost(id: string): Cost { + const cost = special[id] ?? + base[practiceTopic(id)] ?? [ + "문제 조건 참고", + "문제 조건 참고", + "게시된 문제의 분석 기준을 확인하세요", + ]; + return [ + cost[0], + cost[1], + `${focusedById[id] && !special[id] ? "관련 기본 연산: " : ""}${cost[2]}`, + ]; +} diff --git a/apps/web/src/concept-uses.ts b/apps/web/src/concept-uses.ts index fd0e65c..098380b 100644 --- a/apps/web/src/concept-uses.ts +++ b/apps/web/src/concept-uses.ts @@ -1,3 +1,4 @@ +import { focusedConcepts } from "../../server/src/focused-concepts.js"; /** [When to use the concept, a concrete application]. */ export const conceptUses: Record = { array: [ @@ -189,3 +190,6 @@ export const conceptUses: Record = { "다항식 계수 곱, 신호의 주파수 분석, 합으로 만들 수 있는 값의 개수 계산에 활용해요. 부동소수점 오차와 패딩 길이를 고려해야 해요.", ], }; + +for (const c of focusedConcepts) + conceptUses[c.id] = [conceptUses[c.parent][0], c.example]; diff --git a/apps/web/src/learning-categories.ts b/apps/web/src/learning-categories.ts index 166a7d3..d212e1a 100644 --- a/apps/web/src/learning-categories.ts +++ b/apps/web/src/learning-categories.ts @@ -1,3 +1,4 @@ +import { practiceTopic } from "../../server/src/focused-concepts.js"; const categories: [string, string[]][] = [ ["기초와 설계", ["recursion", "prefix-sum", "greedy"]], [ @@ -58,7 +59,8 @@ const categories: [string, string[]][] = [ ]; export function learningCategory(topic: { id: string; category: string }) { return ( - categories.find(([, ids]) => ids.includes(topic.id))?.[0] ?? topic.category + categories.find(([, ids]) => ids.includes(practiceTopic(topic.id)))?.[0] ?? + topic.category ); } export function groupLearningTopics< diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9372bf3..43e0014 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,3 +1,10 @@ +import { conceptCost } from "./concept-costs.js"; +import { + focusedById, + focusedConcepts, + practiceTopic, +} from "../../server/src/focused-concepts.js"; +import { BillingScreens } from "./BillingScreens.js"; import { track, setAnalyticsUser, @@ -128,6 +135,7 @@ function App() { history: "/records", support: "/support", admin: "/admin", + plans: "/plans", lesson: `/learn/${id}/concept`, practice: `/learn/${id}/predict/1`, challenges: `/learn/${id}/code`, @@ -149,7 +157,7 @@ function App() { await save(); const problemId = route.problemId ?? - problems.find((p) => p.topic_id === route.topicId)?.id; + problems.find((p) => p.topic_id === practiceTopic(route.topicId))?.id; if (!route.recordId && !problemId) throw new ApiError("NOT_FOUND", "문제를 찾을 수 없어요.", 404); if ( @@ -157,7 +165,9 @@ function App() { (!problems.some((p) => p.id === problemId) || (route.topicId && !problems.some( - (p) => p.id === problemId && p.topic_id === route.topicId, + (p) => + p.id === problemId && + p.topic_id === practiceTopic(route.topicId), ))) ) throw new ApiError( @@ -188,7 +198,9 @@ function App() { (problemId && r.problem_id !== problemId) || (route.topicId && !problems.some( - (p) => p.id === r.problem_id && p.topic_id === route.topicId, + (p) => + p.id === r.problem_id && + p.topic_id === practiceTopic(route.topicId), )) ) throw new ApiError("NOT_FOUND", "기록과 문제가 일치하지 않아요.", 404); @@ -693,6 +705,14 @@ function App() { > 문의하기 + {user.isAdmin && !nativeAndroid && ( )} + {["plans", "checkout"].includes(actualPage) && ( + + )} {actualPage === "home" && ( <> {mobile ? ( @@ -1064,6 +1089,7 @@ function App() { key={v} > {v} + [{i}] ))} @@ -1074,7 +1100,9 @@ function App() {
LEARNING PATH -

개념별 학습

+

+ 개념별 학습 {topics.length}개 +

diff --git a/apps/web/src/prediction-model.ts b/apps/web/src/prediction-model.ts index e659038..b9f1d12 100644 --- a/apps/web/src/prediction-model.ts +++ b/apps/web/src/prediction-model.ts @@ -1,3 +1,4 @@ +import { practiceTopic } from "../../server/src/focused-concepts.js"; import { lessonFrames, type LessonFrame } from "./lesson-model.js"; import { lessonExamples } from "./lesson-examples.js"; import { @@ -12,6 +13,7 @@ export type LearningFrame = { advanced?: AdvancedFrame; }; export function learningFrames(topic: string): LearningFrame[] { + topic = practiceTopic(topic); if ((advancedTopics as readonly string[]).includes(topic)) return advancedLesson(topic, 0).frames.map((advanced) => ({ note: advanced.note, @@ -111,7 +113,10 @@ export function checkpoints(topic: string) { changed[0]; if (!field) continue; // Start binary search at its first comparison; sorted-input setup is not a prediction. - if (topic === "binary-search" && current.core?.vars.mid === undefined) + if ( + practiceTopic(topic) === "binary-search" && + current.core?.vars.mid === undefined + ) continue; const answer = after[field]; let choices: { label: string; value: number }[] | undefined; diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 6ca8d74..e362fb3 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -77,5 +77,7 @@ export function readRoute(path: string) { result.page = "admin"; result.section = parts[1] ?? "overview"; } + if (parts.length === 1 && ["plans", "checkout"].includes(parts[0])) + result.page = parts[0]; return result; } diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 21f57bc..9ebd24b 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -546,13 +546,13 @@ nav .nav { white-space: nowrap; } main { - max-width: 1440px; + max-width: 1800px; padding: 48px 48px 64px; margin: auto; min-height: calc(100vh - 170px); } footer { - max-width: 1440px; + max-width: 1800px; padding: 22px 48px; margin: auto; border-top: 1px solid var(--border); @@ -1793,3 +1793,223 @@ body:has(.mobile-navigation) footer { text-overflow: ellipsis; } } + +/* Focused learning: one explanation, one example panel, one primary next action. */ +.lesson-layout { + grid-template-columns: minmax(330px, 0.85fr) minmax(0, 1.65fr); + gap: 32px; +} +.lesson-layout > .prose { + position: static; + max-height: none; + overflow: visible; +} +.lesson-layout .prose p { + line-height: 1.85; +} +.example-tab-layout { + display: grid; + grid-template-columns: 108px minmax(0, 1fr); + gap: 20px; + align-items: start; +} +.example-side-tabs { + display: flex; + flex-direction: column; + gap: 10px; + border-right: 1px solid var(--border); + padding-right: 14px; +} +.example-side-tabs button { + white-space: normal; + width: 100%; + padding: 14px 8px; + min-height: 52px; +} +#example-content { + min-width: 0; +} +#example-content > .panel { + border: 0; + box-shadow: none; + padding: 0; +} +.example-animation img { + width: 100%; + height: auto; + object-fit: contain; +} +.animation-legend { + display: flex; + gap: 20px; + font-size: 13px; + margin: 8px 0; + color: var(--muted); +} +.animation-legend span:first-child { + color: var(--accent); + font-weight: 700; +} +.complexity-table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; +} +.complexity-table caption { + text-align: left; + font-weight: 600; + margin-bottom: 12px; +} +.complexity-table th, +.complexity-table td { + border: 1px solid var(--border); + padding: 12px; + text-align: left; +} +.prose hr, +.billing-page hr { + border: 0; + border-top: 1px solid var(--border); + margin: 28px 0; +} +.hero .cell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; +} +.hero .cell small { + font-size: 12px; +} +.section-heading h2 small { + color: var(--muted); + font-size: 16px; + font-weight: 400; +} +.billing-page { + max-width: 1240px; + margin: auto; +} +.plans-grid, +.checkout-layout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 28px; +} +.plan-card { + display: flex; + flex-direction: column; + align-items: start; + padding: 32px; +} +.plan-card ul { + padding-left: 22px; + line-height: 2.4; + margin-bottom: 28px; +} +.plan-card > button { + margin-top: auto; + width: 100%; +} +.plan-price { + font-size: 28px; + font-weight: 700; +} +.plan-price small { + font-size: 14px; + color: var(--muted); + font-weight: 400; +} +.featured-plan { + border: 2px solid var(--accent); +} +.billing-policy { + margin-top: 28px; +} +.billing-facts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + margin: 24px 0; +} +.billing-facts dd { + margin: 0; + text-align: right; +} +.billing-facts dt { + color: var(--muted); +} +.order-summary { + align-self: start; +} +.order-summary button { + width: 100%; + margin-top: 14px; +} +.payment-placeholder { + border: 1px dashed var(--border); + border-radius: 12px; + padding: 24px; + color: var(--muted); +} +@media (min-width: 1600px) { + main { + padding-left: 48px; + padding-right: 48px; + } + .topic-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} +@media (max-width: 1100px) { + .lesson-layout { + grid-template-columns: minmax(300px, 1fr) minmax(0, 1.4fr); + gap: 20px; + } + .example-tab-layout { + grid-template-columns: 80px minmax(0, 1fr); + gap: 10px; + } + .example-side-tabs { + padding-right: 8px; + } +} +@media (max-width: 849px) { + .lesson-layout, + .plans-grid, + .checkout-layout { + grid-template-columns: 1fr; + } + .example-tab-layout { + grid-template-columns: 74px minmax(0, 1fr); + } + .example-animation { + padding: 16px; + } + .animation-legend { + flex-wrap: wrap; + gap: 6px; + } + .plan-card { + padding: 24px; + } +} + +.example-settings { + margin: 12px 0 20px; + border-bottom: 1px solid var(--border); + padding-bottom: 14px; +} +.example-settings summary { + cursor: pointer; + color: var(--muted); +} +.complexity-table { + font-size: 13px; +} +.complexity-table td, +.complexity-table th { + overflow-wrap: anywhere; + padding: 10px; +} diff --git a/apps/web/src/visualization.tsx b/apps/web/src/visualization.tsx index 2c46965..e3be7da 100644 --- a/apps/web/src/visualization.tsx +++ b/apps/web/src/visualization.tsx @@ -1,3 +1,4 @@ +import { practiceTopic } from "../../server/src/focused-concepts.js"; import { track } from "./analytics.js"; import { DiagramCanvas, graphPositions } from "./DiagramCanvas.js"; import { lessonExamples } from "./lesson-examples.js"; @@ -249,7 +250,7 @@ export function Structure({ vars, topic }: { vars: any; topic: string }) { x: (width * (i - first + 0.5)) / 2 ** level, y: 65 + level * 110, label: String(v), - detail: `인덱스 ${i}`, + detail: `[${i}]`, state: vars.index === i || vars.mid === i ? "active" : "", }; }); @@ -266,7 +267,9 @@ export function Structure({ vars, topic }: { vars: any; topic: string }) { />

{topic === "tree" ? "중위 순회" : "힙 배열"}{" "} - {JSON.stringify(vars.order ?? (topic === "tree" ? [] : values))} + + {JSON.stringify(vars.order ?? (topic === "tree" ? [] : values))} +

{vars.values.length > 31 && (

처음 31개 노드를 표시해요. 전체 값은 변수 보기에서 확인하세요.

@@ -316,6 +319,7 @@ export function Structure({ vars, topic }: { vars: any; topic: string }) { ); } export function LessonDiagram({ topic }: { topic: string }) { + topic = practiceTopic(topic); return (advancedTopics as readonly string[]).includes(topic) ? ( ) : ( @@ -384,80 +388,83 @@ function CoreLessonDiagram({ topic }: { topic: string }) {

{examples[exampleIndex]?.description}

-
{ - e.preventDefault(); - const nums = text - .split(/[,\s]+/) - .filter(Boolean) - .map(Number); - if ( - !nums.length || - nums.length > 8 || - nums.some((n) => !Number.isInteger(n) || Math.abs(n) > 1000) - ) { +
+ 입력값 직접 바꾸기 + { + e.preventDefault(); + const nums = text + .split(/[,\s]+/) + .filter(Boolean) + .map(Number); + if ( + !nums.length || + nums.length > 8 || + nums.some((n) => !Number.isInteger(n) || Math.abs(n) > 1000) + ) { + track("EXAMPLE_APPLIED", { + topic, + count: Math.min(nums.length, 1000000), + success: false, + }); + setInvalid("정수 1~8개를 입력하세요. 각 값은 -1000~1000이에요."); + return; + } track("EXAMPLE_APPLIED", { topic, - count: Math.min(nums.length, 1000000), - success: false, + count: nums.length, + success: true, }); - setInvalid("정수 1~8개를 입력하세요. 각 값은 -1000~1000이에요."); - return; - } - track("EXAMPLE_APPLIED", { - topic, - count: nums.length, - success: true, - }); - setValues(nums); - setCustom(true); - setStep(0); - setInvalid(""); - }} - > - - {[ - "binary-search", - "linear-search", - "two-pointer", - "greedy", - "recursion", - "dp", - "memoization", - "tabulation", - ].includes(topic) && ( + setValues(nums); + setCustom(true); + setStep(0); + setInvalid(""); + }} + > - )} - - {invalid &&

{invalid}

} - + {[ + "binary-search", + "linear-search", + "two-pointer", + "greedy", + "recursion", + "dp", + "memoization", + "tabulation", + ].includes(topic) && ( + + )} + + {invalid &&

{invalid}

} + +

{current.note}

({ - topic, - frames: learningFrames(topic), - checkpoints: checkpoints(topic).length, -})); +const data = [...new Set(Object.keys(conceptUses).map(practiceTopic))].map( + (topic) => ({ + topic, + frames: learningFrames(topic), + checkpoints: checkpoints(topic).length, + }), +); if (data.some((x) => !x.frames.length || !x.checkpoints)) throw new Error( JSON.stringify( diff --git a/scripts/render-learning-gifs.py b/scripts/render-learning-gifs.py index c81a9d3..7b6b20e 100644 --- a/scripts/render-learning-gifs.py +++ b/scripts/render-learning-gifs.py @@ -1,26 +1,43 @@ """Render deterministic algorithm traces exported by export-learning-frames.ts. Requires Pillow. No user code or external image input is involved. """ -import json, math +import json, math, os from pathlib import Path from PIL import Image, ImageDraw, ImageFont root=Path(__file__).resolve().parents[1] data=json.loads((root/'tmp/learning-v2/frames.json').read_text(encoding='utf-8')) out=root/'apps/web/public/learning';out.mkdir(parents=True,exist_ok=True) -font=ImageFont.truetype('C:/Windows/Fonts/malgun.ttf',20) -small=ImageFont.truetype('C:/Windows/Fonts/malgun.ttf',16) +font_path=os.environ.get('ALGOSTEP_FONT', 'C:/Windows/Fonts/malgun.ttf') +font=ImageFont.truetype(font_path,20) +small=ImageFont.truetype(font_path,16) +def fitted(d, text, width, size=20): + for px in range(size,7,-1): + f=ImageFont.truetype(font_path,px) + if d.textbbox((0,0),text,font=f)[2] <= width: return f + raise ValueError(f'Label too wide: {text}') +def lines(d,text,width,f): + result=[];line='' + for ch in str(text): + if d.textlength(line+ch,font=f)>width and line:result.append(line);line=ch + else:line+=ch + if line:result.append(line) + return result + def wrap(s,n=48): return '\n'.join(str(s)[i:i+n] for i in range(0,len(str(s)),n)) for item in data: for mode in ['light','dark']: bg,fg,line,accent,tint,on=('#ffffff','#18213a','#dce1ec','#3159dd','#eaf0ff','#ffffff') if mode=='light' else ('#192133','#edf1fa','#35435d','#9bb4ff','#22365f','#101522') images=[] + previous_nodes={};previous_rows=[];previous_values=[] # Keep every state: playback and manual examples come from the same computation. for index,frame in enumerate(item['frames']): im=Image.new('RGB',(960,600),bg);d=ImageDraw.Draw(im) d.text((28,18),f'{index+1} / {len(item["frames"])}',font=small,fill=accent) - d.multiline_text((28,46),wrap(frame['note']),font=font,fill=fg,spacing=5) + note_lines=lines(d,frame['note'],890,font) + if len(note_lines)>3: raise ValueError('Note overflows: '+frame['note']) + d.multiline_text((28,46),'\n'.join(note_lines),font=font,fill=fg,spacing=5) nodes=[];edges=[];rows=[] if frame.get('advanced'): a=frame['advanced'];nodes=a['nodes'];edges=a['edges'];rows=a['rows'] @@ -44,23 +61,38 @@ def visit(n,x,y,gap): edges.append({'from':nid,'to':c.get('$id','')});visit(c,x+(-gap if k=='left' else gap),y+85,gap/2) visit(v.get('root') or v.get('head'),400,0,210) else: - nodes=[dict(id=str(i),label=str(x),detail=f'[{i}]',x=60+(i%8)*100,y=80+(i//8)*100,shape='card',state='active' if i==v.get('mid') else '') for i,x in enumerate(a)] + nodes=[dict(id=str(i),label=str(x),detail=f'[{i}]',x=60+(i%8)*100,y=80+(i//8)*100,shape='card',state='active' if i in [v.get(k) for k in ['mid','index','i','j','current']] or (i=len(previous_rows) or row!=previous_rows[j] + if changed:d.rounded_rectangle((22,458+j*25,938,482+j*25),radius=4,fill=tint) + d.text((28,460+j*25),text,font=rf,fill=accent if changed else fg) + previous_rows=rows images.append(im) name=f'{item["topic"]}-{mode}' images[0].save(out/(name+'.png')) diff --git a/tests/browser/learning-expansion.spec.ts b/tests/browser/learning-expansion.spec.ts new file mode 100644 index 0000000..cf7157f --- /dev/null +++ b/tests/browser/learning-expansion.spec.ts @@ -0,0 +1,95 @@ +import { test, expect } from "@playwright/test"; + +test("wide learning panel switches exclusive vertical tabs and shows worked case and complexity table", async ({ + page, +}) => { + await page.request.post("/api/auth/google", { + headers: { origin: "http://127.0.0.1:5173" }, + data: { credential: "integration-test-token" }, + }); + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto("/learn/binary-search-lower-bound/concept"); + await expect( + page.getByRole("heading", { name: "lower bound 경계 탐색", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("region", { name: "사용 용도" })).toContainText( + "[2,5,5,9]", + ); + await expect(page.locator(".complexity-table")).toContainText("시간"); + expect((await page.locator("main").boundingBox())!.width).toBeGreaterThan( + 1600, + ); + await expect(page.locator(".example-animation img")).toHaveJSProperty( + "naturalWidth", + 960, + ); + await expect(page.getByLabel("예제 선택", { exact: true })).toHaveCount(0); + await page.getByRole("tab", { name: "단계별 예제", exact: true }).click(); + await expect(page.locator(".example-animation img")).toHaveCount(0); + await expect(page.getByLabel("예제 선택", { exact: true })).toBeVisible(); + await expect(page.locator(".array-board .node-detail").first()).toContainText( + "[0]", + ); + await page.screenshot({ + path: "test-results/wide-learning-tabs.png", + fullPage: true, + }); + await page + .getByRole("tab", { name: "단계별 예제", exact: true }) + .press("ArrowUp"); + await expect(page.getByRole("tab", { name: "자동 동작" })).toBeFocused(); + await expect(page.locator(".example-animation img")).toBeVisible(); + await page.getByRole("link", { name: "03 코드 작성" }).click(); + await expect(page.getByText("main.py", { exact: true })).toBeVisible(); + await expect(page).toHaveURL(/binary-search-lower-bound\/code\?record=/); + await page.reload(); + await expect(page.getByText("main.py", { exact: true })).toBeVisible(); +}); + +test("subscription and checkout previews have paths, no active payment, and responsive layouts", async ({ + page, +}) => { + await page.request.post("/api/auth/google", { + headers: { origin: "http://127.0.0.1:5173" }, + data: { credential: "integration-test-token" }, + }); + const mutations: string[] = []; + page.on("request", (r) => { + if (r.method() === "POST" && /payment|checkout|subscription/.test(r.url())) + mutations.push(r.url()); + }); + await page.goto("/plans"); + await expect( + page.getByText("LLM 코드 리뷰 월 3건", { exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "결제 화면 미리 보기" }).click(); + await expect(page).toHaveURL(/\/checkout$/); + await expect( + page.getByRole("button", { name: "결제 준비 중" }), + ).toBeDisabled(); + await expect(page.locator(".billing-page input")).toHaveCount(0); + await page.reload(); + await expect( + page.getByRole("heading", { name: "구독 결제 안내" }), + ).toBeVisible(); + await page.screenshot({ + path: "test-results/checkout-preview.png", + fullPage: true, + }); + await page.goBack(); + await expect(page).toHaveURL(/\/plans$/); + for (const width of [1920, 1024, 390, 360]) { + await page.setViewportSize({ width, height: 900 }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + } + await page.getByRole("button", { name: "테마 변경" }).click(); + await page.screenshot({ + path: "test-results/plans-mobile.png", + fullPage: true, + }); + expect(mutations).toEqual([]); +}); diff --git a/tests/browser/platform.spec.ts b/tests/browser/platform.spec.ts index 4498be6..9e9e7af 100644 --- a/tests/browser/platform.spec.ts +++ b/tests/browser/platform.spec.ts @@ -70,6 +70,7 @@ test("all P1 and P2 topics expose working scenarios and advanced diagram control .getByRole("button") .filter({ has: page.getByRole("heading", { name, exact: true }) }) .click(); + await page.getByRole("tab", { name: "단계별 예제", exact: true }).click(); for (const v of ["0", "1", "2"]) { await page.getByLabel("예제 선택", { exact: true }).selectOption(v); await expect( @@ -155,6 +156,7 @@ test("expanded quizzes and movable diagrams retain positions across steps", asyn await authenticate(page); await page.goto("/"); await page.getByRole("button", { name: /너비 우선 탐색/ }).click(); + await page.getByRole("tab", { name: "단계별 예제", exact: true }).click(); await page.getByLabel("예제 선택", { exact: true }).selectOption("1"); const node = page.locator('.diagram-node[data-node-id="0"]').first(); await node.scrollIntoViewIfNeeded(); @@ -244,7 +246,7 @@ test("home groups all topics by type without priority labels and supports search }) => { await authenticate(page); await page.goto("/"); - await expect(page.locator(".topic-card")).toHaveCount(47); + await expect(page.locator(".topic-card")).toHaveCount(200); await expect( page.getByRole("navigation", { name: "학습 종류" }), ).toBeVisible(); @@ -255,7 +257,7 @@ test("home groups all topics by type without priority labels and supports search ).toBeVisible(); await expect( page.getByRole("region", { name: "정렬 학습" }).locator(".topic-card"), - ).toHaveCount(6); + ).toHaveCount(25); expect(await page.locator("main").innerText()).not.toMatch(/\bP[012]\b/); await page.screenshot({ path: "test-results/grouped-home.png", @@ -263,11 +265,19 @@ test("home groups all topics by type without priority labels and supports search }); await page.getByLabel("개념 검색").fill("동적 계획법"); await expect(page.locator(".topic-section")).toHaveCount(1); - await expect(page.locator(".topic-card")).toHaveCount(4); + await expect(page.locator(".topic-card")).toHaveCount(17); await page.getByLabel("개념 검색").fill("없는개념xyz"); await expect(page.getByRole("status")).toContainText("검색 결과가 없어요"); await page.getByLabel("개념 검색").fill("FFT"); - await page.locator(".topic-card").click(); + await page + .locator(".topic-card") + .filter({ + has: page.getByRole("heading", { + name: "FFT와 다항식 곱셈", + exact: true, + }), + }) + .click(); await expect( page.getByRole("button", { name: "다른 문제 살펴보기" }), ).toHaveCount(0); @@ -307,8 +317,9 @@ test("every concept includes a concrete purpose and application before the quiz }) => { await authenticate(page); await page.goto("/"); + test.setTimeout(180000); const { topics } = await (await page.request.get("/api/topics")).json(); - expect(topics).toHaveLength(47); + expect(topics).toHaveLength(200); for (const topic of topics) { await page .getByRole("button") @@ -557,7 +568,14 @@ test("mobile P0 examples accept input and display sorting and references", async await authenticate(page); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/"); - await page.getByRole("button", { name: /버블 정렬/ }).click(); + await page + .locator(".topic-card") + .filter({ + has: page.getByRole("heading", { name: "버블 정렬", exact: true }), + }) + .click(); + await page.getByRole("tab", { name: "단계별 예제", exact: true }).click(); + await page.getByText("입력값 직접 바꾸기", { exact: true }).click(); await page.getByLabel("예제 값", { exact: true }).fill("3, 1, 2"); await page.getByRole("button", { name: "예제 적용 · 처음부터" }).click(); const next = page.getByRole("button", { name: "다음 단계", exact: true }); @@ -568,6 +586,7 @@ test("mobile P0 examples accept input and display sorting and references", async ).toHaveText(["1", "2", "3"]); await page.getByRole("button", { name: "학습 목록", exact: true }).click(); await page.getByRole("button", { name: /단일 연결 리스트/ }).click(); + await page.getByRole("tab", { name: "단계별 예제", exact: true }).click(); await page.getByRole("button", { name: "다음 단계", exact: true }).click(); await expect( page.getByRole("img", { name: "객체 필드와 참조 관계" }),