From 796b4cf48504d18822fa77c0cd2e9b5437e851f9 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:00:13 +0900 Subject: [PATCH 01/29] =?UTF-8?q?docs:=20=EC=8A=B9=EC=9D=B8=EB=90=9C=20?= =?UTF-8?q?=EA=B5=90=EC=9C=A1=20=EA=B8=B0=ED=9A=8D=EA=B3=BC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EA=B3=84=EC=95=BD=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 11 ++++++++ .gitattributes | 1 + .github/pull_request_template.md | 19 ++++++++++++++ .gitignore | 8 ++++++ AGENTS.md | 11 ++++++++ CONTRIBUTING.md | 37 +++++++++++++++++++++++++++ README.md | 21 ++++++++++++++-- docs/architecture.md | 35 ++++++++++++++++++++++++++ docs/education-roadmap.md | 30 ++++++++++++++++++++++ docs/java-version-matrix.md | 25 +++++++++++++++++++ docs/job-evidence.md | 19 ++++++++++++++ docs/milestones.md | 15 +++++++++++ docs/product-plan.md | 43 ++++++++++++++++++++++++++++++++ 13 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/pull_request_template.md create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/architecture.md create mode 100644 docs/education-roadmap.md create mode 100644 docs/java-version-matrix.md create mode 100644 docs/job-evidence.md create mode 100644 docs/milestones.md create mode 100644 docs/product-plan.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5e3108d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.java] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..320efed --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## 문제와 변경 후 동작 + +## 관련 이슈 + +실제 이슈가 있을 때 연결합니다. + +## 영향 범위와 공식 근거 + +Java 최소 버전·출처 및 공고 근거 변경 여부를 적습니다. + +## 실제 검증 결과 + +- 실행한 검사와 결과: +- 독립 검토 범위와 결과: +- 실행하지 못한 검사와 이유: + +## 남은 위험과 배포 + +병합·배포 여부 및 되돌리는 방법을 적습니다. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47a816e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +work/ +*.class +.DS_Store +.env +.env.* +.openai/ +test-results/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..53698b5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Java Edu 작업 계약 + +대상은 다른 언어 경험자의 Java 전환 학습과 현직자 심화다. 완전 입문자 설명을 기본 과정으로 확대하지 않는다. 공식 Java 8/11/17/21 기준과 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩의 5단 수업 흐름을 유지한다. + +협업 규칙의 기준 문서는 CONTRIBUTING.md이다. 파일당 쓰기 소유자를 한 명으로 정한다. 콘텐츠, 프런트엔드, 조사 근거, 테스트를 독립 작업으로 나누고 의미 있는 구현은 독립 검토를 받는다. 기존 변경을 보존하며 추가 위임은 부모가 허용한 경우에만 한다. + +역할: 총괄은 범위·통합·검증 책임; 연구자는 공식 명세와 공고 원문 대조; 교육 설계자는 교육 데이터와 독립 Java 예제; 프런트엔드 담당자는 dist의 화면·상호작용; 독립 리뷰어는 정확성·보안·회귀; QA는 실제 기능·합성 화면 검사. 역할은 전문 자격을 의미하지 않는다. + +미지원 Java 버전이나 preview 문법을 정식 예제로 표시하지 않는다. 공고 관찰을 채용 통계로 만들지 않는다. 브라우저 퀴즈와 Java 실행 검증을 구분한다. 실제 실행하지 않은 테스트는 미실행으로 기록한다. 실행기 계약은 docs/architecture.md를 따른다. 교재 기준·컴파일 대상·실제 실행 JVM을 분리해서 표시한다. 프로토타입은 외부 Wandbox의 실제 JVM21을 사용하며 Java8/11/17 실제 JVM이라고 주장하지 않는다. Spring·DB 통합 단원은 해당 실행 환경 검증 후 공개한다. + +사용자는 마일스톤별 커밋·푸시와 완성된 프로토타입/서비스의 배포를 승인했다. origin은 jbsim999/java-edu, main과 단기 작업 브랜치를 사용한다. 강제 푸시·미검증 병합·공개 범위 변경·유료 자원 구매 권한은 만들지 않는다. GitHub 저장소는 공개이며 새 Sites 프로토타입은 기본 owner-private를 유지한다. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..53dfbe1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# 협업 규칙 + +이 저장소의 기본 규칙이다. Junbo GitHub Conventions의 추천안을 본 프로젝트에 맞춰 적용했다. 기존 사용자 저장소의 정책이나 GitHub 보호 설정을 확인한 것으로 해석하지 않는다. + +## 변경 단위와 검토 + +- 기준 브랜치 `main`, 단기 작업 브랜치 `feat/java-learning-mvp`처럼 `type/description`. 실제 이슈가 있으면 `type/number-description`; 번호를 만들지 않는다. +- 커밋과 PR 제목은 `type(scope): 변경 내용`. 타입은 feat, fix, docs, test, refactor, perf, style, build, ci, chore. scope는 web, curriculum, evidence, examples, ci 중 실제 변경 대상에 맞게 사용한다. 이 목록은 팀의 추가 규칙이다. +- 하나의 PR은 하나의 목적을 갖는다. 구현 중에는 Draft PR을 사용한다. 중요 변경은 구현자 외 담당자의 독립 검토와 관련 검사 결과를 남긴다. 에이전트 검토를 사람의 GitHub 승인으로 꾸미지 않는다. +- `main` 강제 푸시와 기존 작업 되돌리기를 금지한다. 병합과 운영 배포는 사용자 승인 범위 안에서만 수행한다. PR 준비는 자동 병합 권한을 뜻하지 않는다. +- `[필수]`, `[제안]`, `[질문]`, `[사소]`로 리뷰 의도를 표시한다. 필수 문제 해결 후 해당 실패 조건을 다시 검사한다. + +## 교육 콘텐츠 기준 + +대상은 다른 언어로 프로그래밍 경험이 있는 Java 전환 학습자와 현직 개발자다. 완전 입문자 과정은 포함하지 않는다. 각 수업은 이론·예시·문제풀이·실무 심화·웹 IDE 직접 코딩을 모두 갖춘다. 클래스 골격형과 메서드 본문형을 학습 목표에 맞춰 사용한다. + +언어는 해당 버전 JLS, API는 API Specification과 Since, 런타임은 JVM 명세·공식 가이드를 기준으로 한다. 수업 `minVersion`은 해당 수업의 실행 예제를 지원하는 최소 대상 버전이다. 최초 도입 버전은 본문·호환표에서 별도로 밝힌다. preview/incubator는 기본 과정에서 제외한다. 버전별 예제 폴더와 기대 출력 manifest를 함께 변경한다. + +공고 근거는 URL·제목·기업·확인일·모집 상태·접근 범위·관찰 요건을 기록한다. 확인일은 게시일이 아니다. 공고를 특정한 표본에서 관찰한 기술이라고 표현하고 사용률이나 Java 버전 수요로 확대하지 않는다. 실무 과제는 교육 설계 제안이며 기업의 채용 과제가 아니다. + +## 실제 검사 + +다음은 구현 단계의 검사 계약이다. M1 문서 마일스톤에서는 이 검사를 실행한 것으로 표시하지 않는다. 실제 CI 정의·실행 결과는 후속 마일스톤에서 검증한다. + +`npm run check`: JS 구문과 교재/출처/공고/예제 데이터 정합성. CI 작업 이름 `Content integrity`. + +`npm run test:java`: 설치된 JDK에서 호환 예제를 컴파일하고 기대 출력과 비교한다. CI 작업 `Java examples (8/11/17/21)`에서 각 JDK를 독립 사용한다. `--release` 검사와 실제 해당 JDK 실행을 구분한다. + +`npm run test:browser`: 웹 기능과 화면의 브라우저 에뮬레이션 검사. CI 작업 `Browser QA`. 실제 휴대전화 테스트라고 기록하지 않는다. 테스트 명령이나 CI 정의가 존재하는 것과 실행 성공은 별개다. 상세 결과는 PR과 docs/verification.md에 남긴다. + +필수 브랜치 보호 설정은 아직 적용하지 않는다. CI 작업 이름을 위와 같이 정의했지만 GitHub에서 실제 검사 결과를 확인한 후 필수 검사 지정 여부를 결정한다. + +## 명명과 근거 + +식별자·브랜치는 영어, 설명·리뷰는 한국어. API, HTTP, SQL, URL, ID, JVM, JDK처럼 통용 약어를 사용하고 `usr`, `cnt`, `mgr` 같은 모호한 축약을 새 이름에 넣지 않는다. 기존 공개 식별자는 명명 규칙 때문에 바꾸지 않는다. + +표준 근거: [Conventional Commits 1.0.0](https://www.conventionalcommits.org/ko/v1.0.0/). 브랜치·scope·리뷰 표시는 프로젝트의 선택이다. 프로토타입 배포는 Sites의 owner-private 호스팅과 Worker API를 사용한다. GitHub 저장소 공개와 서비스 공개는 별개다. 지원하지 않는 프레임워크·DB 실습을 실행 가능으로 표시하지 않는다. diff --git a/README.md b/README.md index 22fd85b..c069075 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,19 @@ -# java-edu -공식 Java 문서 기반 전환 학습과 Java 8·11·17·21 실무 심화 교육 +# Java Edu + +공식 Java 문서 기반으로 다른 언어 경험자의 Java 전환 학습과 현직자의 심화를 연결하는 한국어 교육 서비스입니다. OOP·타입·동시성을 깊게 다루고, 수업 끝에서 Java 코드를 직접 작성·실행·검증합니다. + +현재 상태: **개발 중**. M1은 기획·교육지도·실행 계약 문서 마일스톤이며 앱 완성이나 배포 성공을 뜻하지 않습니다. 실제 검증·배포 결과는 후속 마일스톤에 기록합니다. + +- [기획안](docs/product-plan.md) +- [교육 로드맵](docs/education-roadmap.md) +- [마일스톤](docs/milestones.md) +- [실행 아키텍처](docs/architecture.md) +- [공식 Java 호환 기준](docs/java-version-matrix.md) +- [채용 근거](docs/job-evidence.md) +- [협업 규칙](CONTRIBUTING.md) + +수업은 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩의 5단계입니다. 객관식 성취·코드 행동 검증·설계 평가를 구분합니다. + +교재는 Java 8·11·17·21 기준입니다. 첫 프로토타입의 외부 실행 환경은 **Java21**이며 8·11·17 컴파일 대상과 해당 JVM 실행을 혼동하지 않습니다. 실행 버튼을 누를 때 작성 코드가 Wandbox로 전송되며 운영 로그에 남을 수 있습니다. 민감정보를 입력하지 마세요. + +앱·테스트 완성 후 Node22+로 `npm ci`, `npm run dev`를 실행합니다. 예정된 검사는 `npm run check`, `npm run test:java`, `npm run test:browser`입니다. 현재 M1에서 실행 완료했다고 주장하지 않습니다. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..020f0e8 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,35 @@ +# 실행 아키텍처와 공개 범위 + +기준일 2026-10-12. 프로토타입은 정적 교육 UI와 Cloudflare Worker API를 owner-private Sites에 배포한다. 사용자 기기에 임의 Java 코드를 직접 실행하지 않는다. 공개 GitHub와 개인 전용 호스팅의 접근 범위는 별개다. + +## Java 실행 계약 + +브라우저→같은 origin의 실행 API→외부 Wandbox 컴파일/실행 API로 연결한다. 제공자 endpoint·compiler·옵션은 서버가 고정한다. 교육 콘텐츠가 가진 과제 ID·버전·trusted checks를 서버가 결정하며 클라이언트의 기대값·컴파일 옵션을 신뢰하지 않는다. + +실제 JVM은21(`openjdk-jdk-21+35`). `--release`로8/11/17/21의 언어/API 대상 컴파일을 검사해도 실행 JVM은21이다. 8/11/17의 실제 런타임 검증은 별도 CI와 후속 실행 서버에서 분리한다. 2026-10-12 실제 확인용 코드는 `--release\n8`과 package-private `class Main`으로 컴파일하고 `JAVA_EDU_OK`·`21-internal`을 출력했다. Java8 obsolete 경고와 종료0을 함께 확인했다. 이 결과는 전체 과제 검증을 대신하지 않는다. + +주 파일은prog.java이므로 template의 Solution과 Main은 package-private다. 사용자 Solution과 trusted Main harness를 합쳐 실행한다. 호출·반환 타입·과제 개정판·컴파일 대상·샘플/제출 검사를 고정한다. 결과는 실행ID·작성 코드 snapshot에 연결한다. 단순 stdout 문구만으로 정답을 판정하지 않는다. 교육용 행동 검증은 악의적 제출에 대한 완전한 부정행위 방지 시스템을 뜻하지 않는다. + +## 데이터와 접근 + +진도·작성 코드·설계 메모는 브라우저에 저장한다. 전 기기 동기화로 표시하지 않는다. 실행 버튼을 누르면 코드가 외부 Wandbox에 전송된다. save:false는 공개 permalink 생성을 막을 뿐 제공자 운영 로그에 저장되지 않음을 보장하지 않는다. UI에서 제3자 전송·로그 가능성·민감정보 입력 금지를 설명한다. + +Sites private gateway가 접근을 제한하고 인증된 방문자의 `oai-authenticated-user-id`를 제공한다. 배포 API는 이 trusted ingress 이후 identity를 요구한다. 로컬 preview만 identity 없이 실행한다. 공개 서비스로 전환하려면 계정·쿼터·비용·부정 사용을 별도로 설계한다. + +## 제한과 실패 + +서버에서 요청·소스·응답 크기와 대기시간을 제한하고 동일 origin·과제 ID·JSON 구조를 검사한다. 제공자별429·장애·컴파일 오류·실행 오류·검사 실패를 구분한다. POST 실행을 자동 재시도하지 않는다. 제공자의 IP 쿼터는 shared proxy 요청이 합산될 수 있으며 운영 상한은 미확인이다. 브라우저 취소는 결과 대기를 중단하는 것이며 원격 프로세스 종료를 보장하지 않는다. + +## 후속 실행 기반 + +네 실제 JDK, 승인 의존성, 다중 Java/XML/SQL 파일, 임시 DB, HTTP 앱·준비된 외부 시스템을 실행별 격리한다. 컴파일·annotation processing부터 제한한다. 데이터 초기화·하위 프로세스 종료·임시 파일 정리·네트워크 범위를 검사한다. 교육 코드에 호스트 Docker 권한을 제공하지 않는다. 배포 계정·비용·실행 자원은 현재 확보하지 않았다. + +## 공식 근거 + +- [Wandbox 공식 API 목록](https://wandbox.org/api/list.json) +- [Wandbox FAQ](https://github.com/melpon/wandbox#faq) +- [Wandbox 실행 처리·로그](https://github.com/melpon/wandbox/blob/develop/feline/src/api_compile_json.rs) +- [Wandbox 요청·응답](https://github.com/melpon/wandbox/blob/develop/feline/src/types.rs) +- [javac 공식 설명](https://docs.oracle.com/en/java/javase/21/docs/specs/man/javac.html) + +Piston은 확인한 공개 Java 목록15.0.2 및 실행키 정책 때문에 이번 무키 프로토타입에서 선택하지 않았다. [공식 정책](https://github.com/engineer-man/piston#public-api) diff --git a/docs/education-roadmap.md b/docs/education-roadmap.md new file mode 100644 index 0000000..ce505f2 --- /dev/null +++ b/docs/education-roadmap.md @@ -0,0 +1,30 @@ +# Java Edu 교육 로드맵 v0.2 + +기준일 2026-10-12. 전환 경로는 진단→Java 코어→테스트·빌드→Spring·DB→인증·운영→통합 프로젝트, 현직자 경로는 진단→부족한 코어→설계·동시성·정합성→버전 전환→개선 프로젝트다. 경력·객관식 총점만으로 단원을 면제하지 않는다. + +| 단계 | 학습 내용 | 직접 코딩·성취 조건 | +| --- | --- | --- | +| 1 객체·타입 | 값 전달·참조·boxing·동등성·오버로딩/오버라이딩 | 실행 결과 설명·결함 수정 | +| 2 깊은 OOP | 책임·협력·캡슐화·불변식·다형성·LSP·상속/합성·SOLID의 비용 | 예약·재고 모델의 행동과 설계 근거 | +| 3 제네릭·컬렉션 | 소거·variance·PECS·equals/hashCode·변경 가능성·자료구조 계약 | 타입 안전한 API·가변 키 오류 수정 | +| 4 실패·자원·함수형 | 예외 처리 책임·원인 보존·자원 해제·람다·Stream·Optional·java.time | 누수·예외 은폐·부수 효과·경계 검사 | +| 5 빌드·테스트·라이브러리 | Maven·Gradle·JUnit·Mockito·AssertJ·Lombok | 재현 빌드·행동 테스트·생성 코드 해석 | +| 6 동시성·JVM | JMM·원자성·가시성·순서·happens-before·락·취소·GC·관측 | 경쟁 조건 반례·수정 근거·종료/취소 검증 | +| 7 웹·DB 기반 | HTTP·SQL·제약조건·인덱스·실행계획·JDBC·연결 풀 | 요청 계약·SQL·자원·롤백 | +| 8 Spring Core | IoC·DI·빈 생명주기·스코프·설정·AOP·프록시·자동 설정 | 객체 조립 전환·호출 경계 설명 | +| 9 MVC·REST | 계층 책임·Jackson·Validation·예외 응답·API 문서화 | 정상·잘못된 입력·오류 HTTP 검사 | +| 10 영속성 | MyBatis SQL/XML/매핑, JPA/Hibernate 상태/조회, Spring Data | 같은 도메인 두 구현 비교·선택한 방식 심화 | +| 11 트랜잭션·정합성 | 전파·격리·롤백·프록시·락·재시도·Flyway | 부분 실패·경쟁 요청 통합 검증 | +| 12 인증·인가 | Security·세션·OAuth2·OIDC·JWT·CSRF/CORS | 준비된 인증 환경·만료/권한 부족 거부 | +| 13 Redis·Kafka | 만료·무효화·중복·순서·재처리·장애 | 캐시·이벤트 정합성 | +| 14 관측·배포 | 로그·지표·추적·Actuator·Micrometer·OpenTelemetry·Docker·CI·통합 테스트 환경 | 실패 추적·깨끗한 빌드·출시 검증 | +| 15 버전 전환 | 8→11→17→21 문법/API/빌드/의존성/Spring 변화 | 실제 JDK별 회귀·이행 근거 | +| 16 통합 프로젝트 | 설계·DB·보안·정합성·운영·변경 대응 | 실패 시나리오·테스트·설계 결정·관측 증거 | + +OOP·타입→컬렉션·실패→테스트→Spring 실행 모델→영속성→정합성의 선수 관계를 유지한다. HTTP·SQL/JDBC는 해당 Spring·영속성 실습의 공통 기반이다. MyBatis를 JPA 선수 조건으로 강제하지 않는다. SQL/JDBC 기반에서 두 방식 비교→선택 심화→트랜잭션으로 합류한다. + +버전 차이는 각 수업에서 설명하고15단계에서 종합한다. 8은 공통 기반·람다/Stream/time,11은 표준 HTTP와 문자열/파일 API,17은 switch 표현식/text block/record/sealed,21은 record pattern/pattern switch/virtual thread를 다룬다. 최초 도입 버전은 별도 표시한다. + +공통 프로젝트는 주문·재고 예약·취소다. 도메인 모델→테스트→HTTP API→영속화/트랜잭션→권한/중복/동시 요청→캐시/이벤트/관측→버전 상향을 연결한다. 모든 도구를 한 프로젝트에 무조건 끼워 넣지 않는다. + +선택 심화는 Batch·스케줄링, QueryDSL·검색, WebFlux, Kubernetes·분산 설계로 분기한다. 수료는 원리 설명·반례·코드 수정·행동 검증·설계 대안과 비용 설명으로 판정한다. 학습 효과는 실제 학습자 검토 전까지 미확인이다. diff --git a/docs/java-version-matrix.md b/docs/java-version-matrix.md new file mode 100644 index 0000000..9a62fb2 --- /dev/null +++ b/docs/java-version-matrix.md @@ -0,0 +1,25 @@ +# Java 버전 기준 + +Oracle 공식 본문 확인일 2026-10-12. 언어는 JLS, 표준 API는 해당 판본의 계약·Since, 런타임은 JVMS·공식 가이드를 사용한다. 문헌 대조와 코드 컴파일/실행 결과를 구분한다. + +| 기능 | 최초 정식 버전 | 이 과정 대상 중 최소 버전 | +| --- | --- | --- | +| 람다·Stream·Optional·java.time | 8 | 8 | +| List.of | 9 | 11 | +| 지역 변수 var | 10 | 11 | +| 람다 매개변수 var·HttpClient·String.isBlank/strip/lines/repeat·Files.readString/writeString·Optional.isEmpty | 11 | 11 | +| switch 표현식 | 14 | 17 | +| text block | 15 | 17 | +| record·instanceof 패턴·Stream.toList | 16 | 17 | +| sealed | 17 | 17 | +| record pattern·pattern switch·virtual thread·SequencedCollection | 21 | 21 | + +Java17 pattern switch는 preview이므로 정식 예제에서 제외한다. record의 컴포넌트 필드는final이지만 참조하는 객체까지 깊은 불변성을 보장하지 않는다. 가상 스레드는 CPU 속도·모든 요청 지연 감소를 보장하지 않는다. Java8의Stream예제에는toList()/List.of()를 섞지 않는다. + +공식 근거: [JLS8](https://docs.oracle.com/javase/specs/jls/se8/html/index.html), [공식 언어 변경표](https://docs.oracle.com/en/java/javase/21/language/java-language-changes-release.html), [Stream8](https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html), [HttpClient11](https://docs.oracle.com/en/java/javase/11/docs/api/java.net.http/java/net/http/HttpClient.html), [String11](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/String.html), [Stream17](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/stream/Stream.html), [record17](https://docs.oracle.com/en/java/javase/17/language/records.html), [가상 스레드21](https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html). + +Spring Boot2.7/Framework5.3과 Boot3/Framework6 및 Boot4/Framework7을 분리한다. 후자 두 세대는Java17이상이다. Boot2.7.18은Java21까지 호환한다고 명시하므로 레거시=높은JDK실행불가로 가르치지 않는다. 유지보수 지원과 실행 호환성은 별개다. [2.7 요구사항](https://docs.spring.io/spring-boot/docs/2.7.18/reference/html/getting-started.html), [3.5 요구사항](https://docs.spring.io/spring-boot/3.5/system-requirements.html), [현재 요구사항](https://docs.spring.io/spring-boot/system-requirements.html). + +MyBatis-Spring2.1→Framework5/Java8+,3.0→Framework6/Java17+,4.0→Framework7/Java17+를 구분한다. [공식 표](https://mybatis.org/spring/). JUnit5는Java8+,JUnit6은Java17+,Mockito5는Java11+로 테스트 도구를 일괄 최신화하지 않는다. [JUnit5](https://docs.junit.org/5.14.4/overview.html), [JUnit current](https://docs.junit.org/current/user-guide/), [Mockito](https://github.com/mockito/mockito). + +직접JEP본문 접근은 조사 도구에서403이었으므로 검증 완료로 세지 않았다. 동일 기능의 Oracle 공식 설명을 확인했다. 이 문서는 전체 예제 실제실행 PASS나 최종patch선정을 주장하지 않는다. diff --git a/docs/job-evidence.md b/docs/job-evidence.md new file mode 100644 index 0000000..c205668 --- /dev/null +++ b/docs/job-evidence.md @@ -0,0 +1,19 @@ +# 실무 주제의 채용 공고 근거 + +확인일: 2026-10-12, Asia/Seoul. 5개 기업·4개 도메인의 공개 공고를 선정한 관찰 표본이다. 기술 사용률이나 Java 버전별 채용 수요를 의미하지 않는다. 마감 공고는 과거 실무 요구의 근거다. 확인일은 게시일이 아니며 검색 도구의 추출에는 캐시가 포함될 수 있다. `full`은 주요 업무와 요건 본문을 읽었다는 의미다. + +| 기업 / 공고 | 관찰 내용 | 상태 및 한계 | +| --- | --- | --- | +| [토스플레이스 — Server Developer, Platform](https://toss.im/career/job-detail?company=%ED%86%A0%EC%8A%A4%ED%94%8C%EB%A0%88%EC%9D%B4%EC%8A%A4&detailedPosition=Platform&job_id=4071141003) | 테스트·호환성 관리, 동시성·재시도·중복·부분 실패 대응. Java/Kotlin·Spring 경험 | 명시적 마감·모집 상태 미확인. Platform 본문 선택 URL | +| [알라딘커뮤니케이션 — 백엔드 엔지니어-개발2팀](https://jumpit.saramin.co.kr/position/52759934) | 테스트 작성, 부하·쿼리 튜닝. 동시성·분산 처리 우대 | 마감 표시 및 2026-02-05 마감일 확인. Kotlin 별도 요건, Java는 언어 선택지 | +| [코비젼 — 경력직 개발자 채용](https://jumpit.saramin.co.kr/position/50154427) | 백엔드 요건: SQL·RDBMS, Spring·JPA, 컨테이너 운영, API 설계 | 마감 표시 및 2025-03-06 마감일 확인. 여러 직군 요건을 합산하지 않음 | +| [와탭랩스 — Kubernetes 에이전트 개발자](https://career.rememberapp.co.kr/job/posting/291531) | Java 또는 Go, 자료구조·멀티스레드·소켓, 메모리 효율, 비동기 처리 | 채용 시 마감 표시. 실제 모집 지속 미확인. 경력 에이전트 개발 직무 | +| [뮤즈라이브 — 백엔드 엔지니어(Java/Spring)](https://www.jobplanet.co.kr/job/search?entry=job_posting_keyword&posting_ids%5B%5D=1679758) | 코드 품질 개선, Spring Boot·MySQL·Redis·AWS 환경, 대용량 처리 우대 | 마감일 2026-12-27 표시, 실제 모집 상태 미확인. Java 세부 버전 없음 | + +## 교육 설계상의 연결 + +기업이 요구한 과제와 교육자가 제안한 과제를 구분한다. 객체 설계(`oop`), 자료구조 선택(`collections`), 실패 정책(`exceptions`), HTTP 계약(`modern-apis`), 경계·실패 테스트(`testing-design`), 공유 상태·타임아웃·취소(`concurrency`)를 공고에서 관찰한 주제와 연결했다. 실제 기업의 채용 과제가 아니다. + +이 표본에는 Java 8·11·17·21의 사용 여부, records·sealed·virtual threads 또는 JUnit의 직접 요구를 입증하는 근거가 없다. 이 기능들의 교육 근거는 Java 또는 해당 도구의 공식 문서다. + +선정한 5개 공고는 원문 본문을 직접 확인했다. 원티드 와디즈 공고 344971은 원문 열기 오류로 제외했다. 공고 전문과 기업 홍보 내용을 재게시하지 않고 필요한 사실을 요약했다. 모집 상태를 다시 확인하려면 원문을 열어야 한다. diff --git a/docs/milestones.md b/docs/milestones.md new file mode 100644 index 0000000..f989bde --- /dev/null +++ b/docs/milestones.md @@ -0,0 +1,15 @@ +# 개발 마일스톤 + +사용자가 2026-10-12에 마일스톤별 커밋·푸시 및 완성된 프로토타입/서비스의 배포를 승인했다. 문서·코드·검사 정의가 존재하는 것과 실행 성공은 구분한다. + +| ID | 범위 | 완료 게이트 | 현재 상태 | +| --- | --- | --- | --- | +| M1 | 기획·교육 로드맵·버전/실행/협업 계약 | 독립 문서 리뷰·형식/링크/범위 확인·커밋·원격 ref 확인 | 독립 문서 리뷰 PASS·커밋/푸시 진행 | +| M2 | Java 코어12수업·과제·UI·진도·출처·CI | 콘텐츠 정합성·예제/답안·UI mock 검사·독립 리뷰·커밋/푸시 | 구현 중 | +| M3 | 실제 JVM21 외부 실행·샘플/제출 API·Worker 패키징 | API 단위 검사·실제 코드 실행·오류/버전 계약·독립 리뷰·커밋/푸시 | 구현 중 | +| M4 | 프로토타입 통합·배포 | 반응형·키보드·진도·오래된 응답 무효화·합성 화면·CI 확인·출시 커밋/푸시·배포 성공 | 대기 | +| M5 | 별도 JVM8/11/17/21·의존성·DB·HTTP 실행 기반 | 배포 자원·격리·정리·호환 조합·통합 검사 | 후속 설계 | +| M6 | Spring/REST/Lombok/MyBatis/테스트 대표 수업 | 실제 의존성·다중 파일·DB·HTTP 코딩 실습 | M5 의존 | +| M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | M5/6 의존 | + +M4는 전체 교육 과정 완료를 뜻하지 않는다. 교재4버전과 실제 실행 JVM21의 차이, 외부 제공자 제한, 후속 자원 필요를 배포 결과와 함께 명시한다. 마일스톤마다 관련 파일만 stage하고 강제 푸시하지 않는다. 배포는 검증한 커밋의 동일 소스를 사용한다. diff --git a/docs/product-plan.md b/docs/product-plan.md new file mode 100644 index 0000000..8fb5e31 --- /dev/null +++ b/docs/product-plan.md @@ -0,0 +1,43 @@ +# Java Edu 기획안 v0.2 + +기준일 2026-10-12 (Asia/Seoul). 사용자가 승인한 교육 방향과 첫 프로토타입 범위를 기록한다. + +## 목표와 대상 + +Java 동작 원리를 이해하고 설계 선택을 설명하며 실제 서비스를 구현·검증하는 교육 프로그램을 만든다. 다른 언어 경험자의 전환 경로와 현직자의 심화 경로가 공통 교재를 공유한다. 완전 프로그래밍 입문 과정은 제외하되 OOP·타입 시스템·동시성은 깊게 학습한다. 코드 해석·결함 수정·근거 설명 진단으로 필요한 단원을 선택한다. + +## 교육 범위 + +| 영역 | 내용 | +| --- | --- | +| Java 코어 | 객체·타입·값 전달, 캡슐화·불변식, 다형성·계약·LSP, 상속·합성·의존 방향, 제네릭·컬렉션·예외·람다·Stream·시간 | +| 실행 모델 | JMM·원자성·가시성·순서·happens-before, 락·Executor·취소, JVM·GC·관측, 리플렉션·애너테이션 | +| Spring | Framework·Boot·IoC·DI·빈 생명주기·스코프·AOP·프록시·MVC·자동 설정 | +| 라이브러리 | Lombok 생성 코드, Jackson·Validation, API 문서화 | +| 영속성 | SQL·JDBC·HikariCP·MyBatis·JPA/Hibernate·Spring Data JPA·Flyway·트랜잭션 | +| 보안 | Spring Security·세션·OAuth2·OIDC·JWT·인증/인가 경계 | +| 테스트·빌드 | JUnit·Mockito·AssertJ·Testcontainers·Maven·Gradle | +| 분산·운영 | Redis·Kafka, Actuator·Micrometer·OpenTelemetry·Prometheus/Grafana, Docker·CI/CD | +| 선택 심화 | Batch·WebFlux·QueryDSL·Kubernetes·검색·분산 설계 | + +Java 명세, 도구의 공식 계약, 교육자의 설계 지침을 구분한다. OAuth2는 인가 규약, OIDC는 인증 계층, Spring Security는 그 구현을 지원하는 프레임워크다. JPA 명세·Hibernate 구현·Spring Data 추상화도 구분한다. SOLID·패턴 사용 개수 자체로 평가하지 않는다. + +## 수업과 평가 + +모든 공개 수업은 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩이다. 클래스 골격형은 구조·불변식·책임 설계, 메서드 본문형은 API·경계 처리·로직 구현에 사용한다. 이후 프로젝트형은 Java·XML·SQL·설정 등 여러 파일을 다룬다. 외부 호출 계약은 명시하고 내부 설계 선택권을 둔다. + +웹 IDE는 코드 보존·실행·피드백·수정·제출을 연결한다. 샘플 실행과 제출 검증, 실행 성공과 행동 테스트 통과, 설계 설명 기록과 설계 검토 완료를 구분한다. 코드가 바뀌면 이전 결과를 현재 코드의 성취로 표시하지 않는다. + +## 버전·실무 근거 + +교육 버전 8·11·17·21, 최초 정식 도입 버전·최소 예제 버전·실제 실행 JVM을 구분한다. preview/incubator는 기본 과정에서 제외한다. Java8/11의 기존 Spring 세대와 Java17/21 현대 Spring 세대는 검증한 의존성 조합으로 분리한다. 기술적 호환성과 유지보수 지원은 별도다. + +공고는 관찰 표본이다. 기업·직무·URL·확인일·모집 상태·필수/우대·접근 범위를 기록한다. 수업 연결은 교육자의 제안이며 시장 사용률이나 기업의 실제 채용 과제로 확대하지 않는다. + +## 공개 범위와 확장 + +첫 **프로토타입**은 Java 코어 12개 수업, 두 종류의 실행 과제, 검색·버전·트랙·출처·진도·웹 IDE를 연결한다. 외부 실행 JVM은21이며 Java8/11/17/21 컴파일 대상 검증을 제공한다. 이것은 전체 과정이나 네 JVM 실행 서비스 완성본이 아니다. + +후속 **실무 MVP**는 Spring·REST·Lombok·MyBatis·테스트의 대표 수업을 공개한다. 해당 라이브러리·다중 파일·DB·HTTP 실행 환경 검증이 공개 조건이다. 그 뒤 JPA·보안·캐시·메시징·운영 단원을 확장한다. 필요한 배포 계정·자원은 명시하고 비용·권한을 임의로 확대하지 않는다. + +구현자 외 독립 리뷰, 실제 기능 검사, 합성 화면 검수 후 마일스톤별 커밋·푸시한다. 완성된 프로토타입은 owner-private Sites에 배포한다. 공개 GitHub는 서비스 공개 권한과 구분한다. From 731b4a2cb44f00d107ad29cda7918739114e9428 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:20:51 +0900 Subject: [PATCH 02/29] =?UTF-8?q?feat(curriculum):=20=EC=BD=94=EC=96=B4=20?= =?UTF-8?q?12=EC=88=98=EC=97=85=EA=B3=BC=205=EB=8B=A8=EA=B3=84=20=ED=95=99?= =?UTF-8?q?=EC=8A=B5=EC=8B=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/verify.yml | 55 + .gitignore | 3 + README.md | 7 +- dist/app.js | 1201 ++++++++++++++++++++++ dist/data/curriculum.js | 1173 +++++++++++++++++++++ dist/data/exercises.js | 300 ++++++ dist/data/job-evidence.js | 40 + dist/index.html | 77 ++ dist/styles.css | 488 +++++++++ docs/curriculum.md | 92 ++ docs/milestones.md | 2 +- examples/expected-output.json | 62 ++ examples/java11/modern-apis/Main.java | 15 + examples/java17/records-sealed/Main.java | 17 + examples/java21/java21/Main.java | 23 + examples/java8/collections/Main.java | 18 + examples/java8/concurrency/Main.java | 25 + examples/java8/exceptions/Main.java | 25 + examples/java8/generics/Main.java | 14 + examples/java8/java-basics/Main.java | 28 + examples/java8/oop/Main.java | 29 + examples/java8/polymorphism/Main.java | 24 + examples/java8/streams/Main.java | 13 + examples/java8/testing-design/Main.java | 27 + examples/solutions.json | 14 + package-lock.json | 550 ++++++++++ package.json | 23 + scripts/check-java.mjs | 72 ++ scripts/serve.mjs | 48 + server/exercises.mjs | 331 ++++++ tests/browser.mjs | 816 +++++++++++++++ tests/content.test.mjs | 96 ++ 32 files changed, 5705 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/verify.yml create mode 100644 dist/app.js create mode 100644 dist/data/curriculum.js create mode 100644 dist/data/exercises.js create mode 100644 dist/data/job-evidence.js create mode 100644 dist/index.html create mode 100644 dist/styles.css create mode 100644 docs/curriculum.md create mode 100644 examples/expected-output.json create mode 100644 examples/java11/modern-apis/Main.java create mode 100644 examples/java17/records-sealed/Main.java create mode 100644 examples/java21/java21/Main.java create mode 100644 examples/java8/collections/Main.java create mode 100644 examples/java8/concurrency/Main.java create mode 100644 examples/java8/exceptions/Main.java create mode 100644 examples/java8/generics/Main.java create mode 100644 examples/java8/java-basics/Main.java create mode 100644 examples/java8/oop/Main.java create mode 100644 examples/java8/polymorphism/Main.java create mode 100644 examples/java8/streams/Main.java create mode 100644 examples/java8/testing-design/Main.java create mode 100644 examples/solutions.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/check-java.mjs create mode 100644 scripts/serve.mjs create mode 100644 server/exercises.mjs create mode 100644 tests/browser.mjs create mode 100644 tests/content.test.mjs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..b522769 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,55 @@ +name: Verify Java Edu + +on: + push: + branches: [main, 'feat/**', 'fix/**'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + content: + name: Content integrity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npm run check + java: + name: Java examples (${{ matrix.java }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: ['8', '11', '17', '21'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '22' + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - run: npm run test:java + browser: + name: Browser QA + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run dev > /tmp/java-edu-preview.log 2>&1 & + - run: npm run test:browser + env: + QA_ARTIFACT_DIR: work/java-edu-qa diff --git a/.gitignore b/.gitignore index 47a816e..857017e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ work/ .env.* .openai/ test-results/ +worker/assets.generated.mjs +dist/server/ +dist/.openai/ diff --git a/README.md b/README.md index c069075..5f597ab 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 공식 Java 문서 기반으로 다른 언어 경험자의 Java 전환 학습과 현직자의 심화를 연결하는 한국어 교육 서비스입니다. OOP·타입·동시성을 깊게 다루고, 수업 끝에서 Java 코드를 직접 작성·실행·검증합니다. -현재 상태: **개발 중**. M1은 기획·교육지도·실행 계약 문서 마일스톤이며 앱 완성이나 배포 성공을 뜻하지 않습니다. 실제 검증·배포 결과는 후속 마일스톤에 기록합니다. +현재 상태: **프로토타입 검증 중**. Java 코어 12수업·24문항·12코딩 과제와 5단계 학습실을 구현했습니다. Spring·Lombok·MyBatis·OAuth2/OIDC·JPA·Redis·Kafka 및 운영 과정은 교육 로드맵에 포함하며 후속 실습 기반에서 확장합니다. 전체 교육 과정 완료를 뜻하지 않습니다. - [기획안](docs/product-plan.md) - [교육 로드맵](docs/education-roadmap.md) @@ -10,10 +10,13 @@ - [실행 아키텍처](docs/architecture.md) - [공식 Java 호환 기준](docs/java-version-matrix.md) - [채용 근거](docs/job-evidence.md) +- [구현된 코어 과정](docs/curriculum.md) - [협업 규칙](CONTRIBUTING.md) 수업은 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩의 5단계입니다. 객관식 성취·코드 행동 검증·설계 평가를 구분합니다. 교재는 Java 8·11·17·21 기준입니다. 첫 프로토타입의 외부 실행 환경은 **Java21**이며 8·11·17 컴파일 대상과 해당 JVM 실행을 혼동하지 않습니다. 실행 버튼을 누를 때 작성 코드가 Wandbox로 전송되며 운영 로그에 남을 수 있습니다. 민감정보를 입력하지 마세요. -앱·테스트 완성 후 Node22+로 `npm ci`, `npm run dev`를 실행합니다. 예정된 검사는 `npm run check`, `npm run test:java`, `npm run test:browser`입니다. 현재 M1에서 실행 완료했다고 주장하지 않습니다. +Node22+로 `npm ci`, `npm run dev`를 실행하면 `http://127.0.0.1:4173`에서 학습실을 사용할 수 있습니다. 진도·코드·메모는 이 브라우저에 저장됩니다. 편집기는 일반 텍스트 편집기이며 자동 완성을 제공하지 않습니다. + +`npm run check`는 콘텐츠와 실행 API 검사, `npm run test:java`는 설치된 JDK의 예제·기준 답안 검증입니다. `npm run test:browser`는 서버가 실행 중일 때 모의 API로 기능을 검사합니다. 로컬 Edge는 `PLAYWRIGHT_CHANNEL=msedge`를 지정하고 CI는 Chromium을 사용합니다. 검사 정의와 실제 결과를 구분해 기록합니다. diff --git a/dist/app.js b/dist/app.js new file mode 100644 index 0000000..bd8e699 --- /dev/null +++ b/dist/app.js @@ -0,0 +1,1201 @@ +const STORAGE_KEY = 'java-edu:progress:v1'; +const VERSIONS = [8, 11, 17, 21]; +const STEPS = ['theory', 'example', 'quiz', 'practice', 'lab']; +const STEP_LABELS = { theory: '이론', example: '예제', quiz: '문제', practice: '실무', lab: '코드 실습' }; +const TRANSITION_IDS = new Set(['java-basics', 'oop', 'polymorphism', 'collections', 'generics', 'exceptions', 'streams', 'modern-apis', 'records-sealed']); +const ADVANCED_IDS = new Set(['oop', 'polymorphism', 'generics', 'streams', 'concurrency', 'records-sealed', 'java21', 'testing-design']); +const EXECUTION_STATUSES = new Set(['passed', 'failed', 'compile_error', 'runtime_error', 'timeout', 'provider_error']); +const SOURCE_LIMIT = 50000; +const NOTE_LIMIT = 20000; +const mobileMedia = window.matchMedia('(max-width: 899px)'); +let lessons = []; +let exercises = []; +let jobEvidence = []; +let saveTimer; +let toastTimer; +let runtimeSequence = 0; + +const state = { + version: 21, track: 'all', search: '', lessonId: '', step: 'theory', + quizPassed: new Set(), labPassed: new Map(), drafts: new Map(), notes: new Map(), + quizAnswers: new Map(), quizResults: new Map(), labResults: new Map(), undo: new Map(), + storage: 'ready', runtime: null, runtimeStatus: 'checking', runtimeMessage: '', + pending: null, menuOpen: false, +}; + +const dom = Object.fromEntries([ + 'workspace-grid', 'loading-state', 'lesson-list', 'lesson-card', 'lesson-panel', 'lesson-title', + 'lesson-description', 'lesson-category', 'lesson-position', 'lesson-meta', 'lesson-achievements', + 'lesson-footer', 'source-list', 'evidence-list', 'reference-column', 'version-select', + 'lesson-search', 'curriculum-sidebar', 'mobile-menu-toggle', 'drawer-backdrop', +].map((id) => [id, document.getElementById(id)])); + +function el(tag, className = '', text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = String(text); + return node; +} + +function icon(name) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + svg.setAttribute('class', 'icon'); + svg.setAttribute('aria-hidden', 'true'); + use.setAttribute('href', `#icon-${name}`); + svg.append(use); + return svg; +} + +function actionButton(text, className, onClick, id) { + const button = el('button', className, text); + button.type = 'button'; + if (id) { button.id = id; button.dataset.testid = id; } + button.addEventListener('click', onClick); + return button; +} + +function safeLink(title, url, className = '') { + const link = el('a', className); + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Unsupported URL'); + link.href = parsed.href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + } catch { + link.removeAttribute('href'); + } + if (title) link.textContent = title; + return link; +} + +function announce(message) { + const target = document.getElementById('announcer'); + target.textContent = ''; + window.setTimeout(() => { target.textContent = message; }, 30); +} + +function toast(message) { + const target = document.getElementById('toast'); + clearTimeout(toastTimer); + target.textContent = message; + target.classList.add('is-visible'); + toastTimer = window.setTimeout(() => target.classList.remove('is-visible'), 4500); +} + +function currentLesson() { return visibleLessons().find((lesson) => lesson.id === state.lessonId); } +function exerciseFor(lesson) { return lesson && exercises.find((exercise) => exercise.id === lesson.exerciseId || exercise.lessonId === lesson.id); } +function sourceFor(exercise) { return state.drafts.has(exercise.id) ? state.drafts.get(exercise.id) : exercise.template; } +function labComplete(lesson) { + const exercise = exerciseFor(lesson); + return Boolean(exercise && state.labPassed.has(exercise.id) && state.labPassed.get(exercise.id) === sourceFor(exercise)); +} +function lessonComplete(lesson) { return state.quizPassed.has(lesson.id) && labComplete(lesson); } + +function visibleLessons() { + const query = state.search.trim().toLocaleLowerCase('ko'); + return lessons.filter((lesson) => { + if (Number(lesson.minVersion) > state.version) return false; + if (state.track !== 'all') { + const hasDeclaredTrack = ['transition', 'advanced', 'both'].includes(lesson.track); + const fallback = state.track === 'transition' ? TRANSITION_IDS : ADVANCED_IDS; + if (hasDeclaredTrack ? lesson.track !== state.track && lesson.track !== 'both' : !fallback.has(lesson.id)) return false; + } + if (!query) return true; + const text = [lesson.title, lesson.description, lesson.category, ...(lesson.goals || []), ...(lesson.jobTags || [])].join(' ').toLocaleLowerCase('ko'); + return text.includes(query); + }); +} + +function ensureSelection() { + const filtered = visibleLessons(); + if (!filtered.some((lesson) => lesson.id === state.lessonId)) { + const nextId = filtered[0]?.id || ''; + if (nextId !== state.lessonId) { + cancelPending('수업 변경', false); + state.lessonId = nextId; + state.step = 'theory'; + } + } +} + +function restoreStorage() { + let raw; + try { raw = window.localStorage.getItem(STORAGE_KEY); } + catch { state.storage = 'unavailable'; return; } + if (!raw) return; + try { + const record = JSON.parse(raw); + if (!record || typeof record !== 'object' || record.schema !== 1) throw new Error('Invalid record'); + if (VERSIONS.includes(record.version)) state.version = record.version; + if (lessons.some((lesson) => lesson.id === record.lessonId)) state.lessonId = record.lessonId; + if (Array.isArray(record.quizPassed)) { + state.quizPassed = new Set(record.quizPassed.filter((id) => lessons.some((lesson) => lesson.id === id))); + } + for (const [key, destination, idKey, collection, limit] of [ + ['drafts', state.drafts, 'exerciseId', exercises, SOURCE_LIMIT], + ['labPassed', state.labPassed, 'exerciseId', exercises, SOURCE_LIMIT], + ['notes', state.notes, 'lessonId', lessons, NOTE_LIMIT], + ]) { + if (!Array.isArray(record[key])) continue; + for (const entry of record[key]) { + if (!entry || typeof entry !== 'object') continue; + const value = key === 'notes' ? entry.text : entry.source; + if (typeof value !== 'string' || value.length > limit || !collection.some((item) => item.id === entry[idKey])) continue; + destination.set(entry[idKey], value); + } + } + } catch { + state.storage = 'invalid'; + } +} + +function updateStorageIndicator() { + const indicator = document.getElementById('storage-indicator'); + indicator.classList.toggle('is-unavailable', state.storage !== 'ready'); + const label = state.storage === 'unavailable' ? '이번 방문 동안만 기록' : state.storage === 'invalid' ? '기존 기록을 읽지 못했습니다' : '이 브라우저에 저장'; + document.getElementById('storage-label').textContent = label; + indicator.title = state.storage === 'unavailable' ? '브라우저 저장소를 사용할 수 없습니다. 작성 코드를 별도로 복사해 주세요.' : '진도와 코드는 현재 브라우저에만 저장됩니다.'; + const editorSave = document.getElementById('editor-save-state'); + if (editorSave) editorSave.textContent = state.storage === 'unavailable' ? '저장 불가 · 코드 복사 권장' : '브라우저에 저장'; + const notesSave = document.getElementById('notes-save-state'); + if (notesSave) notesSave.textContent = state.storage === 'unavailable' ? '브라우저 저장을 사용할 수 없어 이번 방문 동안만 유지됩니다.' : '이 브라우저에 저장됩니다. 설계 메모는 자동 평가하지 않습니다.'; +} + +function saveStorage() { + clearTimeout(saveTimer); + const record = { + schema: 1, version: state.version, lessonId: state.lessonId, + quizPassed: [...state.quizPassed], + labPassed: [...state.labPassed].map(([exerciseId, source]) => ({ exerciseId, source })), + drafts: [...state.drafts].map(([exerciseId, source]) => ({ exerciseId, source })), + notes: [...state.notes].map(([lessonId, text]) => ({ lessonId, text })), + }; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record)); + state.storage = 'ready'; + } catch { + if (state.storage !== 'unavailable') toast('브라우저에 저장할 수 없습니다. 작성 코드를 별도로 복사해 주세요.'); + state.storage = 'unavailable'; + } + updateStorageIndicator(); +} + +function scheduleSave() { clearTimeout(saveTimer); saveTimer = window.setTimeout(saveStorage, 450); } + +function readHash() { + const hash = window.location.hash.slice(1); + if (!hash || hash === 'learning-workspace' || hash === 'lesson-title') return; + let requestedId; + let requestedStep; + let requestedVersion; + try { + if (hash.includes('=')) { + const params = new URLSearchParams(hash); + requestedId = params.get('lesson'); + requestedStep = params.get('step'); + const candidate = Number(params.get('version')); + requestedVersion = VERSIONS.includes(candidate) ? candidate : null; + } else { + requestedId = decodeURIComponent(hash); + } + } catch { requestedId = ''; } + const requestedLesson = lessons.find((lesson) => lesson.id === requestedId); + state.track = 'all'; + state.search = ''; + if (requestedVersion) state.version = requestedVersion; + if (requestedLesson) { + if (!requestedVersion && requestedLesson.minVersion > state.version) { + state.version = VERSIONS.find((version) => version >= requestedLesson.minVersion) || 21; + } + if (requestedLesson.minVersion <= state.version) { + state.lessonId = requestedLesson.id; + state.step = STEPS.includes(requestedStep) ? requestedStep : 'theory'; + } else { + state.lessonId = ''; + state.step = 'theory'; + announce(`요청한 수업에는 Java ${requestedLesson.minVersion} 이상이 필요합니다. Java ${state.version}에서 사용할 수 있는 수업을 표시합니다.`); + } + } else { + state.lessonId = ''; + state.step = 'theory'; + announce('수업 링크를 찾지 못해 첫 번째 수업을 표시합니다.'); + } +} + +function syncHash(mode = 'replace') { + if (!state.lessonId) return; + const params = new URLSearchParams({ lesson: state.lessonId, step: state.step, version: String(state.version) }); + const next = `#${params.toString()}`; + if (window.location.hash === next) return; + try { window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', next); } + catch { /* Browsers with restricted history still retain all in-page interactions. */ } +} + +function renderControls() { + dom['version-select'].value = String(state.version); + dom['lesson-search'].value = state.search; + document.querySelectorAll('[data-track]').forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.track === state.track))); +} + +function renderSidebar() { + const filtered = visibleLessons(); + dom['lesson-list'].replaceChildren(); + document.getElementById('filtered-count').textContent = `${filtered.length}개 수업`; + document.getElementById('filtered-completed').textContent = `${filtered.filter(lessonComplete).length} / ${filtered.length} 완료`; + if (!filtered.length) { + dom['lesson-list'].append(el('p', 'sidebar-empty', '조건에 맞는 수업이 없습니다. 검색어 또는 학습 기준 버전을 바꿔 보세요.')); + return; + } + for (const lesson of filtered) { + const index = lessons.findIndex((item) => item.id === lesson.id); + const complete = lessonComplete(lesson); + const link = el('a', `lesson-link${lesson.id === state.lessonId ? ' is-active' : ''}${complete ? ' is-complete' : ''}`); + link.href = `#${new URLSearchParams({ lesson: lesson.id, step: 'theory', version: String(state.version) })}`; + link.dataset.lessonId = lesson.id; + link.dataset.complete = String(complete); + link.dataset.testid = `lesson-${lesson.id}`; + if (lesson.id === state.lessonId) link.setAttribute('aria-current', 'page'); + const number = el('span', 'lesson-index'); + number.append(complete ? icon('check') : document.createTextNode(String(index + 1).padStart(2, '0'))); + if (complete) number.append(el('span', 'sr-only', '수업 완료')); + const copy = el('span', 'lesson-link-copy'); + copy.append(el('span', 'lesson-link-title', lesson.title)); + const meta = el('span', 'lesson-link-meta'); + meta.append(el('span', '', `Java ${lesson.minVersion}+`), el('span', '', complete ? '완료' : lesson.track === 'advanced' ? '실무 심화' : lesson.track === 'both' ? '공통' : 'Java 전환')); + copy.append(meta); + link.append(number, copy); + link.addEventListener('click', (event) => { + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return; + event.preventDefault(); + selectLesson(lesson.id, 'theory', true); + }); + dom['lesson-list'].append(link); + } +} + +function continuationLesson() { + const compatible = lessons.filter((lesson) => lesson.minVersion <= state.version); + return compatible.find((lesson) => lesson.id === state.lessonId && !lessonComplete(lesson)) || compatible.find((lesson) => !lessonComplete(lesson)) || compatible[0]; +} + +function renderProgress() { + const count = lessons.filter(lessonComplete).length; + document.getElementById('lesson-total').textContent = String(lessons.length); + document.getElementById('overall-completed').textContent = String(count); + document.getElementById('overall-denominator').textContent = ` / ${lessons.length}`; + const progress = document.getElementById('overall-progress'); + progress.setAttribute('aria-valuemax', String(lessons.length)); + progress.setAttribute('aria-valuenow', String(count)); + progress.setAttribute('aria-valuetext', `전체 ${lessons.length}개 수업 중 ${count}개 완료`); + document.getElementById('overall-progress-fill').style.width = `${lessons.length ? (count / lessons.length) * 100 : 0}%`; + const next = continuationLesson(); + document.getElementById('continue-label').textContent = next && lessonComplete(next) ? '완료한 수업 다시 보기' : state.quizPassed.size || state.drafts.size ? '이어서 학습하기' : '학습 시작하기'; + document.getElementById('continue-title').textContent = next?.title || '수업을 준비하고 있습니다'; + document.getElementById('continue-button').disabled = !next; +} + +function renderAchievements(lesson = currentLesson()) { + dom['lesson-achievements'].replaceChildren(); + if (!lesson) return; + const exercise = exerciseFor(lesson); + const quizDone = state.quizPassed.has(lesson.id); + const labDone = labComplete(lesson); + const priorLab = Boolean(exercise && state.labPassed.has(exercise.id) && !labDone); + for (const [kind, complete, text, past] of [ + ['quiz', quizDone, quizDone ? '객관식 성취 완료' : '객관식 미완료', false], + ['lab', labDone, labDone ? '현재 코드 제출 통과' : priorLab ? '이전 코드 통과 · 현재 코드 재제출 필요' : '코드 제출 미완료', priorLab], + ]) { + const badge = el('span', `achievement-badge${complete ? ' is-complete' : ''}${past ? ' is-past' : ''}`, text); + badge.dataset.achievement = kind; + badge.dataset.complete = String(complete); + dom['lesson-achievements'].append(badge); + } + dom['lesson-card'].dataset.complete = String(lessonComplete(lesson)); +} + +function renderLesson() { + const lesson = currentLesson(); + const tabs = document.querySelector('.lesson-tabs'); + dom['lesson-panel'].replaceChildren(); + dom['lesson-meta'].replaceChildren(); + dom['lesson-footer'].replaceChildren(); + dom['lesson-achievements'].replaceChildren(); + tabs.hidden = !lesson; + dom['lesson-footer'].hidden = !lesson; + dom['lesson-card'].dataset.lessonId = lesson?.id || ''; + if (!lesson) { + dom['lesson-category'].textContent = 'FIND YOUR NEXT LESSON'; + dom['lesson-title'].textContent = '수업을 찾지 못했습니다'; + dom['lesson-description'].textContent = '현재 검색어와 필터에 맞는 수업이 없습니다.'; + dom['lesson-position'].textContent = '—'; + dom['lesson-panel'].setAttribute('aria-labelledby', 'lesson-title'); + const empty = el('div', 'empty-state'); + empty.append(icon('search'), el('h3', '', '다른 키워드로 찾아볼까요?'), el('p', '', `Java ${state.version}에서 사용할 수 있는 수업 중 제목·설명·학습 목표·기술 키워드를 검색합니다.`), actionButton('검색과 트랙 초기화', 'button button-secondary', () => { state.track = 'all'; state.search = ''; applyFilters(); })); + dom['lesson-panel'].append(empty); + return; + } + dom['lesson-category'].textContent = lesson.category || 'JAVA CORE'; + dom['lesson-title'].textContent = lesson.title; + dom['lesson-description'].textContent = lesson.description; + dom['lesson-position'].textContent = `${String(lessons.indexOf(lesson) + 1).padStart(2, '0')} / ${String(lessons.length).padStart(2, '0')}`; + dom['lesson-meta'].append(el('span', 'meta-chip', `Java ${lesson.minVersion}+`)); + if (lesson.featureSince) dom['lesson-meta'].append(el('span', 'meta-chip is-neutral', `기능 도입 ${String(lesson.featureSince)}`)); + const trackLabel = lesson.track === 'both' ? '전환 · 심화 공통' : lesson.track === 'advanced' ? '실무 심화' : 'Java 전환'; + dom['lesson-meta'].append(el('span', 'meta-chip is-neutral', trackLabel)); + if (lesson.duration) { + const time = el('span', 'meta-chip is-neutral'); + time.append(icon('clock'), document.createTextNode(typeof lesson.duration === 'number' ? `${lesson.duration}분` : lesson.duration)); + dom['lesson-meta'].append(time); + } + if (lesson.preview) dom['lesson-meta'].append(el('span', 'meta-chip is-preview', 'Preview 기능 포함')); + renderAchievements(lesson); + document.querySelectorAll('[data-step]').forEach((tab) => { + const selected = tab.dataset.step === state.step; + tab.setAttribute('aria-selected', String(selected)); + tab.tabIndex = selected ? 0 : -1; + }); + dom['lesson-panel'].setAttribute('aria-labelledby', `tab-${state.step}`); + const renderers = { theory: renderTheory, example: renderExample, quiz: renderQuiz, practice: renderPractice, lab: renderLab }; + renderers[state.step](lesson); + renderLessonFooter(lesson); +} + +function renderLessonFooter(lesson) { + const filtered = visibleLessons(); + const index = filtered.findIndex((item) => item.id === lesson.id); + const previous = filtered[index - 1]; + const next = filtered[index + 1]; + if (previous) { + const button = actionButton('← 이전 수업', '', () => selectLesson(previous.id, 'theory', true)); + button.append(el('strong', '', previous.title)); + dom['lesson-footer'].append(button); + } + if (next) { + const button = actionButton('다음 수업 →', '', () => selectLesson(next.id, 'theory', true)); + button.append(el('strong', '', next.title)); + dom['lesson-footer'].append(button); + } + if (!previous && !next) dom['lesson-footer'].append(el('p', 'inline-note', '현재 조건에 맞는 유일한 수업입니다.')); +} + +function renderReferences() { + const lesson = currentLesson(); + dom['source-list'].replaceChildren(); + dom['evidence-list'].replaceChildren(); + if (!lesson) return; + for (const source of lesson.sources || []) { + const link = safeLink('', source.url, 'source-link'); + link.append(el('strong', '', source.title), icon('external')); + const facts = [source.edition, source.checkedAt ? `확인 ${source.checkedAt}` : '공식 원문'].filter(Boolean); + link.append(el('span', '', facts.join(' · '))); + dom['source-list'].append(link); + } + const evidence = jobEvidence.filter((item) => item.lessonIds?.includes(lesson.id)); + if (!evidence.length) { + dom['evidence-list'].append(el('p', 'reference-description', '이 수업에 직접 연결한 공고 사례는 아직 없습니다. 공식 문서와 실무 시나리오를 먼저 살펴보세요.')); + return; + } + evidence.forEach((item, index) => { + const card = el('div', 'evidence-item'); + const company = el('p', 'evidence-company', item.company); + company.append(el('span', 'evidence-status', item.status || '상태 미확인')); + const link = safeLink(item.title, item.url, 'evidence-title'); + link.append(icon('external')); + card.append(company, link, el('p', '', item.summary)); + const tags = el('div', 'technology-tags'); + (item.technologies || []).slice(0, 5).forEach((tag) => tags.append(el('span', '', tag))); + card.append(tags, el('p', 'evidence-date', `확인 ${item.checkedAt || '미기록'} · ${item.access === 'partial' ? '일부 본문 확인' : '본문 확인'}`)); + if (index < 2) dom['evidence-list'].append(card); + else { + let more = dom['evidence-list'].querySelector('details'); + if (!more) { + more = el('details', 'evidence-more'); + more.append(el('summary', 'text-button', `사례 ${evidence.length - 2}개 더 보기`)); + dom['evidence-list'].append(more); + } + more.append(card); + } + }); +} + +function renderAll() { + renderControls(); + renderSidebar(); + renderProgress(); + renderLesson(); + renderReferences(); + updateStorageIndicator(); +} + +function nextStepButton(step, label) { + const row = el('div', 'next-step-row'); + const button = actionButton(label || `${STEP_LABELS[step]}로 이어가기`, 'button button-secondary', () => selectStep(step, true)); + button.append(icon('arrow')); + row.append(button); + return row; +} + +function sectionTitle(text, number) { + const heading = el('div', 'section-heading'); + if (number) heading.append(el('span', 'section-number', number)); + heading.append(el('h3', '', text)); + return heading; +} + +function renderTheory(lesson) { + const goals = el('div', 'learning-goals'); + goals.append(el('p', 'small-label', 'AFTER THIS LESSON')); + const list = el('ul'); + (lesson.goals || []).forEach((goal) => { const item = el('li'); item.append(icon('check'), el('span', '', goal)); list.append(item); }); + goals.append(list); + dom['lesson-panel'].append(goals); + (lesson.theory || []).forEach((part, index) => { + const section = el('section', 'theory-section'); + section.append(sectionTitle(part.heading, String(index + 1).padStart(2, '0')), el('p', '', part.text)); + dom['lesson-panel'].append(section); + }); + dom['lesson-panel'].append(nextStepButton('example', '예제로 개념 확인하기')); +} + +async function copyText(text, selectableNode) { + try { + if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable'); + await navigator.clipboard.writeText(text); + toast('코드를 복사했습니다.'); + } catch { + if (selectableNode instanceof HTMLTextAreaElement) { + selectableNode.focus(); + selectableNode.select(); + } else if (selectableNode) { + const range = document.createRange(); + range.selectNodeContents(selectableNode); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + toast('자동 복사를 사용할 수 없습니다. 선택된 코드를 직접 복사해 주세요.'); + } +} + +function renderExample(lesson) { + const example = lesson.example; + if (!example) return; + dom['lesson-panel'].append(sectionTitle(example.title || '코드로 개념 살펴보기'), el('p', 'section-caption', '아래는 학습용 예제와 예상 출력입니다. 실제 실행은 코드 실습에서 확인하세요.')); + const shell = el('div', 'code-shell'); + const heading = el('div', 'code-heading'); + const filename = el('span'); filename.append(icon('code'), document.createTextNode('Main.java')); + const pre = el('pre', 'code-block'); + pre.tabIndex = 0; + pre.setAttribute('aria-label', '학습 예제 소스 코드'); + pre.append(el('code', '', example.code)); + const copy = actionButton('코드 복사', 'copy-button', () => copyText(example.code, pre), 'copy-example'); + copy.prepend(icon('copy')); + heading.append(filename, copy); + shell.append(heading, pre); + const output = el('div', 'expected-output'); + output.append(el('p', 'small-label', '예상 출력 · 실행 결과가 아닙니다'), el('pre', '', example.output || '(표준 출력 없음)')); + dom['lesson-panel'].append(shell, output, el('p', 'explanation', example.explanation), nextStepButton('quiz', '문제로 이해 확인하기')); +} + +function renderQuiz(lesson) { + const questions = lesson.quiz || []; + dom['lesson-panel'].append(sectionTitle('이해한 개념을 확인해 보세요'), el('p', 'section-caption', `총 ${questions.length}문항입니다. 모든 문항에 정답을 제출하면 객관식 성취가 기록됩니다.`)); + if (!questions.length) { + dom['lesson-panel'].append(el('p', 'inline-note', '이 수업의 문제를 준비하고 있습니다.')); + return; + } + if (!state.quizAnswers.has(lesson.id)) state.quizAnswers.set(lesson.id, new Map()); + const answers = state.quizAnswers.get(lesson.id); + const result = state.quizResults.get(lesson.id); + if (result) { + const allCorrect = result.correct === questions.length; + const summary = el('div', `result-summary${allCorrect ? '' : ' is-incomplete'}`); + summary.id = 'quiz-result'; + summary.tabIndex = -1; + summary.dataset.correct = String(result.correct); + summary.append(el('strong', '', allCorrect ? '모든 개념을 정확히 이해했습니다.' : `${questions.length}문항 중 ${result.correct}문항 정답`), el('p', '', allCorrect ? '객관식 성취를 기록했습니다. 실무 시나리오를 읽고 코드 실습으로 이어가세요.' : '각 해설을 확인하고 다시 풀어 보세요. 모든 정답을 제출해야 성취가 기록됩니다.')); + dom['lesson-panel'].append(summary); + } else if (state.quizPassed.has(lesson.id)) { + dom['lesson-panel'].append(el('p', 'inline-note', '이미 객관식 성취를 완료한 수업입니다. 다시 풀어도 획득한 성취는 유지됩니다.')); + } + const form = el('form', 'quiz-form'); + form.id = 'quiz-form'; + form.noValidate = true; + questions.forEach((question, index) => { + const fieldset = el('fieldset', 'quiz-question'); + const legend = el('legend'); + legend.append(el('span', 'question-number', `Q${String(index + 1).padStart(2, '0')}.`), document.createTextNode(question.question)); + fieldset.append(legend); + const options = el('div', 'quiz-options'); + question.options.forEach((option, optionIndex) => { + const label = el('label', 'quiz-option'); + const input = el('input'); + input.type = 'radio'; + input.name = `quiz-${question.id}`; + input.value = String(optionIndex); + input.checked = answers.get(question.id) === optionIndex; + input.disabled = Boolean(result); + input.required = true; + input.addEventListener('change', () => { + answers.set(question.id, optionIndex); + const error = document.getElementById('quiz-error'); + if (error) error.textContent = ''; + }); + label.append(input, el('span', 'option-letter', String.fromCharCode(65 + optionIndex)), el('span', '', option)); + options.append(label); + }); + fieldset.append(options); + if (result) { + const correct = answers.get(question.id) === question.correctIndex; + const feedback = el('div', `quiz-feedback${correct ? '' : ' is-wrong'}`); + feedback.append(el('strong', '', correct ? '정답입니다' : `정답: ${String.fromCharCode(65 + question.correctIndex)}. ${question.options[question.correctIndex]}`), el('p', '', question.explanation)); + fieldset.append(feedback); + } + form.append(fieldset); + }); + const error = el('p', 'form-error'); + error.id = 'quiz-error'; + error.setAttribute('role', 'alert'); + form.append(error); + const actions = el('div', 'quiz-actions'); + if (!result) { + const submit = el('button', 'button button-primary', '정답 확인하기'); + submit.type = 'submit'; + submit.id = 'quiz-submit'; + submit.dataset.testid = 'quiz-submit'; + actions.append(submit); + } else { + actions.append(actionButton('다시 풀기', 'button button-secondary', () => { + state.quizResults.delete(lesson.id); + state.quizAnswers.set(lesson.id, new Map()); + renderLesson(); + document.querySelector('#quiz-form input')?.focus(); + announce('답안을 초기화했습니다. 문제를 다시 풀어 보세요.'); + }, 'quiz-retry')); + if (result.correct === questions.length) actions.append(actionButton('실무 시나리오 살펴보기 →', 'button button-primary', () => selectStep('practice', true))); + } + form.append(actions); + form.addEventListener('submit', (event) => { + event.preventDefault(); + const unanswered = questions.find((question) => !answers.has(question.id)); + if (unanswered) { + error.textContent = `${questions.indexOf(unanswered) + 1}번 문제의 답을 선택해 주세요.`; + Array.from(form.querySelectorAll('input')).find((input) => input.name === `quiz-${unanswered.id}`)?.focus(); + return; + } + const correct = questions.filter((question) => answers.get(question.id) === question.correctIndex).length; + state.quizResults.set(lesson.id, { correct }); + if (correct === questions.length) state.quizPassed.add(lesson.id); + saveStorage(); + renderAll(); + document.getElementById('quiz-result')?.focus(); + announce(`${questions.length}문항 중 ${correct}문항 정답입니다.${correct === questions.length ? ' 객관식 성취를 완료했습니다.' : ' 해설을 확인해 주세요.'}`); + }); + dom['lesson-panel'].append(form); +} + +function renderPractice(lesson) { + const practice = lesson.practice; + if (!practice) return; + dom['lesson-panel'].append(sectionTitle(practice.title || '실무의 제약을 더해 보기'), el('p', 'section-caption', '동작하는 코드에서 한 걸음 더 나아가, 선택의 이유와 실패 조건을 설명해 보세요.')); + const scenario = el('div', 'scenario-card'); + scenario.append(el('p', 'small-label', 'THE SCENARIO'), el('p', '', practice.scenario)); + dom['lesson-panel'].append(scenario); + const tasks = el('ol', 'practice-tasks'); + (practice.tasks || []).forEach((task, index) => { + const item = el('li'); + item.append(el('span', 'task-index', String(index + 1).padStart(2, '0')), el('span', '', task)); + tasks.append(item); + }); + dom['lesson-panel'].append(tasks); + if (practice.takeaway) { + const takeaway = el('div', 'takeaway'); + takeaway.append(el('strong', '', '기억할 설계 기준'), el('p', '', practice.takeaway)); + dom['lesson-panel'].append(takeaway); + } + const label = el('label', 'design-notes-label', '나의 설계 판단'); + label.htmlFor = 'design-notes'; + const notes = el('textarea', 'design-notes'); + notes.id = 'design-notes'; + notes.value = state.notes.get(lesson.id) || ''; + notes.maxLength = NOTE_LIMIT; + notes.placeholder = '어떤 선택을 했나요? 책임의 위치, 실패 조건, 다른 대안과의 차이를 적어 보세요.'; + notes.setAttribute('aria-describedby', 'notes-save-state'); + notes.addEventListener('input', () => { state.notes.set(lesson.id, notes.value); scheduleSave(); }); + const footnote = el('p', 'notes-footnote'); + footnote.id = 'notes-save-state'; + dom['lesson-panel'].append(label, notes, footnote, nextStepButton('lab', '직접 구현해 보기')); +} + +function renderLab(lesson) { + const exercise = exerciseFor(lesson); + if (!exercise) { + dom['lesson-panel'].append(sectionTitle('코드 실습 준비 중'), el('p', 'section-caption', '이 수업에 연결할 실습을 준비하고 있습니다.')); + return; + } + const heading = el('div', 'lab-header'); + heading.append(el('h3', '', exercise.title), el('span', 'lab-mode', exercise.mode === 'class' ? 'CLASS DESIGN' : 'METHOD IMPLEMENTATION')); + dom['lesson-panel'].append(heading, el('p', 'section-caption', exercise.mode === 'class' ? '주어진 계약을 만족하는 클래스를 직접 설계하세요. 클래스 전체를 편집할 수 있습니다.' : '제공된 클래스와 메서드 골격을 완성하세요. 클래스 전체를 편집할 수 있습니다.')); + const runtime = el('div', 'runtime-card'); + runtime.id = 'runtime-info'; + runtime.setAttribute('aria-live', 'polite'); + dom['lesson-panel'].append(runtime); + const instructions = el('ol', 'lab-instructions'); + (exercise.instructions || []).forEach((instruction) => instructions.append(el('li', '', instruction))); + dom['lesson-panel'].append(instructions); + if (exercise.sampleDescription) { + const sample = el('div', 'expected-output'); + sample.append(el('p', 'small-label', '공개 예시 · 예상 검증 결과'), el('p', 'section-caption', exercise.sampleDescription), el('pre', '', exercise.sampleOutput || '')); + dom['lesson-panel'].append(sample); + } + if (exercise.rubric?.length) { + const rubric = el('details', 'lab-rubric'); + rubric.append(el('summary', '', '설계를 설명할 때 확인할 기준')); + const list = el('ul'); + exercise.rubric.forEach((item) => list.append(el('li', '', item))); + rubric.append(list, el('p', 'inline-note', '이 기준은 자기 점검용입니다. 테스트 통과가 설계 설명의 자동 승인을 의미하지 않습니다.')); + dom['lesson-panel'].append(rubric); + } + const editorShell = el('div', 'editor-shell'); + const toolbar = el('div', 'editor-toolbar'); + const filename = el('span', 'editor-filename'); + filename.append(icon('code'), document.createTextNode('Solution · Java')); + const editorTools = el('div', 'editor-tools'); + const editor = el('textarea', 'code-editor'); + editor.id = 'code-editor'; + editor.dataset.testid = 'code-editor'; + editor.value = sourceFor(exercise); + editor.spellcheck = false; + editor.autocomplete = 'off'; + editor.setAttribute('autocapitalize', 'off'); + editor.setAttribute('autocorrect', 'off'); + editor.setAttribute('wrap', 'off'); + editor.setAttribute('aria-label', `${exercise.title} Java 코드 편집기`); + editor.setAttribute('aria-describedby', 'editor-hint editor-save-state'); + editor.maxLength = SOURCE_LIMIT; + editor.addEventListener('input', () => { + state.drafts.set(exercise.id, editor.value); + state.undo.delete(exercise.id); + renderUndo(exercise); + refreshDraftDependentUi(exercise); + scheduleSave(); + }); + editor.addEventListener('blur', saveStorage); + editorTools.append( + actionButton('들여쓰기', '', () => { + editor.setRangeText(' ', editor.selectionStart, editor.selectionEnd, 'end'); + editor.dispatchEvent(new Event('input', { bubbles: true })); + editor.focus(); + }, 'indent-code'), + actionButton('복사', '', () => copyText(editor.value, editor), 'copy-code'), + actionButton('초기 코드', '', () => { + const previous = sourceFor(exercise); + if (previous === exercise.template) { toast('이미 초기 코드입니다.'); return; } + state.undo.set(exercise.id, previous); + state.drafts.set(exercise.id, exercise.template); + editor.value = exercise.template; + refreshDraftDependentUi(exercise); + renderUndo(exercise); + saveStorage(); + announce('초기 코드로 되돌렸습니다. 바로 아래 되돌리기 버튼으로 이전 코드를 복구할 수 있습니다.'); + }, 'lab-reset'), + ); + toolbar.append(filename, editorTools); + const editorStatus = el('div', 'editor-status'); + const lines = el('span'); lines.id = 'editor-line-count'; + const save = el('span'); save.id = 'editor-save-state'; + editorStatus.append(lines, save); + editorShell.append(toolbar, editor, editorStatus); + const hint = el('p', 'editor-hint', '일반 텍스트 편집기 · 자동 완성 없음 · Tab은 다음 항목으로 이동합니다. 들여쓰기는 공백 4칸입니다. class Solution 골격을 유지하고 최상위 public class·package·Main 선언은 추가하지 마세요. 실행 요청은 UTF-8 32 KiB까지 전송할 수 있습니다.'); + hint.id = 'editor-hint'; + const undo = el('div', 'reset-undo'); undo.id = 'lab-reset-undo'; undo.hidden = true; + const disclosure = el('p', 'execution-disclosure', '실행·제출을 누르면 작성 코드가 외부 서비스 Wandbox로 전송됩니다. 운영 로그에 코드가 남을 수 있으니 비밀정보를 입력하지 마세요. '); + disclosure.id = 'execution-disclosure'; + disclosure.append(safeLink('Wandbox 서비스 안내 ↗', 'https://github.com/melpon/wandbox#faq')); + const actions = el('div', 'lab-actions'); + const run = actionButton('예시 실행', 'button button-secondary', () => executeCode(exercise, 'run'), 'run-code'); + run.prepend(icon('play')); + run.setAttribute('aria-describedby', 'execution-disclosure'); + const submit = actionButton('제출하고 검증하기', 'button button-primary', () => executeCode(exercise, 'submit'), 'submit-code'); + submit.prepend(icon('check')); + submit.setAttribute('aria-describedby', 'execution-disclosure'); + const cancel = actionButton('응답 대기 취소', 'button button-quiet', () => cancelPending('user'), 'cancel-execution'); + cancel.hidden = true; + const executionState = el('span', 'execution-state'); executionState.id = 'execution-state'; executionState.setAttribute('aria-live', 'polite'); + actions.append(run, submit, cancel, executionState); + const output = el('section', 'execution-panel'); + output.id = 'execution-output'; + output.dataset.testid = 'execution-output'; + output.setAttribute('aria-label', '실제 Java 실행 결과'); + output.setAttribute('aria-busy', 'false'); + output.tabIndex = -1; + dom['lesson-panel'].append(editorShell, hint, undo, disclosure, actions, output); + renderRuntime(); + renderUndo(exercise); + refreshEditorCounter(exercise); + renderExecution(exercise); + updateLabControls(); +} + +function refreshEditorCounter(exercise) { + const count = document.getElementById('editor-line-count'); + if (count) count.textContent = `${sourceFor(exercise).split('\n').length} lines · ${sourceFor(exercise).length.toLocaleString('ko-KR')} / ${SOURCE_LIMIT.toLocaleString('ko-KR')} chars`; +} + +function renderUndo(exercise) { + const row = document.getElementById('lab-reset-undo'); + if (!row) return; + row.replaceChildren(); + row.hidden = !state.undo.has(exercise.id); + if (row.hidden) return; + row.append(document.createTextNode('초기 코드로 되돌렸습니다.'), actionButton('되돌리기', '', () => { + if (!state.undo.has(exercise.id)) return; + const source = state.undo.get(exercise.id); + state.undo.delete(exercise.id); + state.drafts.set(exercise.id, source); + const editor = document.getElementById('code-editor'); + if (editor) editor.value = source; + refreshDraftDependentUi(exercise); + renderUndo(exercise); + saveStorage(); + editor?.focus(); + announce('이전 작성 코드를 복구했습니다.'); + }, 'lab-undo')); +} + +function refreshDraftDependentUi(exercise) { + refreshEditorCounter(exercise); + renderAchievements(); + renderProgress(); + renderSidebar(); + renderExecution(exercise); +} + +function renderRuntime() { + const card = document.getElementById('runtime-info'); + if (!card) return; + const exercise = exerciseFor(currentLesson()); + card.replaceChildren(); + card.classList.toggle('is-unavailable', state.runtimeStatus === 'unavailable'); + const line = el('div', 'runtime-line'); + const runtimeVersion = state.runtime?.runtime?.javaVersion || 21; + line.append(el('strong', '', `실제 실행 JVM · Java ${runtimeVersion}`), el('span', '', `컴파일 대상 · Java ${exercise?.minVersion || 8}`)); + card.append(line); + if (state.runtimeStatus === 'checking') { + card.append(el('p', '', 'Wandbox 실행 환경의 연결 상태를 확인하고 있습니다.')); + } else if (state.runtimeStatus === 'unavailable') { + card.append(el('p', '', state.runtimeMessage || '실행 서버에 연결할 수 없습니다. 수업·문제·코드 작성은 계속 이용할 수 있습니다.'), actionButton('연결 다시 확인', 'runtime-retry', loadRuntime, 'runtime-retry')); + } else { + card.append(el('p', '', `Wandbox · 이 실습은 --release ${exercise?.minVersion || 8}로 컴파일하고 Java ${runtimeVersion} JVM에서 실행합니다. 학습 기준 버전과 실제 실행 JVM은 구분됩니다.`)); + if (state.runtime?.notice) card.append(el('p', '', state.runtime.notice)); + } +} + +function updateLabControls() { + const exercise = exerciseFor(currentLesson()); + const run = document.getElementById('run-code'); + const submit = document.getElementById('submit-code'); + const cancel = document.getElementById('cancel-execution'); + const status = document.getElementById('execution-state'); + if (!run || !submit || !cancel || !status || !exercise) return; + const busy = Boolean(state.pending && state.pending.exerciseId === exercise.id); + const available = state.runtimeStatus === 'ready' && state.runtime.supportedTargets.includes(Number(exercise.minVersion)); + run.disabled = busy || !available; + submit.disabled = busy || !available; + cancel.hidden = !busy; + status.textContent = busy ? '실제 Java 컴파일·실행 중…' : state.runtimeStatus === 'checking' ? '실행 환경 확인 중' : !available ? '실행 연결을 확인해 주세요' : '코드는 버튼을 누를 때 전송됩니다'; + document.getElementById('execution-output')?.setAttribute('aria-busy', String(busy)); +} + +async function loadRuntime() { + const sequence = ++runtimeSequence; + state.runtimeStatus = 'checking'; + renderRuntime(); + updateLabControls(); + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch('/api/runtime', { signal: controller.signal, headers: { Accept: 'application/json' }, cache: 'no-store' }); + if (!response.ok) throw new Error('Runtime unavailable'); + const runtime = await response.json(); + if (sequence !== runtimeSequence) return; + if (!runtime || typeof runtime !== 'object' || runtime.available !== true || Number(runtime.runtime?.javaVersion) !== 21 || !Array.isArray(runtime.supportedTargets)) { + state.runtimeStatus = 'unavailable'; + state.runtimeMessage = typeof runtime?.notice === 'string' ? runtime.notice : '현재 Wandbox 실행 환경을 사용할 수 없습니다. 잠시 후 다시 확인해 주세요.'; + } else { + state.runtime = { ...runtime, supportedTargets: runtime.supportedTargets.map(Number).filter((version) => VERSIONS.includes(version)) }; + state.runtimeStatus = 'ready'; + state.runtimeMessage = ''; + } + } catch { + if (sequence !== runtimeSequence) return; + state.runtimeStatus = 'unavailable'; + state.runtimeMessage = '실행 서버에 연결하지 못했습니다. 수업·문제·코드 작성은 계속 이용할 수 있습니다.'; + } finally { + clearTimeout(timer); + if (sequence === runtimeSequence) { renderRuntime(); updateLabControls(); } + } +} + +function renderExecution(exercise) { + const panel = document.getElementById('execution-output'); + if (!panel) return; + panel.replaceChildren(); + const pending = state.pending?.exerciseId === exercise.id ? state.pending : null; + const result = state.labResults.get(exercise.id); + const outdated = Boolean(result && result.sourceSnapshot !== sourceFor(exercise)); + panel.dataset.status = pending ? 'running' : outdated ? 'outdated' : result?.status || 'idle'; + panel.setAttribute('aria-busy', String(Boolean(pending))); + const heading = el('div', 'execution-panel-heading'); + const title = el('strong'); title.append(icon('terminal'), document.createTextNode('실제 실행 결과')); + const metadata = result && result.runtimeVersion ? `Java ${result.runtimeVersion} · --release ${result.targetVersion}` : 'Wandbox / Java 21'; + heading.append(title, el('span', '', metadata)); + const body = el('div', 'execution-output-body'); + if (pending) { + body.append(el('p', 'execution-placeholder', '작성 코드를 컴파일하고 실제 JVM에서 실행하고 있습니다. 결과가 도착하면 여기에 표시됩니다.')); + if (pending.source !== sourceFor(exercise)) body.append(el('p', 'execution-warning', '요청 후 코드가 변경되었습니다. 도착하는 결과는 요청 당시 코드의 결과이며 현재 코드의 완료 기록에 반영하지 않습니다.')); + } else if (!result) { + body.append(el('p', 'execution-placeholder', '아직 실행하지 않았습니다. 예시 실행으로 공개 사례를 확인하고, 제출로 전체 테스트를 검증하세요. 예상 출력은 실제 실행 결과로 사용하지 않습니다.')); + } else { + if (outdated) body.append(el('p', 'execution-warning', '요청 후 코드가 변경되었습니다. 현재 코드의 실행 결과가 아닙니다. 현재 코드를 다시 실행·제출해 주세요.')); + const labels = { + passed: result.action === 'submit' ? '제출 테스트 통과' : '공개 예시 실행 통과', + failed: '검증 조건을 충족하지 못했습니다', + compile_error: '컴파일 오류', runtime_error: '실행 중 오류', timeout: '실행 시간 초과', + provider_error: '실행 요청을 완료하지 못했습니다', cancelled: '응답 대기를 취소했습니다', validation_error: '실행할 코드의 크기를 확인해 주세요', + }; + const isError = !['passed', 'cancelled'].includes(result.status); + body.append(el('p', `execution-result-label${isError ? ' is-error' : ''}`, labels[result.status] || '실행 결과 확인 필요')); + if (result.message) body.append(el('p', 'inline-note', result.message)); + if (Array.isArray(result.checks) && result.checks.length) { + const checks = el('ul', 'execution-checks'); + result.checks.forEach((check) => { + const item = el('li', check.passed ? '' : 'is-failed'); + item.append(el('span', 'check-symbol', check.passed ? '✓' : '×'), el('span', '', check.name)); + checks.append(item); + }); + body.append(checks); + body.append(el('p', 'inline-note', `${result.passed} / ${result.total} 검증 통과`)); + } + for (const [name, text, error] of [ + ['표준 출력 (stdout)', result.stdout, false], + ['표준 오류 (stderr)', result.stderr, true], + ['컴파일러 메시지', result.compilerOutput, result.status === 'compile_error'], + ]) { + if (!text) continue; + const section = el('div', `output-section${error ? ' is-error' : ''}`); + const pre = el('pre', '', text); + pre.tabIndex = 0; + pre.setAttribute('aria-label', name); + section.append(el('h4', '', name), pre); + body.append(section); + } + if (result.action === 'run' && result.status === 'passed') body.append(el('p', 'inline-note', '예시 실행은 수업 완료에 포함되지 않습니다. 전체 테스트는 제출로 확인하세요.')); + if (result.action === 'submit' && result.status === 'passed' && !outdated) body.append(el('p', 'inline-note', '현재 코드의 테스트 통과를 기록했습니다. 설계 판단과 설명은 실무 탭에서 직접 점검하세요.')); + if (Number.isFinite(result.durationMs)) body.append(el('p', 'execution-timing', `${result.provider || 'Wandbox'} · 응답 시간 ${(result.durationMs / 1000).toFixed(2)}초`)); + } + panel.append(heading, body); +} + +function cancelPending(reason = 'user', update = true) { + const pending = state.pending; + if (!pending) return; + state.pending = null; + clearTimeout(pending.timer); + pending.controller.abort(); + if (reason === 'user') { + state.labResults.set(pending.exerciseId, { + status: 'cancelled', action: pending.action, sourceSnapshot: pending.source, + message: '브라우저의 응답 대기를 중단했습니다. 외부 서비스에서 이미 시작한 실행이 종료되었다는 의미는 아닙니다.', + }); + announce('응답 대기를 취소했습니다. 외부 실행의 종료를 보장하지 않습니다.'); + } + if (update) { + const exercise = exerciseFor(currentLesson()); + if (exercise) renderExecution(exercise); + updateLabControls(); + } +} + +function executionResult(payload, request, exercise) { + if (!payload || typeof payload !== 'object' || payload.requestId !== request.id || !EXECUTION_STATUSES.has(payload.status)) throw new Error('실행 서버의 응답 형식을 확인할 수 없습니다. 다시 시도해 주세요.'); + if (Number(payload.runtimeVersion) !== 21 || Number(payload.targetVersion) !== Number(exercise.minVersion)) throw new Error('응답의 실행 환경이 이 실습의 계약과 일치하지 않습니다. 완료 기록에 반영하지 않았습니다.'); + const checks = Array.isArray(payload.checks) ? payload.checks.filter((check) => check && typeof check.name === 'string' && typeof check.passed === 'boolean').map((check) => ({ name: check.name, passed: check.passed })) : []; + const total = Number(payload.total); + const passed = Number(payload.passed); + if (payload.status === 'passed' && (!Number.isInteger(total) || total <= 0 || passed !== total || checks.length !== total || checks.some((check) => !check.passed))) throw new Error('통과 결과의 검증 항목이 일치하지 않습니다. 완료 기록에 반영하지 않았습니다.'); + return { + status: payload.status, action: request.action, sourceSnapshot: request.source, + runtimeVersion: Number(payload.runtimeVersion), targetVersion: Number(payload.targetVersion), + stdout: typeof payload.stdout === 'string' ? payload.stdout : '', + stderr: typeof payload.stderr === 'string' ? payload.stderr : '', + compilerOutput: typeof payload.compilerOutput === 'string' ? payload.compilerOutput : '', + message: typeof payload.error?.message === 'string' ? payload.error.message : '', + checks, passed: Number.isFinite(passed) ? passed : 0, total: Number.isFinite(total) ? total : checks.length, + provider: typeof payload.provider === 'string' ? payload.provider : 'Wandbox', + durationMs: Number.isFinite(payload.durationMs) ? payload.durationMs : undefined, + }; +} + +async function executeCode(exercise, action) { + if (state.pending || state.runtimeStatus !== 'ready' || !state.runtime.supportedTargets.includes(Number(exercise.minVersion))) return; + const source = sourceFor(exercise); + if (!source.trim()) { + toast('실행할 Java 코드를 입력해 주세요.'); + document.getElementById('code-editor')?.focus(); + return; + } + saveStorage(); + const id = window.crypto?.randomUUID ? window.crypto.randomUUID() : `request_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`; + const requestBody = JSON.stringify({ exerciseId: exercise.id, source, action, requestId: id }); + if (new TextEncoder().encode(requestBody).byteLength > 32 * 1024) { + state.labResults.set(exercise.id, { + status: 'validation_error', action, sourceSnapshot: source, + message: '요청 전체가 UTF-8 32 KiB를 초과합니다. 코드를 줄여 다시 시도해 주세요. 한글은 여러 바이트를 사용하며 작성 코드는 이 브라우저에 유지됩니다.', + }); + renderExecution(exercise); + announce('코드가 실행 요청의 32 KiB 제한을 초과했습니다. 코드를 줄여 주세요.'); + return; + } + const controller = new AbortController(); + const request = { id, source, action, exerciseId: exercise.id, lessonId: state.lessonId, version: state.version, controller, timedOut: false, timer: null }; + request.timer = window.setTimeout(() => { request.timedOut = true; controller.abort(); }, 60000); + state.pending = request; + state.labResults.delete(exercise.id); + updateLabControls(); + renderExecution(exercise); + announce(action === 'submit' ? '코드를 제출해 전체 테스트를 검증합니다.' : '공개 예시를 실제 Java 환경에서 실행합니다.'); + try { + const response = await fetch('/api/execute', { + method: 'POST', signal: controller.signal, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: requestBody, + }); + let payload; + try { payload = await response.json(); } + catch { throw new Error('실행 서버가 읽을 수 없는 응답을 반환했습니다. 잠시 후 다시 시도해 주세요.'); } + if (state.pending !== request) return; + if (!response.ok && (!EXECUTION_STATUSES.has(payload.status) || payload.status === 'passed')) { + throw new Error(typeof payload.error?.message === 'string' ? payload.error.message : `실행 요청을 처리하지 못했습니다. (HTTP ${response.status})`); + } + const result = executionResult(payload, request, exercise); + state.labResults.set(exercise.id, result); + const fresh = request.source === sourceFor(exercise) && request.lessonId === state.lessonId && request.version === state.version; + if (result.status === 'passed' && action === 'submit' && fresh) { + state.labPassed.set(exercise.id, request.source); + saveStorage(); + renderAchievements(); + renderSidebar(); + renderProgress(); + } else if (action === 'submit' && fresh && ['failed', 'compile_error', 'runtime_error', 'timeout'].includes(result.status)) { + state.labPassed.delete(exercise.id); + saveStorage(); + renderAchievements(); + renderSidebar(); + renderProgress(); + } + announce(!fresh ? '요청 당시 코드의 결과가 도착했습니다. 현재 코드를 다시 제출해 주세요.' : result.status === 'passed' ? action === 'submit' ? '제출한 코드가 전체 테스트를 통과했습니다.' : '공개 예시 실행을 통과했습니다.' : '실행 결과를 확인해 주세요.'); + } catch (error) { + if (state.pending !== request) return; + state.labResults.set(exercise.id, { + status: request.timedOut ? 'timeout' : 'provider_error', action, sourceSnapshot: source, + message: request.timedOut ? '60초 안에 응답을 받지 못했습니다. 외부 실행이 계속 중일 수 있습니다. 잠시 후 다시 시도해 주세요.' : error instanceof Error ? error.message : '실행 서비스에 연결할 수 없습니다.', + }); + announce(request.timedOut ? '실행 응답 대기 시간이 초과되었습니다.' : '실행 요청을 완료하지 못했습니다. 오류 안내를 확인해 주세요.'); + } finally { + clearTimeout(request.timer); + if (state.pending === request) { + state.pending = null; + renderExecution(exercise); + updateLabControls(); + } + } +} + +function selectStep(step, focus = false) { + if (!STEPS.includes(step) || !currentLesson()) return; + if (state.step === 'lab' && step !== 'lab') cancelPending('단계 변경', false); + saveStorage(); + state.step = step; + syncHash('push'); + renderLesson(); + updateStorageIndicator(); + if (focus) { + document.getElementById(`tab-${step}`)?.focus({ preventScroll: true }); + dom['lesson-panel'].scrollIntoView({ block: 'nearest', behavior: 'auto' }); + } +} + +function selectLesson(id, step = 'theory', focus = false) { + const lesson = lessons.find((item) => item.id === id); + if (!lesson || lesson.minVersion > state.version) return; + cancelPending('수업 변경', false); + state.lessonId = id; + state.step = STEPS.includes(step) ? step : 'theory'; + closeMenu(false); + saveStorage(); + syncHash('push'); + renderAll(); + if (focus) { + dom['lesson-title'].focus({ preventScroll: true }); + dom['lesson-card'].scrollIntoView({ block: 'start', behavior: 'auto' }); + } +} + +function applyFilters() { + ensureSelection(); + saveStorage(); + syncHash(); + renderAll(); + announce(`${visibleLessons().length}개 수업을 표시합니다. 현재 조건에서 ${visibleLessons().filter(lessonComplete).length}개 완료했습니다.`); +} + +function setBackgroundInert(value) { + for (const node of [document.getElementById('site-header'), document.getElementById('intro'), dom['lesson-card'], dom['reference-column'], document.querySelector('.workspace-heading'), document.querySelector('.site-footer')]) { + if (node) node.inert = value; + } +} + +function openMenu() { + if (!mobileMedia.matches || dom['workspace-grid'].hidden) return; + state.menuOpen = true; + dom['curriculum-sidebar'].classList.add('is-open'); + dom['curriculum-sidebar'].setAttribute('role', 'dialog'); + dom['curriculum-sidebar'].setAttribute('aria-modal', 'true'); + dom['curriculum-sidebar'].removeAttribute('aria-hidden'); + dom['drawer-backdrop'].hidden = false; + dom['mobile-menu-toggle'].setAttribute('aria-expanded', 'true'); + document.body.classList.add('menu-open'); + setBackgroundInert(true); + document.getElementById('mobile-menu-close').focus(); +} + +function closeMenu(restoreFocus = true) { + const wasOpen = state.menuOpen; + state.menuOpen = false; + dom['curriculum-sidebar'].classList.remove('is-open'); + dom['curriculum-sidebar'].removeAttribute('role'); + dom['curriculum-sidebar'].removeAttribute('aria-modal'); + if (mobileMedia.matches) dom['curriculum-sidebar'].setAttribute('aria-hidden', 'true'); + else dom['curriculum-sidebar'].removeAttribute('aria-hidden'); + dom['drawer-backdrop'].hidden = true; + dom['mobile-menu-toggle'].setAttribute('aria-expanded', 'false'); + document.body.classList.remove('menu-open'); + setBackgroundInert(false); + if (wasOpen && restoreFocus && mobileMedia.matches) dom['mobile-menu-toggle'].focus(); +} + +function openDialog(id) { + closeMenu(false); + const dialog = document.getElementById(id); + if (dialog && !dialog.open) dialog.showModal(); +} + +function resetProgress() { + cancelPending('학습 기록 초기화', false); + for (const key of ['quizPassed', 'labPassed', 'drafts', 'notes', 'quizAnswers', 'quizResults', 'labResults', 'undo']) state[key].clear(); + saveStorage(); + document.getElementById('reset-dialog').close(); + renderAll(); + dom['lesson-title'].focus({ preventScroll: true }); + toast('이 브라우저의 진도, 작성 코드와 설계 메모를 초기화했습니다.'); +} + +function bindEvents() { + dom['version-select'].addEventListener('change', (event) => { + const version = Number(event.target.value); + if (!VERSIONS.includes(version)) return; + cancelPending('학습 기준 버전 변경', false); + state.version = version; + applyFilters(); + }); + dom['lesson-search'].addEventListener('input', (event) => { state.search = event.target.value; applyFilters(); }); + document.querySelectorAll('[data-track]').forEach((button) => button.addEventListener('click', () => { + state.track = button.dataset.track; + applyFilters(); + })); + document.querySelectorAll('[data-step]').forEach((tab) => { + tab.addEventListener('click', () => selectStep(tab.dataset.step)); + tab.addEventListener('keydown', (event) => { + const index = STEPS.indexOf(tab.dataset.step); + let nextIndex; + if (event.key === 'ArrowRight') nextIndex = (index + 1) % STEPS.length; + else if (event.key === 'ArrowLeft') nextIndex = (index + STEPS.length - 1) % STEPS.length; + else if (event.key === 'Home') nextIndex = 0; + else if (event.key === 'End') nextIndex = STEPS.length - 1; + else return; + event.preventDefault(); + selectStep(STEPS[nextIndex]); + document.getElementById(`tab-${STEPS[nextIndex]}`).focus(); + }); + }); + document.getElementById('continue-button').addEventListener('click', () => { + const lesson = continuationLesson(); + if (!lesson) return; + state.track = 'all'; + state.search = ''; + selectLesson(lesson.id, state.quizPassed.has(lesson.id) && !labComplete(lesson) ? 'lab' : 'theory', true); + }); + dom['mobile-menu-toggle'].addEventListener('click', () => state.menuOpen ? closeMenu() : openMenu()); + document.getElementById('mobile-menu-close').addEventListener('click', () => closeMenu()); + dom['drawer-backdrop'].addEventListener('click', () => closeMenu()); + mobileMedia.addEventListener('change', () => closeMenu(false)); + document.addEventListener('keydown', (event) => { + if (!state.menuOpen) return; + if (event.key === 'Escape') { event.preventDefault(); closeMenu(); return; } + if (event.key !== 'Tab') return; + const focusable = [...dom['curriculum-sidebar'].querySelectorAll('button, a[href], input, select, textarea, [tabindex="0"]')].filter((node) => !node.disabled && node.getClientRects().length); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + }); + for (const id of ['guide-open', 'footer-guide-open']) document.getElementById(id).addEventListener('click', () => openDialog('guide-dialog')); + document.getElementById('reset-progress-open').addEventListener('click', () => openDialog('reset-dialog')); + document.getElementById('confirm-reset-progress').addEventListener('click', resetProgress); + document.querySelectorAll('[data-close-dialog]').forEach((button) => button.addEventListener('click', () => document.getElementById(button.dataset.closeDialog).close())); + window.addEventListener('hashchange', () => { + cancelPending('링크 변경', false); + readHash(); + ensureSelection(); + saveStorage(); + syncHash(); + renderAll(); + }); + window.addEventListener('beforeunload', () => { if (saveTimer) saveStorage(); }); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && saveTimer) saveStorage(); }); +} + +async function boot() { + try { + const [curriculumModule, exerciseModule, evidenceModule] = await Promise.all([ + import('./data/curriculum.js'), import('./data/exercises.js'), import('./data/job-evidence.js'), + ]); + lessons = curriculumModule.lessons; + exercises = exerciseModule.exercises; + jobEvidence = evidenceModule.jobEvidence; + if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(exercises) || !Array.isArray(jobEvidence)) throw new Error('Content data unavailable'); + restoreStorage(); + readHash(); + ensureSelection(); + bindEvents(); + renderAll(); + dom['loading-state'].hidden = true; + dom['workspace-grid'].hidden = false; + closeMenu(false); + syncHash(); + if (state.storage === 'invalid') toast('기존 저장 기록을 읽을 수 없어 새 학습 상태로 시작합니다.'); + else if (state.storage === 'unavailable') toast('브라우저 저장이 제한되어 기록은 이번 방문 동안만 유지됩니다.'); + void loadRuntime(); + } catch (error) { + dom['loading-state'].classList.add('is-error'); + dom['loading-state'].textContent = '학습 콘텐츠를 불러오지 못했습니다. 페이지를 새로고침하거나 연결 상태를 확인해 주세요.'; + const retry = actionButton('다시 불러오기', 'button button-secondary', () => window.location.reload()); + dom['loading-state'].append(el('br'), retry); + console.error('Java Edu initialization failed:', error); + } +} + +void boot(); diff --git a/dist/data/curriculum.js b/dist/data/curriculum.js new file mode 100644 index 0000000..fcaeaad --- /dev/null +++ b/dist/data/curriculum.js @@ -0,0 +1,1173 @@ +// Original Korean learning content. Each example is a standalone Main.java. +export const lessons = [ + { + "id": "java-basics", + "title": "Java의 값 전달과 객체 동등성", + "description": "다른 언어의 직관을 Java의 타입·참조·boxing 계약과 대조합니다.", + "track": "transition", + "level": "전환", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 35, + "category": "Java 사고 모델", + "goals": [ + "변수의 값과 그 값이 가리키는 객체를 구분한다.", + "참조 재할당과 객체 변경의 결과를 설명한다.", + "내용 동등성과 null·boxing의 경계를 다룬다." + ], + "theory": [ + { + "heading": "객체도 값으로 전달된다", + "text": "Java의 메서드 호출은 값 전달입니다. 기본형은 그 값, 참조형은 참조값을 복사합니다. 두 참조가 같은 객체를 가리키면 한쪽의 객체 변경을 다른 쪽에서도 볼 수 있지만, 매개변수에 새 객체를 대입해도 호출자의 변수는 재할당되지 않습니다. 메모리 주소를 직접 다룬다는 설명 대신 변수·참조·객체를 나눠 호출 전후 상태를 그려 보세요." + }, + { + "heading": "같은 객체와 같은 내용", + "text": "참조형의 ==는 참조 동일성을 검사합니다. String.equals는 문자열 내용을 비교하고, Objects.equals는 null을 포함한 동등성 비교를 제공합니다. 사용자 정의 타입은 업무상 같은 값의 의미를 먼저 정해야 합니다. equals를 재정의한다면 hashCode 계약도 함께 지켜야 하며, 데이터베이스 식별자와 객체의 모든 필드가 항상 같은 동등성 기준인 것은 아닙니다." + }, + { + "heading": "boxing은 null과 타입 변환을 숨길 수 있다", + "text": "Integer와 int 사이에는 boxing·unboxing이 일어납니다. null인 Integer를 산술식에 사용하면 unboxing 중 NullPointerException이 발생할 수 있습니다. Integer 객체의 == 결과를 값 비교 규칙으로 사용하지 마세요. 정수 나눗셈, 좁은 타입으로의 변환, null 허용 여부도 공개 메서드 계약에 포함할 대상입니다." + } + ], + "example": { + "title": "참조 복사와 객체 변경 · Main.java", + "code": "class Main {\n static class Box {\n int value;\n Box(int value) { this.value = value; }\n }\n\n static void replaceNumber(int value) {\n value = 99;\n }\n\n static void update(Box box) {\n box.value += 10;\n box = new Box(99);\n }\n\n public static void main(String[] args) {\n int number = 5;\n Box box = new Box(5);\n replaceNumber(number);\n update(box);\n String left = new String(\"java\");\n String right = new String(\"java\");\n System.out.println(number);\n System.out.println(box.value);\n System.out.println(left == right);\n System.out.println(left.equals(right));\n }\n}", + "output": "5\n15\nfalse\ntrue", + "explanation": "number는 5를 유지합니다. update는 기존 Box의 value를 15로 바꾸지만, 매개변수 box의 재할당은 호출자의 box를 바꾸지 않습니다. 두 String 객체는 별개이므로 ==는 false, 내용 비교는 true입니다." + }, + "quiz": [ + { + "id": "java-basics-q1", + "question": "참조형 매개변수 x에 새 객체를 대입했을 때 호출자의 변수에는 어떤 일이 일어날까요?", + "options": [ + "호출자의 변수도 새 객체를 가리킨다.", + "호출자의 변수는 기존 참조값을 유지한다.", + "기존 객체의 모든 필드가 초기화된다.", + "항상 컴파일 오류가 발생한다." + ], + "correctIndex": 1, + "explanation": "참조값도 복사되어 전달됩니다. 매개변수 재할당은 호출자의 변수 재할당과 다릅니다. 같은 객체의 필드를 수정한 경우에는 그 객체를 함께 보는 참조에서 변경이 관찰됩니다." + }, + { + "id": "java-basics-q2", + "question": "Integer count = null; int next = count + 1; 의 결과로 맞는 것은?", + "options": [ + "next는 1이다.", + "next는 null이다.", + "unboxing 과정에서 NullPointerException이 발생한다.", + "모든 Java 버전에서 컴파일이 금지된다." + ], + "correctIndex": 2, + "explanation": "산술 연산에는 int 값이 필요하여 count를 unboxing합니다. null에서 int 값을 얻을 수 없어 실행 시 예외가 발생합니다. null 정책을 먼저 정하고 검사해야 합니다." + } + ], + "practice": { + "title": "외부 입력의 동등성 계약 정하기", + "scenario": "외부 시스템에서 읽은 상품 코드가 같은 문자열이지만 서로 다른 객체로 생성됩니다. 일부 입력에는 null이 있습니다.", + "tasks": [ + "같은 코드의 기준을 대소문자·공백·null 정책으로 명시하세요.", + "참조 비교로 생기는 결함을 재현하고 내용 비교로 수정하세요.", + "정규화가 원본 식별자의 의미를 바꿀 수 있는 사례를 하나 설명하세요." + ], + "takeaway": "데이터를 비교하기 전에 같음의 업무 의미를 정합니다. null 처리와 정규화는 숨은 구현 편의가 아니라 호출 계약입니다." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Passing Information to a Method", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/arguments.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Objects", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/Objects.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Integer", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/Integer.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "java-basics-lab" + }, + { + "id": "oop", + "title": "OOP: 책임과 불변식을 객체에 두기", + "description": "필드를 숨기는 수준을 넘어 잘못된 상태가 만들어지는 경로를 줄입니다.", + "track": "both", + "level": "심화", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 50, + "category": "객체 설계", + "goals": [ + "객체가 유지해야 할 불변식을 문장으로 정의한다.", + "생성자·명령 메서드·조회 메서드의 책임을 구분한다.", + "가변 상태를 외부에 노출하는 비용을 설명한다." + ], + "theory": [ + { + "heading": "캡슐화는 private 필드보다 넓다", + "text": "캡슐화의 핵심은 객체의 상태 변경 규칙을 한곳에서 지키는 것입니다. private 필드를 두고 모든 setter를 공개해도 호출자가 잘못된 상태를 만들 수 있다면 규칙은 보호되지 않습니다. 생성자는 유효한 초기 상태를 만들고, 명령 메서드는 성공·실패 후 유지해야 할 조건을 명시해야 합니다." + }, + { + "heading": "불변식과 실패의 원자성", + "text": "예를 들어 적립 잔액이 0 이상이어야 한다면 차감 전 검증과 차감 후 조건이 필요합니다. 오류를 발견하기 전에 일부 필드를 바꾸면 호출자는 실패 후 상태를 예측하기 어렵습니다. 여러 상태를 한 번에 바꾸는 메서드는 검증 순서와 실패 시 보존할 상태를 설계하세요. 이 수업의 단일 객체 규칙은 멀티스레드·분산 트랜잭션 보장을 뜻하지 않습니다." + }, + { + "heading": "불변 객체와 협력 경계", + "text": "final 참조는 재할당을 막지만 참조 대상 객체의 내부 변경까지 막지는 않습니다. 컬렉션을 반환할 때는 읽기 전용 뷰, 복사본, 요소의 가변성까지 구분해야 합니다. 모든 객체를 불변으로 만드는 것보다 누가 변경을 소유하는지 설명할 수 있는 설계를 목표로 합니다. 서비스·저장소·도메인 객체의 책임은 실제 변경 요구로 검토합니다." + } + ], + "example": { + "title": "잔액 규칙을 지키는 객체 · Main.java", + "code": "class Main {\n static final class Credit {\n private int balance;\n\n Credit(int initialBalance) {\n if (initialBalance < 0) {\n throw new IllegalArgumentException(\"negative balance\");\n }\n balance = initialBalance;\n }\n\n boolean spend(int amount) {\n if (amount <= 0 || amount > balance) {\n return false;\n }\n balance -= amount;\n return true;\n }\n\n int balance() { return balance; }\n }\n\n public static void main(String[] args) {\n Credit credit = new Credit(100);\n System.out.println(credit.spend(40));\n System.out.println(credit.spend(70));\n System.out.println(credit.balance());\n }\n}", + "output": "true\nfalse\n60", + "explanation": "첫 차감 후 잔액은 60입니다. 두 번째 차감은 잔액을 초과하므로 실패하고 상태를 바꾸지 않습니다. 호출자가 balance를 임의로 설정하는 setter는 제공하지 않습니다." + }, + "quiz": [ + { + "id": "oop-q1", + "question": "재고 객체의 필드는 private이지만 setStock(int)가 모든 값을 허용합니다. 가장 정확한 평가는?", + "options": [ + "private이므로 불변식이 자동 보장된다.", + "생성자만 검증하면 이후 상태는 안전하다.", + "음수를 넣을 수 있어 공개 변경 경로에서 규칙이 깨질 수 있다.", + "setter 이름을 바꾸면 문제가 해결된다." + ], + "correctIndex": 2, + "explanation": "접근 제한자는 도구입니다. 공개된 변경 메서드가 업무 규칙을 무시하면 잘못된 상태를 만들 수 있습니다. 규칙을 가진 명령 메서드와 유효성 검증을 함께 설계해야 합니다." + }, + { + "id": "oop-q2", + "question": "final List names에 대한 설명으로 맞는 것은?", + "options": [ + "names의 참조 재할당은 금지되지만 리스트 구현이 허용하면 요소 추가는 가능하다.", + "리스트와 모든 요소가 깊게 불변이 된다.", + "어떤 경우에도 names.add를 호출할 수 없다.", + "다른 스레드와 공유해도 모든 복합 연산이 원자적이다." + ], + "correctIndex": 0, + "explanation": "final은 해당 필드의 참조 재할당을 제한합니다. 리스트의 변경 가능성, 요소의 가변성, 동시성 보장은 별도 계약입니다." + } + ], + "practice": { + "title": "예약 도메인의 상태 변경 경로 줄이기", + "scenario": "재고 수량을 여러 서비스가 직접 수정하면서 음수 재고와 실패 후 부분 변경이 발생했습니다.", + "tasks": [ + "생성·예약·취소 각각의 사전조건과 사후조건을 적으세요.", + "호출자가 규칙을 우회하는 setter와 가변 컬렉션 노출을 찾으세요.", + "실패한 예약 뒤 상태가 유지되는 테스트를 먼저 제시하세요." + ], + "takeaway": "객체를 나눴다는 사실보다 어떤 규칙을 누가 지키는지와 실패 후 상태가 명확한지가 중요합니다." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Classes", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/classes.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JLS 8 — Types, Values, and Variables", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-4.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JLS 8 — Classes", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "Spring Boot" + ], + "exerciseId": "oop-lab" + }, + { + "id": "polymorphism", + "title": "OOP: 다형성, 계약과 합성", + "description": "바뀌는 정책을 분리하면서 기존 호출자의 기대를 유지합니다.", + "track": "both", + "level": "심화", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 55, + "category": "객체 설계", + "goals": [ + "다형성을 타입 분기 제거와 계약 유지의 관점에서 설명한다.", + "상속과 합성의 비용을 비교한다.", + "의존 관계와 테스트 대역의 연결을 이해한다." + ], + "theory": [ + { + "heading": "교체 가능성에는 행동 계약이 필요하다", + "text": "인터페이스를 구현했다는 사실만으로 안전하게 교체할 수 있는 것은 아닙니다. 허용 입력, 반환값, 실패 조건과 부수 효과를 호출자가 기대할 수 있어야 합니다. LSP를 검토할 때는 새 구현이 기존 호출자에게 더 강한 사전조건을 요구하거나 약속한 결과를 약화하는지 살펴보세요. 이는 언어 문법 이상의 설계 판단입니다." + }, + { + "heading": "상속과 합성은 변경 이유로 선택한다", + "text": "상속은 상위 타입의 구현·확장 규칙과 결합됩니다. 정책 교체가 목적이라면 별도 객체를 주입해 협력시키는 합성이 변경 지점을 더 명확하게 만들 수 있습니다. 반대로 모든 메서드에 인터페이스를 추가하면 작은 변경에도 파일과 탐색 비용이 늘어납니다. SOLID는 점수표가 아니라 결합과 변경 비용을 검토하는 관점으로 사용합니다." + }, + { + "heading": "수동 의존성 주입에서 실행 원리 이해하기", + "text": "생성자나 메서드 인자로 협력 객체를 전달하면 선택과 사용을 분리할 수 있습니다. 테스트에서는 같은 계약을 지키는 대역으로 외부 환경을 통제합니다. 이 수업은 순수 Java의 수동 조립입니다. Spring 컨테이너·프록시·AOP를 실행하는 예제가 아니며, 프레임워크 과정에서는 이 구조를 기반으로 객체 생성과 호출 경로가 어떻게 바뀌는지 다룹니다." + } + ], + "example": { + "title": "출력 정책을 조합하는 객체 · Main.java", + "code": "class Main {\n interface Formatter {\n String format(String value);\n }\n\n static final class Report {\n private final Formatter formatter;\n\n Report(Formatter formatter) {\n this.formatter = java.util.Objects.requireNonNull(formatter);\n }\n\n String render(String value) {\n return formatter.format(value);\n }\n }\n\n public static void main(String[] args) {\n Report bracketed = new Report(value -> \"[\" + value + \"]\");\n Report prefixed = new Report(value -> \"report:\" + value);\n System.out.println(bracketed.render(\"stock\"));\n System.out.println(prefixed.render(\"stock\"));\n }\n}", + "output": "[stock]\nreport:stock", + "explanation": "Report는 포맷터의 구체 타입을 확인하지 않고 계약으로 협력합니다. 생성자의 수동 주입으로 같은 Report를 서로 다른 정책과 조합합니다. 람다는 Java 8에서 사용할 수 있습니다." + }, + "quiz": [ + { + "id": "polymorphism-q1", + "question": "정상 소계 0 이상을 받던 정책의 새 구현이 10000 이상만 허용합니다. 우선 검토할 문제는?", + "options": [ + "클래스 이름의 길이", + "기존 호출자가 허용받던 입력을 거절하는 계약 위반", + "람다를 사용하지 않았다는 점", + "인터페이스 파일의 개수" + ], + "correctIndex": 1, + "explanation": "교체 전후 호출자가 기대하는 유효 입력이 달라졌습니다. 더 강한 사전조건을 요구하면 타입이 같아도 기존 사용을 깨뜨릴 수 있습니다." + }, + { + "id": "polymorphism-q2", + "question": "정책 합성에 대한 가장 적절한 설명은?", + "options": [ + "모든 상속은 잘못이므로 반드시 제거한다.", + "인터페이스가 많을수록 언제나 좋은 설계다.", + "어떤 작은 메서드도 별도 전략 클래스로 분리해야 한다.", + "실제 교체·테스트 요구와 추가 추상화 비용을 비교해 선택한다." + ], + "correctIndex": 3, + "explanation": "합성은 유용한 도구지만 무조건적인 규칙은 아닙니다. 변경 이유, 계약, 테스트 경계, 읽기 비용을 근거로 판단합니다." + } + ], + "practice": { + "title": "할인 정책 변경 요청 대응", + "scenario": "정액 할인만 있던 결제 모듈에 비율 할인과 기간 한정 정책이 추가됩니다.", + "tasks": [ + "정책이 반환할 할인액의 허용 범위와 실패 조건을 정하세요.", + "정책 종류를 검사하는 조건문 없이 교체 가능한 협력을 만드세요.", + "새 정책의 계약 위반을 잡는 테스트와 추상화 비용을 설명하세요." + ], + "takeaway": "다형성은 구현 선택을 숨기는 데 그치지 않고, 교체 후에도 호출자가 믿는 계약을 유지해야 합니다." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Interfaces", + "url": "https://docs.oracle.com/javase/tutorial/java/IandI/createinterface.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JDK 8 Tutorial — Polymorphism", + "url": "https://docs.oracle.com/javase/tutorial/java/IandI/polymorphism.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JDK 8 Tutorial — Lambda Expressions", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/lambdaexpressions.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "Spring Boot" + ], + "exerciseId": "polymorphism-lab" + }, + { + "id": "collections", + "title": "컬렉션: 순서, 동등성과 변경 계약", + "description": "List·Set·Map을 자료구조 이름이 아니라 호출 계약으로 선택합니다.", + "track": "both", + "level": "기본", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 40, + "category": "타입과 데이터", + "goals": [ + "순서·중복·키 조회 요구로 컬렉션을 선택한다.", + "equals와 hashCode가 조회에 미치는 영향을 설명한다.", + "뷰와 복사본, 요소의 가변성을 구분한다." + ], + "theory": [ + { + "heading": "인터페이스와 구현이 약속하는 것", + "text": "List는 위치와 중복을, Set은 중복 없는 요소를, Map은 키와 값의 대응을 다룹니다. Map은 Collection의 하위 인터페이스가 아닙니다. HashMap의 순회 순서를 업무 요구로 가정하지 마세요. 최초 등장 순서가 필요한 중복 제거처럼 순서가 계약이라면 LinkedHashSet 등의 구현을 근거로 선택합니다." + }, + { + "heading": "동등성과 해시의 계약", + "text": "equals로 같은 두 객체는 같은 hashCode를 반환해야 합니다. 역은 성립하지 않아 해시 충돌이 있을 수 있습니다. 해시 기반 컬렉션에 넣은 뒤 동등성·해시에 사용한 필드를 바꾸면 원래 기대한 조회가 깨질 수 있습니다. 값 객체나 안정적인 식별자를 키로 쓰는 이유를 이 계약과 연결해 설명하세요." + }, + { + "heading": "변경 불가 뷰는 스냅샷이 아니다", + "text": "Collections.unmodifiableList는 지정 리스트에 대한 변경 불가 뷰입니다. 원본 리스트가 바뀌면 뷰에서 변경을 볼 수 있습니다. new ArrayList로 컨테이너를 복사한 뒤 감싸면 구조 변경을 분리할 수 있지만 요소 자체를 깊게 복사하지는 않습니다. Arrays.asList는 크기가 고정된 리스트이므로 일반 ArrayList와 같은 추가·삭제 계약으로 사용하면 안 됩니다." + } + ], + "example": { + "title": "집계와 컨테이너 복사 · Main.java", + "code": "class Main {\n public static void main(String[] args) {\n java.util.List tags =\n java.util.Arrays.asList(\"java\", \"sql\", \"java\");\n java.util.Map counts =\n new java.util.LinkedHashMap();\n for (String tag : tags) {\n counts.put(tag, counts.getOrDefault(tag, 0) + 1);\n }\n java.util.List snapshot =\n java.util.Collections.unmodifiableList(\n new java.util.ArrayList(tags));\n tags.set(0, \"other\");\n System.out.println(counts.get(\"java\"));\n System.out.println(snapshot.get(0));\n System.out.println(tags.get(0));\n }\n}", + "output": "2\njava\nother", + "explanation": "집계용 Map은 java를 두 번 센 결과를 가집니다. snapshot의 컨테이너를 먼저 복사했으므로 tags.set의 영향을 받지 않습니다. 이 예제의 String은 불변이지만, 가변 요소를 쓸 때는 요소의 공유도 별도로 판단해야 합니다." + }, + "quiz": [ + { + "id": "collections-q1", + "question": "equals가 true인 두 객체의 hashCode에 대한 필수 조건은?", + "options": [ + "반드시 달라야 한다.", + "반드시 같아야 한다.", + "매 호출마다 바뀌어야 한다.", + "HashMap에서는 hashCode가 사용되지 않는다." + ], + "correctIndex": 1, + "explanation": "같다고 판단한 객체는 같은 해시값을 가져야 합니다. 해시값이 같은 두 객체가 반드시 equals까지 true인 것은 아닙니다." + }, + { + "id": "collections-q2", + "question": "원본 ArrayList를 그대로 unmodifiableList로 감싼 뒤 원본에 항목을 추가하면?", + "options": [ + "뷰는 독립 복사본이므로 바뀌지 않는다.", + "뷰를 만든 순간 원본도 수정할 수 없다.", + "원본의 변경이 뷰에서 보일 수 있다.", + "모든 요소가 깊게 복사된다." + ], + "correctIndex": 2, + "explanation": "unmodifiableList는 지정 리스트를 읽는 뷰입니다. 뷰 경유 변경은 제한하지만 원본이 다른 경로에서 바뀌는 것까지 차단하지 않습니다." + } + ], + "practice": { + "title": "중복 이벤트 식별자의 보관 방식 선택", + "scenario": "배치에서 읽은 이벤트 코드의 중복을 제거하되 최초 순서를 유지해야 합니다.", + "tasks": [ + "중복 판정 기준과 null 허용 여부를 정하세요.", + "요구 순서를 보장하는 컬렉션을 선택하세요.", + "반환 결과 변경이 원본 데이터에 영향을 주지 않는 테스트를 작성하세요." + ], + "takeaway": "자료구조 선택은 성능 이름표만으로 결정하지 않습니다. 동등성·순서·변경 가능성도 API 계약입니다." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Collections", + "url": "https://docs.oracle.com/javase/tutorial/collections/index.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Object.equals/hashCode", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Collections", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/Collections.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "collections-lab" + }, + { + "id": "generics", + "title": "제네릭: 불공변성, 경계와 타입 소거", + "description": "캐스팅으로 경고를 숨기지 않고 읽기·쓰기 역할에 맞는 API를 설계합니다.", + "track": "both", + "level": "심화", + "minVersion": 8, + "featureSince": 5, + "preview": false, + "duration": 45, + "category": "타입과 데이터", + "goals": [ + "List가 List의 하위 타입이 아닌 이유를 설명한다.", + "extends와 super를 읽기·쓰기 역할로 선택한다.", + "타입 소거와 raw type의 한계를 이해한다." + ], + "theory": [ + { + "heading": "하위 타입 관계가 그대로 전달되지 않는다", + "text": "Integer가 Number의 하위 타입이어도 List는 List의 하위 타입이 아닙니다. 만약 대입을 허용하면 Number를 받는 쪽에서 Double을 추가해 Integer 전용 리스트의 약속을 깨뜨릴 수 있습니다. 배열의 공변성과 런타임 검사에 익숙하다면 제네릭의 컴파일 시점 제약과 비교해 보세요." + }, + { + "heading": "생산자는 extends, 소비자는 super", + "text": "List에서는 Number로 읽을 수 있지만 특정 Number 값을 안전하게 추가할 수는 없습니다. 실제 리스트의 요소 타입을 모르기 때문입니다. List에는 Integer를 추가할 수 있지만 읽은 값의 일반적인 정적 타입은 Object입니다. 양쪽 역할이 필요한 API는 wildcard를 기계적으로 붙이지 말고 타입 매개변수 관계를 먼저 정하세요." + }, + { + "heading": "타입 소거가 만드는 제한", + "text": "제네릭의 많은 타입 정보는 컴파일 과정에서 소거됩니다. new T(), instanceof List처럼 런타임의 구체 타입을 요구하는 사용에는 제한이 있습니다. raw type과 unchecked cast를 섞으면 컴파일러가 보장하던 안전성을 약화할 수 있습니다. 경고를 없애기 전에 API 경계를 수정할 수 있는지 검토하세요. 제네릭 자체의 도입은 Java 5이며 본 수업 예제 대상은 Java 8입니다." + } + ], + "example": { + "title": "읽기 역할에 맞춘 상한 경계 · Main.java", + "code": "class Main {\n static double sum(java.util.List values) {\n double total = 0;\n for (Number value : values) {\n total += value.doubleValue();\n }\n return total;\n }\n\n public static void main(String[] args) {\n System.out.println(sum(java.util.Arrays.asList(1, 2, 3)));\n System.out.println(sum(java.util.Arrays.asList(0.5, 1.5)));\n }\n}", + "output": "6.0\n2.0", + "explanation": "sum은 항목을 읽어 Number의 메서드만 사용합니다. Integer 리스트와 Double 리스트를 받되, 어떤 숫자 타입을 리스트에 새로 추가할 권한은 요구하지 않습니다." + }, + "quiz": [ + { + "id": "generics-q1", + "question": "List를 List에 바로 대입할 수 없는 이유는?", + "options": [ + "Number에 하위 타입이 없기 때문이다.", + "Integer가 참조형이 아니기 때문이다.", + "허용하면 Number 경로로 Double을 넣어 Integer 리스트의 계약을 깨뜨릴 수 있기 때문이다.", + "Java 8에는 제네릭이 없기 때문이다." + ], + "correctIndex": 2, + "explanation": "제네릭은 기본적으로 불공변입니다. 요소 타입의 하위 관계가 파라미터화된 타입의 같은 하위 관계를 자동으로 만들지 않습니다." + }, + { + "id": "generics-q2", + "question": "List target에 안전하게 할 수 있는 작업은?", + "options": [ + "Integer 값 추가", + "읽은 모든 값을 캐스팅 없이 Integer에 대입", + "어떤 Double 값이든 추가", + "target을 List으로 간주" + ], + "correctIndex": 0, + "explanation": "실제 요소 타입은 Integer 또는 그 상위 타입입니다. Integer는 추가할 수 있지만 읽은 값은 더 넓은 타입의 값일 수 있으므로 일반적으로 Object로 취급해야 합니다." + } + ], + "practice": { + "title": "공통 복사 API의 타입 계약", + "scenario": "Integer 목록을 Number 저장소로, String 목록을 Object 저장소로 복사하는 코드가 반복됩니다.", + "tasks": [ + "source와 target의 읽기·쓰기 역할을 표시하세요.", + "raw type 없이 공통 copy 메서드의 타입 매개변수를 정하세요.", + "target의 기존 항목과 source가 보존되는지 검사하세요." + ], + "takeaway": "유연한 API와 타입 안전성은 양립할 수 있습니다. 어느 방향으로 값을 읽고 쓰는지부터 결정하세요." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Upper Bounded Wildcards", + "url": "https://docs.oracle.com/javase/tutorial/java/generics/upperBounded.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JDK 8 Tutorial — Lower Bounded Wildcards", + "url": "https://docs.oracle.com/javase/tutorial/java/generics/lowerBounded.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JLS 8 — Type Erasure", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-4.html#jls-4.6", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "generics-lab" + }, + { + "id": "exceptions", + "title": "예외: 실패 책임과 자원 소유권", + "description": "예외 종류를 외우는 데서 나아가 처리·전파·진단의 경계를 설계합니다.", + "track": "both", + "level": "기본", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 40, + "category": "실패와 검증", + "goals": [ + "실패를 처리할 계층과 전파할 정보를 정한다.", + "예외 변환 시 원인과 맥락을 보존한다.", + "자원을 연 코드가 종료 책임을 지도록 설계한다." + ], + "theory": [ + { + "heading": "checked 여부와 복구 가능성은 같은 말이 아니다", + "text": "checked 예외는 처리하거나 throws로 선언해야 하는 컴파일 규칙을 가집니다. 이것만으로 현 계층이 복구할 수 있는지는 결정되지 않습니다. 호출자가 다시 입력할지, 재시도할지, 실패로 종료할지 판단하는 데 필요한 정보를 보존하세요. catch(Exception)으로 모든 실패를 정상값으로 바꾸면 호출자가 결과를 오해할 수 있습니다." + }, + { + "heading": "예외를 바꾸더라도 원인을 잃지 않는다", + "text": "낮은 계층의 예외를 업무 의미의 예외로 변환할 수 있지만 cause를 유지하면 원인 추적이 가능합니다. 민감한 원문 전체를 메시지에 넣는 대신 실패한 작업과 안전한 맥락을 전달합니다. 잘못된 입력과 시스템 장애를 같은 임의 기본값으로 숨기지 마세요. 로그 기록과 사용자 응답도 서로 다른 책임입니다." + }, + { + "heading": "자원 해제와 suppressed exception", + "text": "AutoCloseable 자원은 try-with-resources로 수명을 표현할 수 있습니다. 여러 자원은 선언 역순으로 닫힙니다. 본문과 close가 모두 실패하면 본문 예외가 주 예외가 되고 닫기 실패는 suppressed 예외로 보존될 수 있습니다. 메서드에 전달받은 자원을 누가 닫는지는 소유권 계약으로 명시하세요." + } + ], + "example": { + "title": "읽기 자원의 수명 명시 · Main.java", + "code": "class Main {\n static int readQuantity(String text) throws java.io.IOException {\n try (java.io.BufferedReader reader =\n new java.io.BufferedReader(new java.io.StringReader(text))) {\n String line = reader.readLine();\n if (line == null) {\n throw new IllegalArgumentException(\"quantity is required\");\n }\n try {\n return Integer.parseInt(line);\n } catch (NumberFormatException cause) {\n throw new IllegalArgumentException(\"invalid quantity\", cause);\n }\n }\n }\n\n public static void main(String[] args) throws java.io.IOException {\n System.out.println(readQuantity(\"12\"));\n try {\n readQuantity(\"many\");\n } catch (IllegalArgumentException failure) {\n System.out.println(failure.getCause().getClass().getSimpleName());\n }\n }\n}", + "output": "12\nNumberFormatException", + "explanation": "메서드 내부에서 만든 reader를 try-with-resources가 닫습니다. 숫자 파싱 실패는 입력 계약의 예외로 변환하면서 원본 NumberFormatException을 cause에 보존합니다." + }, + "quiz": [ + { + "id": "exceptions-q1", + "question": "예외를 새 IllegalArgumentException으로 변환할 때 원인 보존에 적합한 방법은?", + "options": [ + "항상 return 0으로 끝낸다.", + "catch 블록을 비워 둔다.", + "원본 예외의 메시지를 지운다.", + "새 예외 생성자의 cause 인자로 원본 예외를 전달한다." + ], + "correctIndex": 3, + "explanation": "원인 예외를 연결하면 낮은 계층에서 어떤 실패가 발생했는지 확인할 수 있습니다. 정상값을 반환하거나 예외를 삼키면 호출자가 실패와 성공을 구분하기 어렵습니다." + }, + { + "id": "exceptions-q2", + "question": "try-with-resources 본문과 close가 모두 예외를 던졌다면 일반적인 처리 규칙은?", + "options": [ + "close의 예외만 남고 본문 예외는 사라진다.", + "본문 예외가 주 예외가 되고 close 실패가 suppressed로 보존될 수 있다.", + "두 예외 모두 자동으로 무시된다.", + "항상 프로그램이 컴파일되지 않는다." + ], + "correctIndex": 1, + "explanation": "try-with-resources는 주 예외와 자원 종료 중 발생한 예외를 함께 진단할 수 있도록 suppressed 예외를 사용합니다. 자원 종료가 실패할 수 있다는 사실도 설계해야 합니다." + } + ], + "practice": { + "title": "환경 설정 입력의 실패 계약", + "scenario": "설정 파일에서 읽은 포트 값이 잘못되어도 0을 반환해, 실제 원인을 늦게 발견하고 있습니다.", + "tasks": [ + "입력 부재·형식 오류·범위 오류를 분류하세요.", + "호출자가 실패를 확실하게 알도록 계약을 바꾸세요.", + "파싱 실패의 cause가 남는지 검사하세요." + ], + "takeaway": "복구하지 못한 실패를 성공처럼 포장하지 않습니다. 예외·자원·진단 정보의 책임을 함께 정합니다." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Exceptions", + "url": "https://docs.oracle.com/javase/tutorial/essential/exceptions/index.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JDK 8 Tutorial — try-with-resources", + "url": "https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Throwable", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/Throwable.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "exceptions-lab" + }, + { + "id": "streams", + "title": "Stream: 지연 실행과 부수 효과 없는 집계", + "description": "짧은 코드보다 실행 시점·순서·공유 상태의 계약을 먼저 봅니다.", + "track": "both", + "level": "기본", + "minVersion": 8, + "featureSince": 8, + "preview": false, + "duration": 40, + "category": "데이터 처리", + "goals": [ + "중간 연산과 최종 연산을 구분한다.", + "소스·파이프라인·결과의 역할을 설명한다.", + "외부 가변 상태 없이 조건 집계를 구현한다." + ], + "theory": [ + { + "heading": "Stream은 데이터를 저장하는 컬렉션이 아니다", + "text": "Stream은 소스에서 결과를 만드는 연산 흐름입니다. filter와 map 같은 중간 연산은 지연되며 최종 연산이 파이프라인 평가를 시작합니다. 이미 소비한 Stream을 다시 순회하려고 하지 말고 소스에서 새로 만드세요. 필터·변환·집계 각각이 무엇을 약속하는지 설명할 수 있어야 합니다." + }, + { + "heading": "외부 상태 변경은 집계와 다른 책임이다", + "text": "forEach에서 외부 ArrayList나 일반 카운터를 변경하면 상태 소유권이 흩어지고 병렬 처리에서 문제가 생길 수 있습니다. 결과 수집은 collect, 숫자 집계는 원시형 스트림의 합계처럼 의도가 드러나는 연산을 검토하세요. 콜백은 가능한 한 소스를 바꾸지 않고 입력에 대한 결과를 계산하도록 구성합니다." + }, + { + "heading": "병렬화와 API 버전은 별도 판단이다", + "text": "parallelStream은 자동 성능 개선 스위치가 아닙니다. 작업량, 순서 요구, 공유 상태, 결합 가능한 연산과 실행 환경을 측정해야 합니다. Java 8에서는 collect(Collectors.toList())를 사용합니다. Stream.toList()는 Java 16에서 추가되었고 반환 리스트의 변경 계약도 다르므로 단순 문법 치환으로 취급하지 않습니다." + } + ], + "example": { + "title": "필터·변환·수집 파이프라인 · Main.java", + "code": "class Main {\n public static void main(String[] args) {\n java.util.List names =\n java.util.Arrays.asList(\"java\", \"sql\", \"jvm\", \"java\");\n java.util.List selected = names.stream()\n .filter(name -> name.startsWith(\"j\"))\n .distinct()\n .map(name -> name.toUpperCase(java.util.Locale.ROOT))\n .collect(java.util.stream.Collectors.toList());\n System.out.println(selected);\n System.out.println(names.size());\n }\n}", + "output": "[JAVA, JVM]\n4", + "explanation": "소스의 순서를 따라 j로 시작하는 항목을 고르고 중복 제거 후 대문자로 변환합니다. collect가 최종 연산입니다. 소스 names는 수정하지 않았으므로 크기는 4입니다." + }, + "quiz": [ + { + "id": "streams-q1", + "question": "filter만 연결한 Stream에서 실제 결과를 얻기 위해 필요한 것은?", + "options": [ + "원본 리스트를 반드시 정렬한다.", + "최종 연산으로 파이프라인을 평가한다.", + "항상 parallel로 전환한다.", + "System.gc를 호출한다." + ], + "correctIndex": 1, + "explanation": "Stream의 중간 연산은 지연됩니다. collect, sum 같은 최종 연산이 결과 계산을 시작합니다. 필요한 모든 요소가 항상 처리된다는 뜻은 아니며 단락 연산도 있습니다." + }, + { + "id": "streams-q2", + "question": "Java 8 대상으로 작성한 예제에 Stream.toList()를 사용하면?", + "options": [ + "Java 8 표준 API이므로 문제없다.", + "모든 반환 리스트가 ArrayList로 보장된다.", + "Java 16에서 추가된 API이므로 Java 8 대상 코드와 맞지 않는다.", + "동일 Stream을 무제한 재사용할 수 있다." + ], + "correctIndex": 2, + "explanation": "Stream.toList는 Java 16 API입니다. Java 8 대상에서는 지원 API를 사용하고, 반환 컬렉션의 변경 가능성도 별도로 확인해야 합니다." + } + ], + "practice": { + "title": "집계 배치의 공유 누산기 없애기", + "scenario": "여러 조건을 거친 정수 값을 외부 변수에 더하는 배치 코드를 읽기 쉽고 검증 가능하게 바꾸려 합니다.", + "tasks": [ + "필터 조건과 합계의 입력 범위를 분리해 적으세요.", + "외부 상태 없이 조건에 맞는 값을 집계하세요.", + "빈 입력·null 요소·중복 값을 처리하는 계약을 확인하세요." + ], + "takeaway": "데이터 흐름을 명확하게 만든 뒤 성능을 측정합니다. 짧은 표현이나 병렬화 자체가 설계의 목적은 아닙니다." + }, + "sources": [ + { + "title": "Java SE 8 API — Stream", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Collectors", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/stream/Collectors.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 17 API — Stream.toList (Since 16)", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/stream/Stream.html#toList()", + "edition": "Java SE 17; API Since 16", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "streams-lab" + }, + { + "id": "concurrency", + "title": "동시성: JMM, 원자성과 작업 수명", + "description": "가시성·원자성·완료 대기를 구분하고 스레드 안전성의 근거를 제시합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 8, + "featureSince": 5, + "preview": false, + "duration": 65, + "category": "동시성과 실행 모델", + "goals": [ + "원자성·가시성·happens-before를 구분한다.", + "공유 상태 변경과 작업 완료의 보장을 설명한다.", + "실패·취소·실행기 종료의 책임을 정한다." + ], + "theory": [ + { + "heading": "보이는 것과 한 번에 처리되는 것은 다르다", + "text": "JMM은 여러 스레드 사이에서 어떤 읽기 결과가 허용되는지 설명합니다. volatile은 특정 가시성·순서 보장에 쓰지만 count++처럼 읽기·계산·쓰기의 복합 동작을 원자적으로 만들지는 않습니다. 공유 상태가 필요 없는 설계, 불변 데이터, 락 또는 원자 연산 중 요구에 맞는 방법을 선택하세요." + }, + { + "heading": "happens-before와 안전한 공개", + "text": "프로그램 순서만으로 다른 스레드가 언제 값을 볼지 결론내리지 않습니다. 모니터 잠금, volatile, 스레드 시작·종료, 동시성 API의 메모리 일관성 계약을 확인하세요. ExecutorService의 작업 제출과 Future.get도 명시된 관계를 제공합니다. 잠시 sleep한 뒤 값이 맞았다는 관찰은 동기화의 근거가 아닙니다." + }, + { + "heading": "작업과 자원의 생명주기를 설계한다", + "text": "ExecutorService는 작업 제출과 스레드 관리를 분리합니다. 결과를 기다릴지, 실패를 어떻게 알릴지, 언제 종료할지 정해야 합니다. InterruptedException을 처리하고 상위로 인터럽트 의사를 전달할 때는 상태 복원도 검토합니다. AtomicInteger 하나가 안전해도 여러 필드나 DB 행을 함께 변경하는 업무 규칙까지 자동으로 원자적이 되지는 않습니다." + } + ], + "example": { + "title": "완료 후 읽는 원자 카운터 · Main.java", + "code": "class Main {\n public static void main(String[] args) throws Exception {\n java.util.concurrent.ExecutorService executor =\n java.util.concurrent.Executors.newFixedThreadPool(2);\n java.util.concurrent.atomic.AtomicInteger total =\n new java.util.concurrent.atomic.AtomicInteger();\n try {\n java.util.concurrent.Future first = executor.submit(() -> {\n for (int index = 0; index < 1000; index++) {\n total.incrementAndGet();\n }\n });\n java.util.concurrent.Future second = executor.submit(() -> {\n for (int index = 0; index < 1000; index++) {\n total.incrementAndGet();\n }\n });\n first.get();\n second.get();\n System.out.println(total.get());\n } finally {\n executor.shutdownNow();\n }\n }\n}", + "output": "2000", + "explanation": "증가에는 원자 연산을 사용하고 두 Future가 완료된 뒤 최종 값을 읽습니다. finally에서 실행기를 종료합니다. 이 예제는 작은 메모리 내 카운터이며 DB 트랜잭션이나 분산 환경의 원자성을 보여 주지는 않습니다." + }, + "quiz": [ + { + "id": "concurrency-q1", + "question": "volatile int count에 여러 스레드가 count++를 수행합니다. 맞는 설명은?", + "options": [ + "volatile이므로 모든 증가가 원자적이다.", + "volatile은 메서드 전체를 자동으로 잠근다.", + "가시성과 별개로 증가 연산의 원자성은 보장되지 않는다.", + "항상 컴파일 오류가 발생한다." + ], + "correctIndex": 2, + "explanation": "count++는 읽기·계산·쓰기를 포함합니다. volatile의 보장을 복합 연산 전체의 원자성으로 확대하면 안 됩니다. 요구에 맞는 락이나 원자 연산 등이 필요합니다." + }, + { + "id": "concurrency-q2", + "question": "작업 완료를 확인하지 않고 Thread.sleep을 사용한 테스트의 한계는?", + "options": [ + "sleep 자체가 모든 작업의 완료를 보장한다.", + "시간이 지났다는 관찰만으로 완료·동기화 계약을 증명할 수 없다.", + "sleep은 모든 변수를 final로 만든다.", + "sleep을 두 번 호출하면 어떤 실행 환경에서도 안전하다." + ], + "correctIndex": 1, + "explanation": "작업량과 스케줄링은 달라질 수 있습니다. Future.get, join 등 명시적 완료·동기화 계약을 사용해야 합니다. 반복 성공은 잘못된 동기화의 반례를 없애지 않습니다." + } + ], + "practice": { + "title": "병렬 작업의 완료·실패·종료 계약", + "scenario": "배치 카운트가 간헐적으로 작게 나오고 실행기 스레드가 작업 후에도 남습니다.", + "tasks": [ + "공유 카운터의 원자성 문제와 결과를 너무 일찍 읽는 문제를 분리하세요.", + "작업을 제출하고 모두 완료된 후 반환하며 실행기를 닫으세요.", + "인터럽트·작업 실패가 발생했을 때 호출자가 무엇을 관찰할지 설명하세요." + ], + "takeaway": "동시성 품질은 반복 테스트 횟수만으로 결정하지 않습니다. 어떤 계약으로 값과 작업의 순서를 보장했는지 설명해야 합니다." + }, + "sources": [ + { + "title": "JLS 8 — Threads and Locks / Memory Model", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — ExecutorService", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html", + "edition": "Java SE 8; API Since 5", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — AtomicInteger", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/atomic/AtomicInteger.html", + "edition": "Java SE 8; API Since 5", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "concurrency-lab" + }, + { + "id": "modern-apis", + "title": "Java 11: 입력 처리와 표준 HTTP API", + "description": "새 API의 Since와 호출 계약을 확인하며 Java 8 코드에서의 전환을 판단합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 11, + "featureSince": 11, + "preview": false, + "duration": 40, + "category": "버전 전환", + "goals": [ + "strip·isBlank·lines의 입력 처리 계약을 설명한다.", + "API 최초 도입 버전과 예제 실행 버전을 구분한다.", + "HTTP 클라이언트의 시간 제한과 실패 경계를 설계한다." + ], + "theory": [ + { + "heading": "문자열 API가 바꾸는 입력 정책", + "text": "Java 11의 strip, isBlank, lines는 여러 줄 입력 처리의 의도를 표현하는 데 유용합니다. strip과 isBlank는 Character.isWhitespace의 공백 기준을 사용하며, trim의 기준과 같지 않습니다. 모든 시각적 공백 문자를 전부 제거한다는 뜻도 아닙니다. 문자 인코딩과 줄 구분자까지 포함해 어떤 입력을 허용할지 정하세요." + }, + { + "heading": "var와 API 도입 버전을 분리한다", + "text": "지역 변수 타입 추론 var는 Java 10에 도입되었고 람다 매개변수의 var 문법은 Java 11에 추가되었습니다. var는 동적 타입이 아니며 컴파일 시점에 타입이 정해집니다. 이 수업의 String API와 표준 java.net.http.HttpClient는 Java 11이 필요합니다. 본문에 등장하는 기능의 최초 버전과 실제 예제 전체의 최소 대상 버전을 구분합니다." + }, + { + "heading": "HTTP는 성공 응답 한 번으로 끝나지 않는다", + "text": "표준 HttpClient는 동기 send와 비동기 sendAsync를 제공합니다. 연결 시간 제한과 요청 시간 제한은 적용 지점이 다르며, 통신 성공과 업무상 성공 상태 코드도 분리해야 합니다. 자동 재시도는 요청의 멱등성과 중복 처리 위험을 검토한 뒤 적용합니다. 아래 예제는 클라이언트 설정만 만들며 외부 네트워크 호출을 하지 않습니다." + } + ], + "example": { + "title": "Java 11 API와 명시적 설정 · Main.java", + "code": "class Main {\n public static void main(String[] args) {\n String text = \" build \\n\\n test \";\n long meaningfulLines = text.lines()\n .map(String::strip)\n .filter(line -> !line.isBlank())\n .count();\n java.net.http.HttpClient client =\n java.net.http.HttpClient.newBuilder()\n .connectTimeout(java.time.Duration.ofSeconds(3))\n .build();\n System.out.println(meaningfulLines);\n System.out.println(client.connectTimeout().get().getSeconds());\n }\n}", + "output": "2\n3", + "explanation": "빈 줄을 제외하면 의미 있는 줄은 2개입니다. HTTP 클라이언트에 설정한 연결 시간 제한은 3초입니다. 클라이언트를 만들었다는 사실은 어떤 서버 요청이 성공했다는 증거가 아닙니다." + }, + "quiz": [ + { + "id": "modern-apis-q1", + "question": "지역 변수 타입 추론 var의 최초 도입 버전과 성격은?", + "options": [ + "Java 8의 동적 타입 기능", + "Java 11에서 처음 도입된 런타임 타입 변경 기능", + "Java 10의 컴파일 시점 지역 변수 타입 추론", + "Java 17의 제네릭 제거 기능" + ], + "correctIndex": 2, + "explanation": "지역 변수 var는 Java 10입니다. Java 11은 람다 매개변수에서의 var 문법을 추가했습니다. var를 사용해도 타입은 컴파일 시점에 정해집니다." + }, + { + "id": "modern-apis-q2", + "question": "strip과 trim을 치환하기 전에 확인할 점은?", + "options": [ + "공백 판정 기준과 대상 Java 버전", + "두 API가 언제나 모든 문자에서 동일하다는 가정", + "strip이 원본 String을 제자리 변경한다는 점", + "strip을 사용하면 네트워크 오류가 사라진다는 점" + ], + "correctIndex": 0, + "explanation": "strip은 Java 11 API이며 trim과 공백 판정 기준이 다릅니다. 입력 정규화가 업무 의미를 바꾸지 않는지 실제 요구와 대조해야 합니다." + } + ], + "practice": { + "title": "텍스트 설정을 줄 단위 값으로 바꾸기", + "scenario": "운영자가 여러 줄로 붙여 넣은 항목에서 빈 줄과 앞뒤 공백을 제거해야 합니다.", + "tasks": [ + "공백·줄 구분자·중복 보존 정책을 명시하세요.", + "Java 11 API로 의도가 드러나는 변환을 작성하세요.", + "Java 8 배포 대상에 이 API가 들어갔을 때 어떤 검증이 막아야 하는지 설명하세요." + ], + "takeaway": "짧아진 코드를 확인하는 데서 끝내지 않습니다. API 계약과 빌드·런타임 대상이 일치하는지 확인합니다." + }, + "sources": [ + { + "title": "Java SE 11 API — String", + "url": "https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/String.html", + "edition": "Java SE 11", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 11 API — HttpClient", + "url": "https://docs.oracle.com/en/java/javase/11/docs/api/java.net.http/java/net/http/HttpClient.html", + "edition": "Java SE 11", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 11 — Local Variable Type Inference", + "url": "https://docs.oracle.com/en/java/javase/11/language/local-variable-type-inference.html", + "edition": "Java SE 11; local var Since 10", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "REST API" + ], + "exerciseId": "modern-apis-lab" + }, + { + "id": "records-sealed", + "title": "Java 17: record와 sealed 결과 모델", + "description": "값의 묶음과 결과 종류를 드러내되 불변성과 확장 제약을 정확히 이해합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": 17, + "preview": false, + "duration": 45, + "category": "버전 전환", + "goals": [ + "record가 제공하는 구성 요소와 생성 메서드를 설명한다.", + "sealed 계층으로 허용된 결과 종류를 제한한다.", + "Java 17에서 정식인 기능과 preview 기능을 구분한다." + ], + "theory": [ + { + "heading": "record는 투명한 데이터 운반자", + "text": "record는 구성 요소에 대응하는 final 필드, 접근자, 생성자와 값 기반 equals·hashCode·toString을 제공하는 특별한 클래스입니다. 구성 요소가 가변 리스트라면 record라고 해서 그 리스트까지 깊게 불변이 되지 않습니다. 값의 의미와 유효성 검증을 먼저 정하고 compact constructor나 방어적 복사가 필요한지 검토하세요. record의 정식 도입은 Java 16입니다." + }, + { + "heading": "sealed는 확장 가능한 종류를 제한한다", + "text": "sealed 클래스·인터페이스는 허용된 직접 하위 타입을 제한합니다. 하위 타입은 final, sealed, non-sealed 등 규칙을 따라 확장 정책을 드러냅니다. 예측 가능한 결과의 종류를 모델링할 때 유용하지만 외부 확장을 의도한 API에는 제약 비용이 있습니다. sealed의 정식 도입은 Java 17이므로 두 기능을 함께 쓰는 이 예제의 대상은 17입니다." + }, + { + "heading": "모델의 종류와 처리 문법은 별개다", + "text": "Java 17에서 record와 sealed를 사용할 수 있어도 pattern switch가 정식이라는 뜻은 아닙니다. 본 수업은 Java 17 preview 문법을 쓰지 않습니다. switch 표현식은 Java 14, text block은 15, instanceof 패턴은 16에서 정식 도입되었으며, record pattern과 pattern switch의 정식 학습은 Java 21 수업에서 이어집니다." + } + ], + "example": { + "title": "닫힌 계산 결과와 record 동등성 · Main.java", + "code": "class Main {\n sealed interface Result permits Success, Failure {}\n record Success(int value) implements Result {}\n record Failure(String reason) implements Result {}\n\n static Result doublePositive(int value) {\n if (value <= 0) {\n return new Failure(\"positive-required\");\n }\n return new Success(Math.multiplyExact(value, 2));\n }\n\n public static void main(String[] args) {\n System.out.println(doublePositive(3).equals(new Success(6)));\n System.out.println(doublePositive(0));\n }\n}", + "output": "true\nFailure[reason=positive-required]", + "explanation": "Success와 Failure만 Result의 직접 구현으로 허용합니다. 두 Success(6)은 record의 값 동등성으로 같습니다. 이 단순 예제에서 양수의 두 배가 int 범위를 넘으면 Math.multiplyExact가 ArithmeticException을 던집니다." + }, + "quiz": [ + { + "id": "records-sealed-q1", + "question": "record에 가변 ArrayList를 구성 요소로 넣었을 때 맞는 설명은?", + "options": [ + "record가 리스트와 요소를 자동으로 깊게 복사한다.", + "record이므로 외부에서 리스트를 절대 바꿀 수 없다.", + "구성 요소 참조의 final과 참조 대상의 깊은 불변성은 별개다.", + "record의 equals는 항상 참조 동일성만 비교한다." + ], + "correctIndex": 2, + "explanation": "record가 제공하는 final 필드는 참조의 재할당을 제한합니다. 가변 구성 요소를 공유할지 방어적으로 복사할지는 별도 설계입니다." + }, + { + "id": "records-sealed-q2", + "question": "정식 도입 버전의 조합으로 맞는 것은?", + "options": [ + "record 17, sealed 21", + "record 16, sealed 17", + "record 8, sealed 11", + "record 11, sealed 16" + ], + "correctIndex": 1, + "explanation": "record는 Java 16, sealed는 Java 17에서 정식 도입되었습니다. 이 수업은 둘을 함께 사용하므로 최소 대상 버전이 17입니다." + } + ], + "practice": { + "title": "성공과 거절 이유를 타입으로 표현", + "scenario": "예약 메서드가 true/false만 반환해 재고 부족과 잘못된 수량을 호출자가 구별하기 어렵습니다.", + "tasks": [ + "성공 결과와 실패 결과의 정보를 정하세요.", + "record와 sealed로 결과 종류를 모델링하세요.", + "새 실패 종류를 추가했을 때 호출 코드가 검토해야 할 지점을 설명하세요." + ], + "takeaway": "짧은 DTO 선언보다 상태와 결과의 의미를 명확히 하는 것이 먼저입니다. 닫힌 계층이 만드는 변경 비용도 함께 봅니다." + }, + "sources": [ + { + "title": "Java SE 17 — Record Classes", + "url": "https://docs.oracle.com/en/java/javase/17/language/records.html", + "edition": "Java SE 17; records final Since 16", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 17 — Sealed Classes", + "url": "https://docs.oracle.com/en/java/javase/17/language/sealed-classes-and-interfaces.html", + "edition": "Java SE 17; sealed final Since 17", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 21 — Language Changes", + "url": "https://docs.oracle.com/en/java/javase/21/language/java-language-changes.html", + "edition": "Java SE 21", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "Java 17" + ], + "exerciseId": "records-sealed-lab" + }, + { + "id": "java21", + "title": "Java 21: 패턴 분기와 가상 스레드", + "description": "타입별 처리를 명확하게 표현하고 동시성 도구의 적용 조건을 검토합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 21, + "featureSince": 21, + "preview": false, + "duration": 50, + "category": "버전 전환", + "goals": [ + "record pattern·when 가드·null 분기를 설명한다.", + "패턴의 순서와 포괄성의 의미를 이해한다.", + "가상 스레드를 적용할 작업과 자원 한계를 구분한다." + ], + "theory": [ + { + "heading": "타입과 상태를 함께 드러내는 패턴", + "text": "Java 21에서 record pattern과 pattern switch가 정식 기능이 되었습니다. record의 구성 요소를 패턴으로 꺼내고 when 가드로 추가 조건을 표현할 수 있습니다. 넓은 패턴이 앞에 오면 뒤의 구체 패턴을 가릴 수 있으므로 지배 관계와 포괄성을 검토하세요. switch가 null을 자동으로 정상 처리한다고 가정하지 말고 의도한 case null을 명시합니다." + }, + { + "heading": "가상 스레드는 더 빠른 CPU 스레드가 아니다", + "text": "가상 스레드는 많은 대기 중심 작업을 다루는 데 도움이 될 수 있지만 CPU 계산 자체를 빠르게 만들지 않습니다. 스레드당 작업 방식과 자원 수명을 이해하고 도입하세요. DB 연결 수나 외부 서비스 허용량은 스레드가 가볍다는 이유로 늘어나지 않으므로 별도 동시성 제한과 시간 제한이 필요합니다." + }, + { + "heading": "대상 버전의 실행 특성으로 판단한다", + "text": "Java 21의 가상 스레드에서는 synchronized 블록 안의 특정 블로킹 작업 등으로 carrier에 고정되는 상황을 고려해야 합니다. 이후 JDK에서 동작이 개선되었다는 사실을 Java 21에 소급하지 않습니다. preview 기능을 섞지 않고 기존 테스트·관측 지표·부하 특성으로 이행 효과를 검증합니다. 아래 예제는 기능 사용을 보여 줄 뿐 성능 벤치마크가 아닙니다." + } + ], + "example": { + "title": "패턴 분기 작업을 실행하고 기다리기 · Main.java", + "code": "class Main {\n record Job(String name, int attempts) {}\n\n static String label(Object value) {\n return switch (value) {\n case Job(String name, int attempts) when attempts > 0 ->\n name + \":\" + attempts;\n case Job job -> \"not-started\";\n case null -> \"missing\";\n default -> \"unknown\";\n };\n }\n\n public static void main(String[] args) throws Exception {\n try (java.util.concurrent.ExecutorService executor =\n java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {\n java.util.concurrent.Future result =\n executor.submit(() -> label(new Job(\"sync\", 2)));\n System.out.println(result.get());\n System.out.println(label(null));\n }\n }\n}", + "output": "sync:2\nmissing", + "explanation": "패턴과 가드로 Job의 상태를 분류합니다. 가상 스레드 실행기에 작업을 제출한 뒤 Future로 결과를 기다리고 try-with-resources로 실행기 수명을 관리합니다. 모든 문법·API는 Java 21 정식 기능입니다." + }, + "quiz": [ + { + "id": "java21-q1", + "question": "가상 스레드에 대한 설명으로 가장 적절한 것은?", + "options": [ + "CPU 연산은 플랫폼 스레드보다 항상 빠르다.", + "I/O 대기가 많은 동시 작업에 도움이 될 수 있지만 외부 자원의 한계는 별도로 관리해야 한다.", + "가상 스레드를 쓰면 DB 연결 풀의 제한이 사라진다.", + "동기화와 취소 처리를 모두 생략할 수 있다." + ], + "correctIndex": 1, + "explanation": "가상 스레드는 많은 대기 작업의 처리 규모를 지원하는 도구입니다. 작업 자체의 CPU 속도나 DB·외부 시스템의 수용량을 자동으로 높이지 않습니다." + }, + { + "id": "java21-q2", + "question": "case Order(String id, int quantity) when quantity > 0 의 의미는?", + "options": [ + "모든 Object가 조건 없이 일치한다.", + "Order의 모든 필드를 변경한다.", + "Order 패턴에 맞고 quantity > 0도 만족해야 일치한다.", + "Java 8에서 같은 문법을 사용할 수 있다." + ], + "correctIndex": 2, + "explanation": "패턴 일치와 가드의 참 조건을 모두 만족해야 합니다. record pattern과 pattern switch는 이 수업의 Java 21 정식 문법입니다." + } + ], + "practice": { + "title": "버전 상향의 효과와 위험 설명", + "scenario": "Java 17 서비스의 타입 분기를 단순화하고 대기 중심 호출의 실행 모델을 검토하려 합니다.", + "tasks": [ + "패턴 분기에서 null·알 수 없는 타입·경계 상태를 테스트하세요.", + "가상 스레드 적용 전후 비교에 필요한 대기·CPU·외부 자원 지표를 정하세요.", + "외부 연결 수 제한과 취소·종료 정책을 설명하세요." + ], + "takeaway": "새 기능을 사용했다는 사실과 서비스가 개선되었다는 증거를 구분합니다. 버전별 계약과 실제 작업 특성으로 판단합니다." + }, + "sources": [ + { + "title": "Java SE 21 — Pattern Matching for switch", + "url": "https://docs.oracle.com/en/java/javase/21/language/pattern-matching-switch.html", + "edition": "Java SE 21", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 21 — Record Patterns", + "url": "https://docs.oracle.com/en/java/javase/21/language/record-patterns.html", + "edition": "Java SE 21", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 21 — Virtual Threads", + "url": "https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html", + "edition": "Java SE 21", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "Java 21" + ], + "exerciseId": "java21-lab" + }, + { + "id": "testing-design", + "title": "테스트 설계: 계약과 경계 결함 구별", + "description": "테스트가 통과한다는 사실보다 잘못된 구현을 실제로 구별하는지 확인합니다.", + "track": "both", + "level": "심화", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 45, + "category": "실패와 검증", + "goals": [ + "사례를 정상·경계·실패로 나눠 계약을 검증한다.", + "결정적 계산과 외부 환경 의존을 분리한다.", + "잘못된 구현을 통해 테스트의 판별력을 점검한다." + ], + "theory": [ + { + "heading": "테스트는 구현을 복사하지 않는다", + "text": "메서드 내부의 조건문을 그대로 기대값 계산에 복사하면 같은 결함을 테스트에도 넣을 수 있습니다. 요구사항에서 기대 행동을 먼저 정하고 경계 바로 아래·경계·경계 위를 선택하세요. 대표값 검사가 전체 입력 공간에 대한 수학적 증명은 아니라는 점도 구분합니다." + }, + { + "heading": "테스트 가능성은 의존 관계와 연결된다", + "text": "현재 시간, 임의 값, 네트워크와 DB를 계산 로직에 직접 결합하면 재현하기 어렵습니다. 순수 계산과 외부 의존을 분리하고 필요한 경계에 협력 객체를 주입하세요. 대역을 사용한 단위 검사는 실제 통합 환경의 계약 검사를 대체하지 않습니다. 테스트마다 같은 입력과 조건으로 실행할 수 있도록 설계합니다." + }, + { + "heading": "좋은 테스트는 잘못된 구현을 거절한다", + "text": "정상 구현만 통과하는지 보는 데서 멈추지 않고 >=를 >로 바꾸거나 음수 검증을 누락한 구현을 넣어 보세요. 실제 실무에서는 JUnit 등의 테스트 도구가 실패 위치·메시지·실행을 지원합니다. 이 수업은 외부 의존성 없는 Java로 판별력의 원리를 연습하며 JUnit을 실행했다고 표시하지 않습니다." + } + ], + "example": { + "title": "기대값을 명시하는 작은 계약 검사 · Main.java", + "code": "class Main {\n static int shippingFee(int subtotal) {\n if (subtotal < 0) {\n throw new IllegalArgumentException(\"negative subtotal\");\n }\n return subtotal >= 30000 ? 0 : 3000;\n }\n\n static void check(int expected, int actual) {\n if (expected != actual) {\n throw new AssertionError(\"expected=\" + expected + \", actual=\" + actual);\n }\n }\n\n public static void main(String[] args) {\n check(3000, shippingFee(29999));\n check(0, shippingFee(30000));\n check(0, shippingFee(30001));\n try {\n shippingFee(-1);\n throw new AssertionError(\"negative input must fail\");\n } catch (IllegalArgumentException expected) {\n // Expected contract failure.\n }\n System.out.println(\"4 contract checks passed\");\n }\n}", + "output": "4 contract checks passed", + "explanation": "무료 배송 경계의 양옆과 음수 실패 계약을 직접 검사합니다. -ea 옵션에 의존하는 assert 문 대신 실패 시 AssertionError를 던지므로 검사가 항상 실행됩니다. 실제 테스트 도구를 대체하려는 구현은 아닙니다." + }, + "quiz": [ + { + "id": "testing-design-q1", + "question": "무료 배송 조건이 subtotal >= 30000일 때 경계 결함을 잘 구별하는 입력 묶음은?", + "options": [ + "1000, 2000, 3000", + "0 하나만", + "29999, 30000, 30001", + "항상 100000만" + ], + "correctIndex": 2, + "explanation": "경계 바로 아래·경계·경계 위를 검사하면 >와 >=의 차이 등을 드러낼 수 있습니다. 다른 입력·실패 조건도 별도 요구에 따라 추가합니다." + }, + { + "id": "testing-design-q2", + "question": "정상 구현과 의도적으로 틀린 구현이 모두 테스트를 통과했다면?", + "options": [ + "테스트가 더 유연하므로 항상 좋은 결과다.", + "테스트가 해당 결함을 구별하지 못하므로 기대값·입력·검사 여부를 검토해야 한다.", + "틀린 구현도 자동으로 올바른 계약이 된다.", + "테스트 실행 횟수만 늘리면 모든 설계 문제가 해결된다." + ], + "correctIndex": 1, + "explanation": "테스트는 중요한 결함을 드러내야 합니다. 입력 선정, 기대값, 실제 assertion의 실행 여부를 확인하고 그 결함을 재현하는 검사를 보완합니다." + } + ], + "practice": { + "title": "배송비 정책을 검증하는 테스트 작성", + "scenario": "무료 배송 경계를 잘못 바꾼 구현이 기존 테스트를 통과했습니다.", + "tasks": [ + "정상·경계·실패 대표값을 기대 결과와 함께 정하세요.", + "정상 구현은 수용하고 경계가 틀린 구현은 거절하는 검사 함수를 만드세요.", + "실무 도구로 옮길 때 필요한 실패 메시지와 단위·통합 검사 범위를 설명하세요." + ], + "takeaway": "실행 성공과 계약 검증, 설계 설명은 서로 다른 결과입니다. 테스트가 어떤 결함을 찾는지 근거를 남깁니다." + }, + "sources": [ + { + "title": "Java SE 8 API — IntUnaryOperator", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/function/IntUnaryOperator.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JUnit 5 User Guide — Writing Tests", + "url": "https://docs.junit.org/5.11.4/user-guide/", + "edition": "JUnit 5.11.4; 후속 실무 도구 참고", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — AssertionError", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/AssertionError.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "JUnit" + ], + "exerciseId": "testing-design-lab" + } +]; diff --git a/dist/data/exercises.js b/dist/data/exercises.js new file mode 100644 index 0000000..6bfe6f9 --- /dev/null +++ b/dist/data/exercises.js @@ -0,0 +1,300 @@ +// Public learning contracts. Trusted checks and reference answers are kept outside dist. +export const exercises = [ + { + "id": "java-basics-lab", + "lessonId": "java-basics", + "title": "참조가 달라도 같은 코드인가?", + "mode": "method", + "minVersion": 8, + "instructions": [ + "class Solution의 public boolean sameCode(String left, String right)를 완성하세요.", + "두 문자열의 내용이 같으면 true입니다. 두 값이 모두 null이면 true, 하나만 null이면 false입니다.", + "참조 동일성을 내용 동등성과 구분하세요. 입력값을 변경하지 않습니다." + ], + "template": "class Solution {\n public boolean sameCode(String left, String right) {\n // null과 내용 동등성을 함께 처리하세요.\n return false;\n }\n}", + "sampleDescription": "서로 다른 new String(\"JAVA\") 객체 두 개는 같은 코드로 판단합니다.", + "sampleOutput": "내용이 같은 별도 객체: 통과", + "goals": [ + "참조 동일성과 내용 동등성을 구분한다.", + "null을 포함한 호출 계약을 구현한다." + ], + "rubric": [ + "== 비교에 의존하지 않고 내용 비교를 사용하는지 설명한다.", + "null 계약을 호출자가 예측할 수 있도록 설명한다." + ] + }, + { + "id": "oop-lab", + "lessonId": "oop", + "title": "재고의 불변식을 객체 안에 두기", + "mode": "class", + "minVersion": 8, + "instructions": [ + "class Solution을 재고 객체로 설계하세요. public Solution(int initialStock), public boolean reserve(int quantity), public int remaining()을 제공해야 합니다.", + "초기 재고는 0 이상입니다. 음수이면 생성자에서 IllegalArgumentException을 던지세요.", + "reserve는 양수이며 현재 재고 이하인 수량만 예약하고 true를 반환합니다. 나머지는 false이며 재고를 변경하지 않습니다.", + "재고를 외부에서 직접 변경할 수 없게 하세요. 실패한 예약 뒤에도 남은 재고는 그대로여야 합니다." + ], + "template": "class Solution {\n // 필드, 생성자, reserve, remaining을 설계하세요.\n}", + "sampleDescription": "재고 5개에서 3개 예약 후 잔여 2개, 추가 3개 예약은 실패하고 2개를 유지합니다.", + "sampleOutput": "예약 성공과 초과 예약 실패: 통과", + "goals": [ + "생성자와 메서드에서 객체의 불변식을 지킨다.", + "실패한 명령의 상태 변경 여부를 명확히 한다." + ], + "rubric": [ + "재고 필드를 캡슐화하고 잘못된 상태를 만드는 경로를 차단한다.", + "예약 검증과 변경의 책임을 객체에 두며 실패 계약을 설명한다.", + "이 과제는 단일 스레드 객체이며 분산 락이나 동시 예약 안전성을 구현한 것이 아님을 구분한다." + ] + }, + { + "id": "polymorphism-lab", + "lessonId": "polymorphism", + "title": "상속 없이 할인 정책 교체하기", + "mode": "class", + "minVersion": 8, + "instructions": [ + "class Solution 안에 public interface DiscountPolicy { int discount(int subtotal); }를 선언하세요.", + "public int payable(int subtotal, DiscountPolicy policy)를 구현하세요. 소계에서 정책이 반환한 할인액을 뺀 값을 반환합니다.", + "subtotal은 0 이상, 할인액은 0 이상 subtotal 이하입니다. 위반하면 IllegalArgumentException을 던지세요.", + "policy가 null이면 NullPointerException을 던지세요. policy.discount는 유효한 subtotal에 대해 정확히 한 번 호출합니다.", + "정책의 구체 클래스나 종류를 검사하지 않고 인터페이스로 협력하세요." + ], + "template": "class Solution {\n // DiscountPolicy와 payable 메서드를 설계하세요.\n}", + "sampleDescription": "소계 10000에 할인액 1000을 반환하는 정책을 주입하면 결제액은 9000입니다.", + "sampleOutput": "정액 정책 주입: 통과", + "goals": [ + "협력 인터페이스로 구현 교체 지점을 만든다.", + "다형성과 호출 계약을 함께 검증한다." + ], + "rubric": [ + "구체 타입 분기 없이 정책에 계산을 위임한다.", + "정책을 상속할 이유와 조합할 이유를 비교한다.", + "새 정책이 기존 호출 계약을 깨지 않도록 검증하는 위치를 설명한다." + ] + }, + { + "id": "collections-lab", + "lessonId": "collections", + "title": "순서를 보존하는 중복 제거", + "mode": "method", + "minVersion": 8, + "instructions": [ + "public java.util.List uniqueCodes(java.util.List codes)를 완성하세요.", + "내용이 같은 문자열은 첫 항목만 남기고 최초 등장 순서를 유지합니다. null 항목도 한 번만 남깁니다.", + "입력 리스트 자체는 null이 아니라고 가정합니다. 입력 리스트를 변경하지 마세요.", + "결과는 입력과 다른 새로운 리스트여야 합니다. 결과를 수정해도 입력 리스트는 바뀌지 않아야 합니다." + ], + "template": "class Solution {\n public java.util.List uniqueCodes(java.util.List codes) {\n return new java.util.ArrayList();\n }\n}", + "sampleDescription": "[\"B\", \"A\", \"B\"]를 입력하면 [\"B\", \"A\"]를 반환합니다.", + "sampleOutput": "최초 등장 순서: 통과", + "goals": [ + "컬렉션의 순서·중복·변경 계약을 선택한다.", + "참조 공유와 복사본을 구분한다." + ], + "rubric": [ + "선택한 Set/List 구현이 요구 순서와 동등성 계약에 맞는지 설명한다.", + "복사본이 깊은 복사를 뜻하지 않음을 설명한다." + ] + }, + { + "id": "generics-lab", + "lessonId": "generics", + "title": "생산자와 소비자를 받는 복사 API", + "mode": "method", + "minVersion": 8, + "instructions": [ + "public void copy(java.util.List source, java.util.List target)를 완성하세요.", + "source의 모든 항목을 순서대로 target의 끝에 추가하세요. target의 기존 항목은 유지합니다.", + "두 리스트는 null이 아니며 서로 다른 객체이고, 저장소를 공유하는 뷰·래퍼 관계가 없습니다. target은 source의 모든 요소를 수용하고 추가할 수 있는 리스트입니다.", + "raw type이나 강제 캐스팅을 사용하지 마세요. source를 변경하지 않습니다." + ], + "template": "class Solution {\n public void copy(java.util.List source,\n java.util.List target) {\n // 생산자에서 읽고 소비자에 추가하세요.\n }\n}", + "sampleDescription": "List의 1, 2를 List의 0.5 뒤에 추가합니다.", + "sampleOutput": "Integer에서 Number로 복사: 통과", + "goals": [ + "불공변 제네릭과 wildcard 경계를 구분한다.", + "읽기·쓰기 역할로 API 타입을 정한다." + ], + "rubric": [ + "extends에서 T로 읽을 수 있고 super에 T를 쓸 수 있는 이유를 설명한다.", + "컴파일 오류를 raw type이나 unchecked cast로 우회하지 않는다." + ] + }, + { + "id": "exceptions-lab", + "lessonId": "exceptions", + "title": "원인을 보존하는 설정값 파서", + "mode": "method", + "minVersion": 8, + "instructions": [ + "public int parsePort(String text)를 완성하세요.", + "앞뒤 공백은 String.trim()으로 제거하고 1~65535 범위의 정수를 반환하세요.", + "null, 빈 문자열, 숫자 형식 오류, 범위 밖 값은 IllegalArgumentException으로 거절합니다.", + "Integer.parseInt에서 NumberFormatException이 발생했다면 새 IllegalArgumentException의 cause로 원본 예외를 보존하세요. 오류를 0 같은 정상값으로 바꾸지 않습니다." + ], + "template": "class Solution {\n public int parsePort(String text) {\n return 0;\n }\n}", + "sampleDescription": "\" 8080 \"은 8080입니다. \"eighty\"는 원인 예외를 보존하여 거절합니다.", + "sampleOutput": "공백을 포함한 유효 설정: 통과", + "goals": [ + "실패를 정상값과 구분한다.", + "예외 변환 시 진단 가능한 원인을 보존한다." + ], + "rubric": [ + "입력 검증과 파싱 실패의 책임을 설명한다.", + "광범위한 예외 삼키기나 임의의 기본값 반환을 사용하지 않는다." + ] + }, + { + "id": "streams-lab", + "lessonId": "streams", + "title": "공유 상태 없이 조건 합계 구하기", + "mode": "method", + "minVersion": 8, + "instructions": [ + "public int totalPositiveEven(java.util.List values)를 완성하세요.", + "null 항목, 0, 음수와 홀수는 제외하고 양수인 짝수만 합산하세요.", + "리스트 자체는 null이 아니며 유효 항목의 총합은 int 범위를 넘지 않습니다. 입력을 변경하지 마세요.", + "Stream과 원시형 집계 연산으로 외부 가변 누산기 없이 작성하세요. 이 설계 조건은 별도 루브릭으로 확인합니다." + ], + "template": "class Solution {\n public int totalPositiveEven(java.util.List values) {\n return 0;\n }\n}", + "sampleDescription": "[2, -4, 3, null, 6, 0]의 결과는 8입니다.", + "sampleOutput": "조건 필터와 합계: 통과", + "goals": [ + "중간 연산과 최종 연산을 구분한다.", + "공유 상태 대신 데이터 흐름으로 집계한다." + ], + "rubric": [ + "Stream 파이프라인과 최종 연산을 식별한다.", + "외부 컬렉션·누산기 변경 없이 결과를 구한다.", + "parallelStream으로 바꾸면 무조건 빨라진다는 주장을 하지 않는다." + ] + }, + { + "id": "concurrency-lab", + "lessonId": "concurrency", + "title": "작업 완료를 기다리는 안전한 카운터", + "mode": "class", + "minVersion": 8, + "instructions": [ + "class Solution에 public int count(int workers, int iterations)를 구현하세요.", + "workers개의 실제 작업을 ExecutorService에 제출하고, 각 작업이 공유 AtomicInteger를 iterations번 증가시키게 하세요.", + "모든 작업이 끝난 뒤 합계를 반환하고 ExecutorService를 종료하세요. workers는 1~8, iterations는 0~10000이며 범위 밖 입력은 IllegalArgumentException입니다.", + "대기 중 인터럽트가 발생하면 인터럽트 상태를 복원하고 IllegalStateException으로 전파하세요. 작업 실패도 원인을 보존해 IllegalStateException으로 전파합니다.", + "자동 테스트는 반환값과 입력 계약을 확인합니다. 실제 작업 제출·원자 연산·종료 책임은 별도 설계 검토 대상입니다." + ], + "template": "class Solution {\n // count 메서드와 필요한 협력 객체를 작성하세요.\n}", + "sampleDescription": "작업 2개가 각각 500번 증가시키면 반환값은 1000입니다.", + "sampleOutput": "두 작업의 완료 합계: 통과", + "goals": [ + "공유 상태의 원자성과 완료 대기를 분리한다.", + "Executor의 생명주기와 실패 전파를 책임진다." + ], + "rubric": [ + "workers × iterations 계산만 반환하지 않고 실제 작업을 제출한다.", + "AtomicInteger 등 원자 연산의 적용 범위와 복합 업무 트랜잭션의 차이를 설명한다.", + "작업 완료 확인, Executor 종료, 인터럽트 복원을 검토한다." + ] + }, + { + "id": "modern-apis-lab", + "lessonId": "modern-apis", + "title": "Java 11 API로 여러 줄 입력 정규화하기", + "mode": "method", + "minVersion": 11, + "instructions": [ + "public java.util.List normalizedLines(String text)를 완성하세요.", + "String.lines() 기준으로 줄을 나눈 뒤 각 줄에 strip()을 적용하고 isBlank()인 줄을 제외하세요.", + "최초 줄 순서와 중복 줄은 보존합니다. null 입력은 IllegalArgumentException으로 거절하세요.", + "이 과제는 Java 11 이상의 API를 사용합니다. trim()과 strip()의 공백 판정 차이를 확인하세요." + ], + "template": "class Solution {\n public java.util.List normalizedLines(String text) {\n return new java.util.ArrayList();\n }\n}", + "sampleDescription": "\" Java\\n\\n SQL \"은 [\"Java\", \"SQL\"]이 됩니다.", + "sampleOutput": "빈 줄 제거와 순서: 통과", + "goals": [ + "API Since와 대상 런타임을 연결한다.", + "공백 정의가 입력 계약에 미치는 영향을 이해한다." + ], + "rubric": [ + "String.lines·strip·isBlank의 역할을 설명한다.", + "Java 8에서 동일 코드를 실행할 수 있다고 표시하지 않는다." + ] + }, + { + "id": "records-sealed-lab", + "lessonId": "records-sealed", + "title": "성공과 실패를 닫힌 결과 타입으로 표현하기", + "mode": "class", + "minVersion": 17, + "instructions": [ + "class Solution 안에 public sealed interface Result permits Accepted, Rejected를 선언하세요.", + "public record Accepted(int remaining) implements Result와 public record Rejected(String reason) implements Result를 선언하세요.", + "public Result reserve(int stock, int requested)를 구현하세요. stock < 0이면 Rejected(\"invalid-stock\"), requested <= 0이면 Rejected(\"invalid-quantity\"), requested > stock이면 Rejected(\"insufficient-stock\")를 반환합니다. 이 순서로 검증하세요.", + "그 외에는 Accepted(stock - requested)를 반환합니다. 입력값 외 공유 상태는 필요 없습니다.", + "Java 17에서 정식인 record·sealed 타입을 사용하세요. Java 17의 preview pattern switch는 사용하지 않습니다." + ], + "template": "class Solution {\n // Result, Accepted, Rejected와 reserve를 선언하세요.\n}", + "sampleDescription": "재고 5개에서 2개 예약은 Accepted(3)과 값이 같은 결과입니다.", + "sampleOutput": "성공 결과의 값 동등성: 통과", + "goals": [ + "닫힌 타입 계층으로 결과의 종류를 드러낸다.", + "record의 생성 코드와 불변성의 범위를 구분한다." + ], + "rubric": [ + "sealed 계층과 record로 상태의 의미를 명확히 한다.", + "record가 가변 구성 요소까지 깊게 불변으로 만들지는 않음을 설명한다.", + "Java 17에서 preview 문법을 섞지 않는다." + ] + }, + { + "id": "java21-lab", + "lessonId": "java21", + "title": "패턴과 가드로 입력의 의미 분류하기", + "mode": "method", + "minVersion": 21, + "instructions": [ + "제공된 public record Order(String id, int quantity)는 유지하고 public String describe(Object value)를 완성하세요.", + "Order이며 quantity > 0이면 id + \":\" + quantity를 반환합니다. 나머지 Order는 \"empty\", null은 \"missing\", 다른 타입은 \"unsupported\"입니다.", + "과제의 Order.id는 null이 아니라고 가정합니다.", + "Java 21의 record pattern과 when 가드를 사용하세요. 분기 순서와 null 분기를 설명할 수 있어야 합니다." + ], + "template": "class Solution {\n public record Order(String id, int quantity) {}\n\n public String describe(Object value) {\n return \"unsupported\";\n }\n}", + "sampleDescription": "new Order(\"A-21\", 3)은 \"A-21:3\"입니다.", + "sampleOutput": "양수 주문 패턴: 통과", + "goals": [ + "record pattern과 가드 조건으로 타입·상태를 함께 분기한다.", + "null과 포괄 분기 범위를 명시한다." + ], + "rubric": [ + "record pattern·when 가드의 범위를 설명한다.", + "구체적인 패턴을 넓은 패턴보다 먼저 배치하는 이유를 설명한다.", + "Java 21 정식 문법만 사용하고 preview 옵션에 의존하지 않는다." + ] + }, + { + "id": "testing-design-lab", + "lessonId": "testing-design", + "title": "정상 구현과 경계 결함을 구별하는 테스트", + "mode": "method", + "minVersion": 8, + "instructions": [ + "public boolean acceptsPolicy(java.util.function.IntUnaryOperator policy)를 완성하세요. 배송비 정책을 검사하는 작은 테스트 함수를 작성하는 과제입니다.", + "정책의 계약은 주문액 0~29999이면 배송비 3000, 30000 이상이면 0, 음수 주문액은 IllegalArgumentException입니다.", + "이 과제에서는 0, 29999, 30000, 30001, -1의 다섯 대표값을 검사하세요. 모두 기대 행동이면 true, 하나라도 다르거나 예상 밖 RuntimeException이면 false입니다.", + "policy는 null이 아니며 호출 결과는 결정적이라고 가정합니다. JUnit 의존성 없이 테스트의 판별력을 연습합니다." + ], + "template": "class Solution {\n public boolean acceptsPolicy(java.util.function.IntUnaryOperator policy) {\n return false;\n }\n}", + "sampleDescription": "올바른 정책은 통과하고, 무료 배송 경계를 > 30000으로 잘못 쓴 정책은 거절합니다.", + "sampleOutput": "계약을 만족하는 정책 수용: 통과", + "goals": [ + "정상값과 경계값으로 규칙을 검증한다.", + "테스트가 잘못된 구현을 실제로 구분하는지 확인한다." + ], + "rubric": [ + "정상 구현을 통과시키고 의도적으로 틀린 구현을 실패시킨다.", + "검사한 대표값과 전체 입력 범위에 대한 증명을 구분한다.", + "실무에서는 같은 검사를 JUnit 등 테스트 도구에 옮겨 실패 진단을 개선할 수 있음을 설명한다." + ] + } +]; diff --git a/dist/data/job-evidence.js b/dist/data/job-evidence.js new file mode 100644 index 0000000..741051f --- /dev/null +++ b/dist/data/job-evidence.js @@ -0,0 +1,40 @@ +// 확인일은 게시일이 아닙니다. 수업 연결은 교육 설계자의 제안입니다. +export const jobEvidence = [ + { + id: 'tossplace-platform-4071141003', company: '토스플레이스', title: 'Server Developer', + url: 'https://toss.im/career/job-detail?company=%ED%86%A0%EC%8A%A4%ED%94%8C%EB%A0%88%EC%9D%B4%EC%8A%A4&detailedPosition=Platform&job_id=4071141003', + checkedAt: '2026-10-12', status: '상태 미확인', access: 'full', + technologies: ['Java', 'Kotlin', 'Spring Boot', 'JPA/Hibernate', 'Redis', 'Kafka', 'Kubernetes', '동시성', '테스트'], + summary: 'Platform 본문은 공통 기능과 테스트·호환성 관리, 동시성·타임아웃·재시도·중복·부분 실패 대응을 설명합니다. Java/Kotlin과 Spring 경험은 개발 환경 및 우대 경험으로 제시됩니다.', + lessonIds: ['oop', 'exceptions', 'testing-design', 'concurrency'] + }, + { + id: 'aladin-backend-52759934', company: '알라딘커뮤니케이션', title: '백엔드 엔지니어-개발2팀', + url: 'https://jumpit.saramin.co.kr/position/52759934', checkedAt: '2026-10-12', status: '마감', access: 'full', + technologies: ['Kotlin', 'Java', 'Kubernetes', 'Docker', '단위 테스트', '부하 테스트', '쿼리 튜닝', '동시성'], + summary: 'Kotlin 역량과 별도로 Java 등을 포함한 언어 중 하나의 문제 해결 능력, 테스트 작성, 부하·쿼리 튜닝 경험을 요구합니다. 동시성과 분산 처리는 우대사항입니다.', + lessonIds: ['collections', 'testing-design', 'concurrency'] + }, + { + id: 'covision-backend-50154427', company: '코비젼', title: '경력직 개발자 채용 - JAVA/프론트엔드/백엔드/전문연구요원', + url: 'https://jumpit.saramin.co.kr/position/50154427', checkedAt: '2026-10-12', status: '마감', access: 'full', + technologies: ['Java', 'Spring Framework', 'JPA/Hibernate', 'SQL', 'RDBMS', 'Docker', 'Kubernetes', 'API 설계'], + summary: '여러 직군을 함께 모집한 공고입니다. 백엔드 요건에 SQL·관계형 DB, Spring·JPA, 컨테이너 배포·운영과 API 설계 경험이 명시됩니다.', + lessonIds: ['oop', 'modern-apis', 'testing-design'] + }, + { + id: 'whatap-kubernetes-291531', company: '와탭랩스', title: 'Kubernetes 에이전트 개발자', + url: 'https://career.rememberapp.co.kr/job/posting/291531', checkedAt: '2026-10-12', status: '상태 미확인', access: 'full', + technologies: ['Java', 'Go', 'Kubernetes', '자료구조', '멀티스레드', '소켓 통신', '메모리 최적화', '비동기 처리'], + summary: 'Java 또는 Go 역량, 자료구조·멀티스레드·소켓 이해를 요구합니다. 메트릭 수집과 메모리 효율 개선, 비동기 데이터 처리 설계가 업무에 포함됩니다.', + lessonIds: ['collections', 'concurrency'] + }, + { + id: 'muzlive-backend-1679758', company: '뮤즈라이브', title: '백엔드 엔지니어(Java/Spring)', + url: 'https://www.jobplanet.co.kr/job/search?entry=job_posting_keyword&posting_ids%5B%5D=1679758', + checkedAt: '2026-10-12', status: '상태 미확인', access: 'full', + technologies: ['Java', 'Spring Boot', 'MySQL', 'Redis', 'AWS', 'Git'], + summary: 'Java 서버의 개발·유지보수와 코드 품질·설계 개선을 소개합니다. Spring Boot·MySQL·Redis·AWS는 사용 환경이며 대용량 처리 경험은 우대사항입니다.', + lessonIds: ['oop', 'collections', 'exceptions'] + } +]; diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..969617f --- /dev/null +++ b/dist/index.html @@ -0,0 +1,77 @@ + + + + + + + + Java Edu — 이해에서 설계까지 + + + + + + + + + +
+
+

THE JAVA LEARNING WORKBENCH

이해에서 한 걸음 더,
설계하는 Java로.

다른 언어에서 Java로, 익숙한 코드에서 깊은 설계로.
공식 문서를 읽고 직접 구현하며 판단의 근거를 쌓습니다.

2 학습 트랙4 기준 버전12 코어 수업
+
MY LEARNING브라우저 기록
나의 학습 여정0 / 12

객관식 성취 + 코드 제출 통과 시 수업 완료

+
+ +
+
WORKSPACE 01Java 학습실
Java 공식 문서 기반
+
학습 콘텐츠를 불러오고 있습니다…
+ +
+
javaedu.

공식 문서로 이해하고, 코드로 확인하는 시간.

+
+ + +
+
+ +

LEARNING GUIDE

이 학습실을 사용하는 방법

다른 언어 경험자의 Java 전환과 현직 개발자의 심화 학습을 함께 지원합니다. 이론 → 예제 → 문제 → 실무 → 코드 실습 순서로 진행해 보세요.

  • 학습 기준: Java 8·11·17·21. 선택 버전에 맞는 수업을 표시합니다.
  • 객관식 성취: 한 수업의 모든 문제에 정답을 제출하면 기록됩니다.
  • 실습 통과: 현재 작성 코드로 제출한 테스트가 모두 통과하면 기록됩니다. 두 조건을 모두 충족하면 수업 완료입니다.
  • 설계 기록: 나의 판단을 정리하는 메모입니다. 자동 평가나 정답 판정이 아닙니다.
  • 실행 환경: 실제 JVM은 Java 21이며, 실습별 최소 버전을 컴파일 대상으로 사용합니다. 다른 JVM 버전에서 실행한 결과는 아닙니다.
  • 저장: 코드, 메모, 진도는 이 브라우저에 저장됩니다. 기기 간 동기화·계정 백업을 제공하지 않습니다.

초기 프로토타입의 편집기는 일반 텍스트 입력 방식입니다. 자동 완성이나 실시간 문법 진단은 제공하지 않습니다.

+

BROWSER RECORDS

학습 기록을 초기화할까요?

이 브라우저의 진도, 작성 코드와 설계 메모를 모두 지웁니다. 현재 학습 기준 버전은 유지됩니다.

+ + + diff --git a/dist/styles.css b/dist/styles.css new file mode 100644 index 0000000..d60f186 --- /dev/null +++ b/dist/styles.css @@ -0,0 +1,488 @@ +@charset "UTF-8"; + +:root { + color-scheme: light; + --paper: #f4f3ed; + --card: #fffefa; + --ink: #233a34; + --muted: #66716a; + --green: #173d35; + --green-soft: #e9efe7; + --line: #dddfd4; + --rust: #a54b2b; + --rust-soft: #fcf0e8; + --yellow: #d9e5a5; + --radius: 16px; + --font: "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", "Segoe UI", sans-serif; + --mono: "Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; scroll-padding-top: 1.5rem; } +body { margin: 0; background: var(--paper); color: var(--ink); font: 0.9375rem/1.7 var(--font); -webkit-text-size-adjust: 100%; } +button, input, select, textarea { font: inherit; } +button, a, input, select, textarea { -webkit-tap-highlight-color: transparent; } +button { color: inherit; cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .58; } +button, input, select, textarea, a { touch-action: manipulation; } +button { -webkit-user-select: none; user-select: none; } +input, textarea { min-width: 0; } +a { color: var(--green); text-underline-offset: 3px; } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible, summary:focus-visible { outline: 3px solid #b75d35; outline-offset: 4px; } +button:focus:not(:focus-visible), [tabindex="-1"]:focus { outline: none; } +button, h1, h2, h3, p, ul, ol, pre { margin: 0; } +button { border: 0; background: none; } +ul, ol { padding-left: 1.35rem; } +[hidden] { display: none !important; } +.icon-library { position: absolute; width: 0; height: 0; overflow: hidden; } +.icon { width: 19px; height: 19px; flex: 0 0 19px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.skip-link { position: fixed; z-index: 200; top: -6rem; left: 1rem; padding: .7rem 1rem; background: var(--green); color: white; border-radius: 8px; } +.skip-link:focus { top: 1rem; } +.site-header { min-height: 85px; max-width: 1600px; padding: 1.1rem 3rem; margin: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 1rem 3.5rem; border-bottom: 1px solid var(--line); } +.brand { display: flex; align-items: center; gap: .65rem; color: var(--green); font-size: 1.65rem; font-weight: 800; letter-spacing: -.09rem; text-decoration: none; line-height: 1.2; } +.brand-light { font-weight: 400; } +.brand-mark { display: inline-flex; flex-shrink: 0; width: 2.25rem; height: 2.375rem; align-items: center; justify-content: center; padding-bottom: .25rem; border-radius: .5625rem; background: var(--green); color: white; font-family: Georgia, serif; font-weight: 600; font-style: italic; font-size: 1.9rem; line-height: 1; } +.brand-mark span { color: var(--yellow); font-style: normal; } +.header-navigation { display: flex; align-items: center; gap: 2rem; } +.header-link { padding: .5rem 0; font-size: .875rem; color: var(--muted); text-decoration: none; white-space: nowrap; } +.header-link.is-current { color: var(--green); font-weight: 700; position: relative; } +.header-link.is-current::after { content: ""; width: 4px; height: 4px; border-radius: 50%; position: absolute; bottom: 0; left: calc(50% - 2px); background: var(--green); } +.header-tools { display: flex; align-items: center; gap: 1rem; margin-left: auto; } +.storage-indicator { display: inline-flex; align-items: center; gap: .5rem; color: var(--muted); font-size: .75rem; } +.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex: 0 0 6px; background: #67866e; } +.storage-indicator.is-unavailable .status-dot { background: var(--rust); } +.icon-button { display: inline-flex; align-items: center; justify-content: center; gap: .4rem; min-width: 42px; min-height: 42px; padding: .55rem; border-radius: 8px; } +.icon-button:hover { background: var(--green-soft); } +.mobile-menu-toggle, .mobile-menu-close { display: none; } +.page-shell { width: min(100%, 1600px); margin: auto; padding: 0 3rem; } +.intro { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 365px); align-items: center; gap: 4rem; padding: 3.4rem 0 2.8rem; } +.eyebrow, .small-label { font-size: .6875rem; font-weight: 700; line-height: 1.5; letter-spacing: .13em; } +.eyebrow { color: var(--rust); display: flex; align-items: center; gap: .6rem; } +.eyebrow-line { width: 21px; height: 1px; background: var(--rust); } +.intro h1 { font-size: clamp(2rem, 3.4vw, 3.05rem); font-weight: 760; line-height: 1.35; letter-spacing: -.06em; margin-top: 1rem; } +.intro h1 span { color: var(--green); } +.intro-description { color: var(--muted); margin-top: 1rem; font-size: .875rem; line-height: 1.85; letter-spacing: -.01em; } +.intro-facts { display: flex; flex-wrap: wrap; gap: 1.4rem; color: var(--muted); margin-top: 1.45rem; font-size: .75rem; } +.intro-facts > span { display: inline-flex; align-items: baseline; gap: .4rem; } +.intro-facts > span + span { border-left: 1px solid #cbd0c4; padding-left: 1.4rem; } +.intro-facts strong { font-family: var(--mono); font-size: .9375rem; color: var(--green); font-weight: 500; } +.journey-card { background: #edf0e7; border: 1px solid #d9dfd0; border-radius: 13px; padding: 1.4rem 1.5rem; position: relative; overflow: hidden; } +.journey-card::before { content: ""; width: 82px; height: 82px; position: absolute; right: -25px; top: -35px; border: 1px solid #d1d9c7; border-radius: 50%; box-shadow: 0 0 0 12px #e3e9db, 0 0 0 13px #d1d9c7, 0 0 0 26px #e3e9db, 0 0 0 27px #d1d9c7; opacity: .45; pointer-events: none; } +.journey-top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .5rem 1rem; position: relative; } +.journey-top .small-label { color: #60725a; } +.local-badge { font-size: .6875rem; color: #536c4e; background: #e4eadc; padding: .2rem .5rem; border-radius: 4px; } +.journey-progress-heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .4rem 1rem; margin: 1.2rem 0 .7rem; font-size: .8125rem; font-weight: 600; } +.journey-count { color: #7c8976; font-family: var(--mono); font-size: .75rem; white-space: nowrap; } +.journey-count strong { font-size: 1.55rem; font-weight: 500; color: var(--green); } +.progress-track { height: 5px; border-radius: 4px; background: #d8dfcf; overflow: hidden; } +.progress-track > span { display: block; height: 100%; width: 0; background: var(--green); border-radius: inherit; transition: width .2s; } +.journey-caption { color: #62715a; font-size: .6875rem; margin-top: .55rem; } +.continue-button { margin-top: 1.1rem; padding: .85rem 0 0; border-top: 1px solid #d4dbca; width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 1rem; text-align: left; } +.continue-button > span { min-width: 0; } +.continue-button .icon { background: var(--green); color: white; box-sizing: content-box; padding: .6rem; border-radius: 50%; transition: transform .15s; } +.continue-button:hover .icon { transform: translateX(3px); } +.continue-label { display: block; font-size: .6875rem; color: #68795f; margin-bottom: .2rem; } +.continue-button strong { display: block; font-size: .8125rem; font-weight: 650; line-height: 1.5; overflow-wrap: anywhere; } +.workspace-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--line); font-size: .6875rem; } +.workspace-heading > div { display: flex; align-items: center; gap: .75rem; } +.workspace-kicker { font: .625rem/1.5 var(--mono); letter-spacing: .03em; color: var(--muted); } +.workspace-divider { display: block; width: 1px; height: 12px; background: #bec6b9; } +.official-note { display: flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .625rem; } +.workspace-grid { display: grid; grid-template-columns: 228px minmax(0, 1fr) 240px; align-items: start; gap: 1.5rem; padding-top: 1.5rem; } +.curriculum-sidebar { min-width: 0; position: sticky; top: 1.5rem; max-height: calc(100dvh - 3rem); overflow-y: auto; scrollbar-width: thin; scrollbar-color: #c7d0bd transparent; } +.sidebar-heading { display: flex; align-items: center; justify-content: space-between; gap: .7rem; margin-bottom: 1.3rem; } +.sidebar-heading .small-label { font-size: .6875rem; letter-spacing: .12em; color: var(--muted); margin-bottom: .35rem; } +.sidebar-heading h2 { font-size: 1.075rem; line-height: 1.5; letter-spacing: -.025em; } +.version-control { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; gap: .4rem; } +.version-control label { font-size: .75rem; font-weight: 600; word-break: keep-all; } +.version-control select { width: 100%; min-height: 2.5rem; padding: .35rem 1.3rem .35rem .65rem; border: 1px solid #cfd6c7; border-radius: 7px; background: var(--card); color: var(--green); font-size: .75rem; font-weight: 600; max-width: 100%; } +.version-control p { grid-column: 1 / -1; color: var(--muted); font-size: .6875rem; } +.track-filter { display: flex; gap: 3px; padding: 4px; border-radius: 8px; background: #e9ebe2; margin-top: 1rem; } +.track-filter button { min-width: 0; flex: 1; padding: .4rem .15rem; border-radius: 5px; font-size: .6875rem; min-height: 36px; line-height: 1.5; } +.track-filter button[aria-pressed="true"] { background: var(--card); box-shadow: 0 1px 3px #173d3510; color: var(--green); font-weight: 700; } +.search-field { display: flex; align-items: center; gap: .4rem; margin: .75rem 0 1rem; padding: .55rem .65rem; background: #fffefa; border: 1px solid var(--line); border-radius: 8px; color: #7c867b; } +.search-field .icon { width: 15px; height: 15px; flex-basis: 15px; } +.search-field input { width: 100%; font-size: .75rem; color: var(--ink); border: 0; outline: none; background: transparent; padding: .1rem; } +.search-field:focus-within { outline: 2px solid #a56541; outline-offset: 2px; } +.search-field input:focus-visible { outline: none; } +.filtered-progress { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: .4rem; margin: 0 .2rem .7rem; color: var(--muted); font-size: .6875rem; } +.filtered-progress span:last-child { font-family: var(--mono); color: #617957; } +.lesson-list { display: flex; flex-direction: column; gap: .3rem; } +.lesson-link { text-decoration: none; color: var(--ink); display: flex; align-items: flex-start; gap: .7rem; padding: .65rem .7rem; border-radius: 9px; border: 1px solid transparent; min-height: 58px; } +.lesson-link:hover { background: #e9ede4; } +.lesson-link.is-active { background: var(--green); color: white; box-shadow: 0 4px 10px #173d350a; } +.lesson-index { display: inline-flex; justify-content: center; align-items: center; flex: 0 0 23px; min-height: 23px; margin-top: .05rem; color: #8b9588; font: .625rem/1.5 var(--mono); } +.lesson-link.is-active .lesson-index { color: var(--yellow); } +.lesson-index .icon { width: 15px; height: 15px; } +.lesson-link.is-complete:not(.is-active) .lesson-index { color: #466941; background: #e0e8d7; border-radius: 50%; } +.lesson-link-copy { min-width: 0; flex: 1; } +.lesson-link-title { display: block; font-size: .8125rem; font-weight: 550; line-height: 1.6; letter-spacing: -.02em; overflow-wrap: anywhere; } +.lesson-link-meta { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; font-size: .625rem; color: #6c7866; } +.lesson-link.is-active .lesson-link-meta { color: #b2c4b4; } +.lesson-link.is-active .lesson-link-title { font-weight: 650; } +.sidebar-footnote { display: flex; align-items: flex-start; gap: .6rem; border-top: 1px solid var(--line); margin-top: 1rem; padding-top: 1rem; color: var(--muted); } +.sidebar-footnote .icon { flex-basis: 16px; width: 16px; height: 16px; margin-top: 3px; } +.sidebar-footnote p { font-size: .6875rem; line-height: 1.8; } +.text-button { color: var(--muted); padding: .4rem 0; font-size: .6875rem; text-align: left; text-decoration: underline; text-underline-offset: 4px; } +.text-button:hover { color: var(--green); } +.reset-records { margin-top: .7rem; font-size: .6875rem; } +.lesson-card { min-width: 0; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: clip; box-shadow: 0 6px 18px #37493103; } +.lesson-header { padding: 1.9rem 2rem 1.6rem; } +.lesson-topline { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.lesson-topline .eyebrow { font-size: .6875rem; letter-spacing: .08em; } +.lesson-position { font: .5625rem/1.5 var(--mono); color: #8a9385; flex-shrink: 0; } +.lesson-header h2 { font-size: clamp(1.5rem, 2.1vw, 1.8rem); font-weight: 750; line-height: 1.45; letter-spacing: -.04em; margin-top: .6rem; overflow-wrap: anywhere; } +.lesson-description { font-size: .875rem; line-height: 1.8; color: var(--muted); margin-top: .8rem; overflow-wrap: anywhere; } +.lesson-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .55rem; margin-top: 1.15rem; } +.meta-chip { display: inline-flex; align-items: center; gap: .3rem; padding: .18rem .5rem; border: 1px solid #dde3d5; border-radius: 5px; color: #5d6f51; background: #f1f4ed; font-size: .6875rem; line-height: 1.65; } +.meta-chip .icon { width: 12px; height: 12px; flex-basis: 12px; } +.meta-chip.is-neutral { background: transparent; border-color: var(--line); color: var(--muted); } +.meta-chip.is-preview { background: var(--rust-soft); border-color: #e8cbb8; color: var(--rust); } +.achievement-row { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .95rem; } +.achievement-badge { display: inline-flex; align-items: center; gap: .25rem; font-size: .6875rem; color: #677361; } +.achievement-badge::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: #cbd1c5; } +.achievement-badge.is-complete { color: #3b694b; font-weight: 650; } +.achievement-badge.is-complete::before { background: #52775a; } +.achievement-badge.is-past { color: var(--rust); } +.achievement-badge.is-past::before { background: #ba7654; } +.lesson-tabs { display: flex; align-items: stretch; gap: .2rem; padding: 0 1.6rem; border-top: 1px solid #e8eadf; border-bottom: 1px solid #e0e3d8; background: #fafbf5; overflow-x: auto; scrollbar-width: thin; scrollbar-color: #bcc8b5 transparent; } +.lesson-tabs button { position: relative; flex: 1 0 auto; font-size: .8125rem; color: #67765d; padding: .95rem .5rem; border-bottom: 2px solid transparent; white-space: nowrap; } +.lesson-tabs button[aria-selected="true"] { color: var(--green); border-bottom-color: var(--green); font-weight: 750; } +.tab-number { font: .5625rem/1.5 var(--mono); margin-right: .25rem; opacity: .65; } +.lesson-panel { min-height: 330px; padding: 1.9rem 2rem 2rem; overflow-wrap: anywhere; } +.lesson-panel h3 { font-size: 1.0625rem; letter-spacing: -.025em; line-height: 1.6; } +.section-heading { display: flex; align-items: center; gap: .65rem; margin-bottom: .9rem; } +.section-heading .section-number { font: .6875rem/1.5 var(--mono); color: #8da078; } +.section-caption { font-size: .875rem; color: var(--muted); margin: .45rem 0 1.25rem; line-height: 1.85; } +.learning-goals { background: #f1f4ed; border: 1px solid #e0e6d6; border-radius: 10px; padding: 1.1rem 1.15rem; margin-bottom: 1.8rem; } +.learning-goals .small-label { color: #617455; margin-bottom: .55rem; font-size: .6875rem; } +.learning-goals ul { list-style: none; padding: 0; display: grid; gap: .45rem; } +.learning-goals li { display: flex; gap: .55rem; font-size: .875rem; line-height: 1.75; } +.learning-goals .icon { width: 14px; height: 14px; flex-basis: 14px; color: #628055; margin-top: .2rem; } +.theory-section + .theory-section { margin-top: 1.7rem; padding-top: 1.45rem; border-top: 1px solid #e8eadf; } +.theory-section p { color: #536152; font-size: .9375rem; line-height: 1.95; margin-top: .7rem; white-space: pre-line; } +.next-step-row { display: flex; justify-content: flex-end; padding-top: 1.8rem; margin-top: 1.3rem; border-top: 1px solid #e6e9df; } +.button { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; min-height: 44px; padding: .65rem 1rem; border: 1px solid transparent; border-radius: 8px; font-size: .8125rem; font-weight: 650; line-height: 1.5; text-decoration: none; text-align: center; } +.button .icon { width: 16px; height: 16px; flex-basis: 16px; } +.button-primary { background: var(--green); color: #fff; } +.button-primary:hover:not(:disabled) { background: #265448; } +.button-secondary { border-color: #cfd9c8; background: #f7f9f2; color: var(--green); } +.button-secondary:hover:not(:disabled) { background: #eaf0e3; } +.button-quiet { color: var(--green); border-color: #d7ded0; background: #fffefa; } +.button-quiet:hover:not(:disabled) { background: #f0f4e9; } +.button-danger { background: #a34227; color: #fff; } +.button-small { font-size: .625rem; padding: .4rem .7rem; min-height: 34px; } +.code-shell { min-width: 0; overflow: hidden; border: 1px solid #263f34; border-radius: 10px; background: #19372f; margin: 1rem 0; } +.code-heading { display: flex; align-items: center; justify-content: space-between; gap: .7rem; padding: .6rem .95rem; background: #143127; color: #b6c8af; border-bottom: 1px solid #2c4938; font: .625rem/1.6 var(--mono); } +.code-heading > span { display: flex; align-items: center; gap: .4rem; min-width: 0; } +.code-heading .icon { width: 13px; height: 13px; flex-basis: 13px; } +.copy-button { display: inline-flex; align-items: center; gap: .35rem; color: #c1d2b9; padding: .3rem .4rem; border-radius: 4px; font: .5625rem/1.5 var(--font); flex-shrink: 0; } +.copy-button:hover { background: #294736; } +.code-block { padding: 1.2rem; font: .75rem/1.85 var(--mono); color: #e1edd6; white-space: pre; overflow-x: auto; tab-size: 4; scrollbar-width: thin; scrollbar-color: #6f8767 #19372f; } +.code-block code { font: inherit; } +.expected-output { margin: 1.2rem 0; padding: 1rem 1.1rem; border: 1px dashed #cbd6c1; border-radius: 8px; background: #f6f8f1; } +.expected-output .small-label { color: #5d7850; margin-bottom: .6rem; font-size: .5625rem; } +.expected-output pre { font: .75rem/1.75 var(--mono); color: #385731; white-space: pre; overflow-x: auto; } +.explanation { font-size: .9375rem; line-height: 1.9; color: #5b675c; white-space: pre-line; } +.inline-note { color: var(--muted); font-size: .6875rem; line-height: 1.85; margin-top: .75rem; } +.quiz-form { display: grid; gap: 1.4rem; } +.quiz-question { padding: 0; border: 0; min-width: 0; } +.quiz-question legend { font-size: .9375rem; font-weight: 650; line-height: 1.85; padding: 0; margin-bottom: .75rem; width: 100%; } +.question-number { font: .625rem/1.5 var(--mono); color: var(--rust); margin-right: .4rem; } +.quiz-options { display: grid; gap: .45rem; } +.quiz-option { display: flex; align-items: flex-start; gap: .65rem; border: 1px solid #dfe4d7; border-radius: 8px; padding: .75rem .85rem; background: #fffefa; cursor: pointer; font-size: .875rem; line-height: 1.8; } +.quiz-option:hover { border-color: #9dad8f; background: #f8faf3; } +.quiz-option:has(input:checked) { border-color: #5a7856; background: #edf2e7; } +.quiz-option input { width: 15px; height: 15px; flex: 0 0 15px; margin: .22rem 0 0; accent-color: var(--green); } +.option-letter { color: #829077; font-family: var(--mono); font-size: .625rem; flex: 0 0 14px; margin-top: .1rem; } +.quiz-feedback { padding: .85rem 1rem; margin-top: .65rem; border-radius: 8px; background: #eef5e8; color: #426339; font-size: .8125rem; line-height: 1.85; } +.quiz-feedback.is-wrong { background: var(--rust-soft); color: #8f452b; } +.quiz-feedback strong { display: block; margin-bottom: .3rem; font-size: .6875rem; } +.result-summary { background: #edf4e7; border: 1px solid #cfdcc2; border-radius: 10px; padding: 1rem 1.1rem; margin: 1rem 0 1.5rem; font-size: .75rem; } +.result-summary strong { display: block; font-size: .875rem; color: #335834; margin-bottom: .3rem; } +.result-summary.is-incomplete { background: #fcf3ec; border-color: #eed3c0; color: #925336; } +.result-summary.is-incomplete strong { color: #97502e; } +.quiz-actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .3rem; } +.form-error { color: #a13922; font-size: .75rem; } +.scenario-card { border-left: 3px solid #a7603e; background: #faf3ea; padding: 1.15rem; margin: 1.2rem 0 1.4rem; } +.scenario-card .small-label { color: #a55e3e; margin-bottom: .5rem; font-size: .5625rem; } +.scenario-card p { font-size: .9375rem; line-height: 1.9; color: #795b46; white-space: pre-line; } +.practice-tasks { list-style: none; padding: 0; display: grid; gap: .6rem; margin: 1rem 0; } +.practice-tasks li { display: flex; gap: .75rem; padding-bottom: .65rem; border-bottom: 1px solid #e6e9df; font-size: .875rem; line-height: 1.85; } +.practice-tasks .task-index { display: block; flex: 0 0 22px; font: .625rem/1.8 var(--mono); color: #789165; padding-top: .15rem; } +.takeaway { padding: 1rem 1.1rem; background: #eef3e8; border-radius: 8px; margin-top: 1.4rem; font-size: .75rem; line-height: 1.9; color: #506b45; } +.takeaway strong { display: block; color: #3a5f36; font-size: .625rem; margin-bottom: .4rem; } +.design-notes-label { display: block; font-size: .8125rem; font-weight: 650; margin: 1.5rem 0 .4rem; } +.design-notes { display: block; width: 100%; min-height: 150px; resize: vertical; padding: .85rem 1rem; background: #fcfdf8; color: var(--ink); border: 1px solid #d4ddca; border-radius: 8px; font-size: .8125rem; line-height: 1.85; } +.notes-footnote { color: var(--muted); font-size: .625rem; margin-top: .5rem; } +.lab-header { display: flex; flex-wrap: wrap; gap: .6rem; align-items: flex-start; justify-content: space-between; } +.lab-header h3 { flex: 1 1 200px; } +.lab-mode { padding: .25rem .55rem; background: #edf2e6; color: #627951; border-radius: 5px; font: .5625rem/1.7 var(--mono); } +.runtime-card { background: #f1f5ec; border: 1px solid #dce5d3; border-radius: 9px; padding: .85rem 1rem; margin: 1rem 0; } +.runtime-line { display: flex; align-items: center; flex-wrap: wrap; gap: .45rem .9rem; font-size: .6875rem; } +.runtime-line strong { color: #2e593d; } +.runtime-card p { font-size: .75rem; line-height: 1.8; color: #526747; margin-top: .3rem; } +.runtime-card.is-unavailable { background: #faf1e8; border-color: #ebd6c4; } +.runtime-card.is-unavailable p, .runtime-card.is-unavailable strong { color: #8e5535; } +.runtime-retry { font-size: .625rem; padding: .2rem 0; color: #7b4d31; text-decoration: underline; } +.lab-instructions { padding-left: 1.3rem; font-size: .875rem; line-height: 1.9; margin: 1rem 0; color: #596852; } +.lab-instructions li + li { margin-top: .35rem; } +.lab-rubric { margin: 1rem 0; border: 1px solid #dfe5d8; border-radius: 8px; padding: .7rem .9rem; } +.lab-rubric summary { cursor: pointer; font-size: .6875rem; font-weight: 600; color: #5c714e; } +.lab-rubric ul { margin-top: .6rem; font-size: .6875rem; line-height: 1.85; color: #687762; } +.editor-shell { border: 1px solid #284635; border-radius: 10px; overflow: hidden; margin-top: 1.2rem; background: #17342b; } +.editor-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .3rem .7rem; background: #123026; border-bottom: 1px solid #2a4737; padding: .55rem .85rem; color: #b3c9aa; } +.editor-filename { display: inline-flex; align-items: center; gap: .4rem; font: .625rem/1.6 var(--mono); } +.editor-filename .icon { width: 13px; height: 13px; flex-basis: 13px; } +.editor-tools { display: flex; flex-wrap: wrap; gap: .35rem; } +.editor-tools button { padding: .3rem .45rem; font-size: .5625rem; color: #bed0b4; border-radius: 4px; min-height: 32px; } +.editor-tools button:hover { background: #2b4b37; } +.code-editor { display: block; width: 100%; min-height: 305px; height: 365px; max-height: 70vh; resize: vertical; padding: 1.15rem 1.2rem; border: 0; border-radius: 0; background: #17342b; color: #e6eedc; font: .75rem/1.85 var(--mono); letter-spacing: 0; tab-size: 4; white-space: pre; overflow: auto; caret-color: #e2eaaa; scrollbar-width: thin; scrollbar-color: #6b835e #17342b; } +.code-editor:focus-visible { outline-offset: -4px; outline-color: #cbd985; } +.editor-status { display: flex; justify-content: space-between; flex-wrap: wrap; gap: .4rem; padding: .45rem .85rem; color: #acc09e; border-top: 1px solid #2a4737; font: .5625rem/1.6 var(--mono); } +.editor-hint { font-size: .6875rem; color: var(--muted); margin-top: .45rem; line-height: 1.8; } +.reset-undo { padding: .55rem .75rem; margin-top: .6rem; background: #f8efdf; color: #875c38; border-radius: 6px; font-size: .625rem; } +.reset-undo button { font-size: inherit; font-weight: 700; text-decoration: underline; margin-left: .5rem; color: #815334; } +.execution-disclosure { padding: .8rem .95rem; margin: 1.1rem 0 .85rem; border: 1px solid #deddd0; border-radius: 8px; color: #706853; background: #f9f7ee; font-size: .625rem; line-height: 1.85; } +.execution-disclosure a { color: #72623d; } +.lab-actions { display: flex; align-items: center; flex-wrap: wrap; gap: .55rem; } +.lab-actions .execution-state { margin-left: auto; font-size: .625rem; color: var(--muted); } +.execution-panel { margin-top: 1.25rem; background: #f5f7ef; border: 1px solid #dbe2d2; border-radius: 9px; overflow: hidden; } +.execution-panel-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .4rem .8rem; padding: .7rem .9rem; border-bottom: 1px solid #e0e5d8; font-size: .6875rem; } +.execution-panel-heading strong { display: inline-flex; align-items: center; gap: .4rem; color: #426037; } +.execution-panel-heading .icon { width: 14px; height: 14px; flex-basis: 14px; } +.execution-panel-heading > span { font: .5625rem/1.6 var(--mono); color: #79896b; } +.execution-output-body { padding: 1rem; } +.execution-placeholder { color: #526747; font-size: .8125rem; line-height: 1.9; } +.execution-result-label { font-size: .8125rem; font-weight: 700; color: #42683b; margin-bottom: .45rem; } +.execution-result-label.is-error { color: #a04c2e; } +.execution-warning { color: #96552f; background: #fcf0de; border-left: 2px solid #b17135; padding: .6rem .75rem; font-size: .6875rem; line-height: 1.8; margin-bottom: .7rem; } +.output-section { margin: .7rem 0; } +.output-section h4 { margin: 0 0 .3rem; font-size: .5625rem; font-weight: 650; color: #6b7f5b; letter-spacing: .02em; } +.output-section pre { font: .6875rem/1.85 var(--mono); background: #eef2e5; border: 1px solid #dfe5d4; color: #385033; border-radius: 5px; padding: .75rem .8rem; overflow-x: auto; white-space: pre; } +.output-section.is-error pre { background: #fbf0e7; border-color: #e8d8c9; color: #915235; } +.execution-checks { list-style: none; padding: 0; display: grid; gap: .3rem; margin: .8rem 0; } +.execution-checks li { display: flex; gap: .5rem; font-size: .6875rem; color: #527048; line-height: 1.75; } +.execution-checks li.is-failed { color: #965031; } +.execution-checks .check-symbol { font-weight: 700; min-width: 1rem; } +.execution-timing { color: #7e8b71; font-size: .5625rem; margin-top: .75rem; } +.lesson-footer { padding: 1rem 2rem; border-top: 1px solid #e2e6da; display: flex; justify-content: space-between; flex-wrap: wrap; gap: .6rem; background: #fdfef9; } +.lesson-footer button { font-size: .625rem; min-height: 36px; padding: .35rem 0; color: #6c7e5b; text-align: left; } +.lesson-footer button:last-child { margin-left: auto; text-align: right; } +.lesson-footer button strong { display: block; font-size: .6875rem; color: #425e36; font-weight: 650; } +.reference-column { display: flex; flex-direction: column; gap: 1rem; min-width: 0; } +.reference-card { border: 1px solid #dce1d4; background: #f7f8f1; border-radius: 11px; padding: 1.2rem 1.05rem; min-width: 0; } +.reference-card-heading { display: flex; align-items: center; gap: .5rem; } +.reference-card-heading .icon { width: 16px; height: 16px; flex-basis: 16px; color: #527146; } +.reference-card h2 { font-size: .875rem; letter-spacing: -.025em; line-height: 1.55; } +.reference-description { color: var(--muted); font-size: .75rem; margin: .45rem 0 .7rem; line-height: 1.8; } +.source-list { display: flex; flex-direction: column; } +.source-link { display: block; position: relative; border-top: 1px solid #e1e6d6; padding: .85rem 1.2rem .7rem 0; text-decoration: none; overflow-wrap: anywhere; } +.source-link > .icon { position: absolute; top: .9rem; right: 0; width: 12px; height: 12px; color: #879878; } +.source-link strong { display: block; color: #405d35; font-size: .75rem; font-weight: 550; line-height: 1.75; } +.source-link:hover strong { text-decoration: underline; text-underline-offset: 3px; } +.source-link span { display: block; color: #667859; font-size: .625rem; margin-top: .25rem; } +.evidence-card { background: #f5f0e6; border-color: #e4dccb; } +.evidence-card > .small-label { color: #a1774e; font-size: .5rem; margin-bottom: .4rem; } +.evidence-card h2 { color: #6f553a; } +.evidence-card .reference-description { color: #8b7961; } +.evidence-item { padding: .9rem 0; border-top: 1px solid #e5dccc; } +.evidence-company { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; margin-bottom: .35rem; font-size: .6875rem; font-weight: 650; color: #785b3e; } +.evidence-status { font-size: .5rem; font-weight: 400; border: 1px solid #ded0ba; border-radius: 3px; padding: .05rem .25rem; color: #8c7557; } +.evidence-title { display: inline-flex; align-items: flex-start; gap: .4rem; font-size: .6875rem; line-height: 1.75; color: #795f45; text-decoration: none; } +.evidence-title:hover { text-decoration: underline; } +.evidence-title .icon { width: 11px; height: 11px; flex-basis: 11px; margin-top: .25rem; } +.evidence-item p { margin-top: .45rem; color: #7a674f; font-size: .6875rem; line-height: 1.9; } +.technology-tags { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .6rem; } +.technology-tags span { padding: .12rem .35rem; border-radius: 3px; background: #eee5d5; color: #775832; font-size: .625rem; } +.evidence-item .evidence-date { font-size: .625rem; color: #7c6345; margin-top: .6rem; } +.evidence-disclaimer { font-size: .625rem; color: #7c6345; border-top: 1px solid #e2d8c5; padding-top: .8rem; line-height: 1.9; } +.next-chapter { padding: .7rem .3rem; } +.next-chapter .small-label { font-size: .5rem; color: #8b957f; } +.next-chapter h3 { margin-top: .4rem; font-size: .75rem; color: #637558; font-weight: 600; } +.next-chapter p { font-size: .625rem; color: #88957b; margin-top: .2rem; } +.planned-badge { display: inline-block; margin-top: .65rem; padding: .12rem .4rem; font-size: .5rem; color: #7e8c70; border: 1px dashed #c6d0b9; border-radius: 3px; } +.empty-state { padding: 2.5rem 1rem; text-align: center; color: var(--muted); } +.empty-state .icon { width: 28px; height: 28px; color: #8da078; margin-bottom: 1rem; } +.empty-state h3 { font-size: 1rem; color: #53674a; } +.empty-state p { font-size: .75rem; margin-top: .6rem; line-height: 1.9; } +.empty-state button { margin-top: 1rem; } +.sidebar-empty { padding: 1.2rem .4rem; color: #7d8c6e; font-size: .6875rem; line-height: 1.9; } +.site-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem 1.5rem; padding: 1.8rem 0 2rem; margin-top: 2.5rem; border-top: 1px solid var(--line); color: #859078; } +.footer-brand { font-size: 1rem; font-weight: 700; letter-spacing: -.06em; color: #718066; } +.site-footer p { font-size: .625rem; } +.site-footer button { margin-left: auto; font-size: .5625rem; color: #7c8b6d; } +.loading-state { border: 1px solid var(--line); border-radius: 12px; padding: 4rem 1rem; color: var(--muted); text-align: center; margin-top: 1.5rem; } +.loading-state.is-error { color: #935034; background: #fbf2e8; } +.drawer-backdrop { position: fixed; inset: 0; background: #10261ab3; z-index: 90; backdrop-filter: blur(2px); } +.toast { position: fixed; z-index: 150; left: 50%; bottom: 1.5rem; transform: translateX(-50%) translateY(20px); background: #1d3d2f; color: white; border: 1px solid #466341; padding: .75rem 1.15rem; border-radius: 10px; max-width: calc(100% - 2rem); width: max-content; font-size: .75rem; box-shadow: 0 8px 28px #112b2430; pointer-events: none; opacity: 0; transition: opacity .18s, transform .18s; } +.toast.is-visible { opacity: 1; transform: translateX(-50%) translateY(0); } +.app-dialog { width: min(580px, calc(100% - 2rem)); max-height: calc(100dvh - 2rem); border: 1px solid #cbd7be; border-radius: 14px; padding: 1.6rem 1.8rem; color: var(--ink); background: var(--card); box-shadow: 0 20px 70px #162f2040; } +.app-dialog::backdrop { background: #132c208c; backdrop-filter: blur(2px); } +.app-dialog h2 { font-size: 1.35rem; line-height: 1.6; letter-spacing: -.035em; margin-bottom: .75rem; } +.app-dialog p, .app-dialog li { font-size: .8125rem; line-height: 1.85; color: #5c6d52; } +.app-dialog ul { margin: 1.1rem 0; } +.app-dialog li + li { margin-top: .5rem; } +.app-dialog .dialog-note { padding: .85rem 1rem; background: #eff3e8; border-radius: 8px; font-size: .6875rem; margin-bottom: 1.2rem; } +.dialog-heading { display: flex; justify-content: space-between; align-items: center; gap: .7rem; margin-bottom: .6rem; } +.dialog-heading .small-label, .app-dialog > .small-label { font-size: .5625rem; color: #8b7955; } +.compact-dialog { max-width: 460px; } +.compact-dialog > .small-label { margin-bottom: .65rem; } +.dialog-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: .6rem; margin-top: 1.5rem; } +.noscript-note { margin: 2rem; padding: 1rem; border: 1px solid #cba58c; background: #faf0e5; } + +@media (min-width: 1600px) { + .workspace-grid { grid-template-columns: 248px minmax(0, 1fr) 252px; gap: 2rem; } +} +@media (max-width: 1250px) { + .site-header { padding-right: 2rem; padding-left: 2rem; } + .page-shell { padding: 0 2rem; } + .workspace-grid { grid-template-columns: 215px minmax(0, 1fr); gap: 1.4rem; } + .curriculum-sidebar { grid-row: 1 / span 2; } + .reference-column { grid-column: 2; display: grid; grid-template-columns: 1fr 1fr; align-items: start; } + .next-chapter { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: .7rem 1.2rem; } + .next-chapter h3, .next-chapter p, .next-chapter .planned-badge { margin: 0; } + .intro { gap: 2rem; } +} +@media (max-width: 899px) { + .site-header { min-height: 75px; padding: .9rem 1.5rem; gap: 2rem; } + .header-navigation { gap: 1.3rem; } + .storage-indicator { font-size: .625rem; } + .page-shell { padding: 0 1.5rem; } + .intro { grid-template-columns: minmax(0, 1fr) 290px; padding: 2.5rem 0 2rem; gap: 1.5rem; } + .intro h1 { font-size: 2.1rem; } + .intro-description { font-size: .8125rem; } + .intro-facts { gap: .8rem; font-size: .625rem; } + .intro-facts > span + span { padding-left: .8rem; } + .journey-card { padding: 1.2rem; } + .workspace-grid { grid-template-columns: minmax(0, 1fr); } + .curriculum-sidebar { display: none; position: fixed; top: 0; bottom: 0; left: 0; width: min(340px, calc(100% - 2rem)); max-height: none; padding: 1.25rem 1.4rem 2rem; background: var(--paper); overflow-y: auto; overscroll-behavior: contain; z-index: 100; } + .curriculum-sidebar.is-open { display: block; } + .mobile-menu-toggle, .mobile-menu-close { display: inline-flex; } + .mobile-menu-toggle { font-size: .75rem; padding-right: 0; } + .reference-column { grid-column: 1; } + .lesson-link { min-height: 58px; } + .lesson-link-title { font-size: .8125rem; } + .sidebar-heading h2 { font-size: 1.25rem; } + .version-control label { font-size: .8125rem; } + .version-control select { font-size: .8125rem; min-height: 40px; } + .version-control p, .filtered-progress { font-size: .6875rem; } + .track-filter button { min-height: 40px; font-size: .75rem; } + .search-field input { font-size: .8125rem; } + .search-field { min-height: 43px; } + .lesson-link-meta, .sidebar-footnote p, .reset-records { font-size: .6875rem; } + body.menu-open { overflow: hidden; } +} +@media (max-width: 650px) { + .site-header { padding: .7rem 1.1rem; flex-wrap: wrap; gap: .5rem 1rem; min-height: 68px; } + .brand { font-size: 1.4rem; gap: .5rem; } + .brand-mark { width: 1.9375rem; height: 2.0625rem; font-size: 1.65rem; border-radius: .4375rem; } + .header-navigation { margin-left: auto; } + .header-navigation > a { display: none; } + .header-link { font-size: .75rem; } + .header-tools { margin-left: 0; } + .storage-indicator { display: none; } + .page-shell { padding: 0 1.1rem; } + .intro { grid-template-columns: minmax(0, 1fr); padding-top: 2rem; padding-bottom: 1.6rem; gap: 1.5rem; } + .intro .eyebrow { font-size: .5625rem; } + .intro h1 { font-size: 2.15rem; margin-top: .75rem; } + .intro-description { font-size: .8125rem; margin-top: .8rem; } + .intro-facts { margin-top: 1rem; font-size: .6875rem; } + .journey-card { padding: 1rem 1.15rem; } + .journey-progress-heading { margin-top: .7rem; } + .continue-button { margin-top: .75rem; padding-top: .75rem; } + .continue-button .icon { padding: .5rem; } + .journey-top .small-label { font-size: .5625rem; } + .journey-caption { font-size: .625rem; } + .continue-label { font-size: .625rem; } + .continue-button strong { font-size: .8125rem; } + .workspace-heading { padding-bottom: .75rem; font-size: .625rem; } + .workspace-kicker { font-size: .5rem; } + .official-note { font-size: .5rem; gap: .3rem; } + .workspace-heading > div { gap: .5rem; } + .workspace-grid { padding-top: 1rem; gap: 1rem; } + .lesson-header { padding: 1.45rem 1.25rem 1.2rem; } + .lesson-header h2 { font-size: 1.5rem; } + .lesson-description { font-size: .875rem; } + .lesson-meta { margin-top: .9rem; } + .lesson-tabs { padding: 0 .8rem; gap: .15rem; } + .lesson-tabs button { padding: .9rem .5rem; font-size: .75rem; } + .tab-number { display: none; } + .lesson-panel { padding: 1.35rem 1.25rem 1.5rem; } + .lesson-panel h3 { font-size: 1rem; } + .learning-goals { padding: .9rem; margin-bottom: 1.4rem; } + .learning-goals li { font-size: .875rem; } + .theory-section p { font-size: .9375rem; } + .code-block { padding: 1rem; font-size: .6875rem; } + .lesson-footer { padding: .75rem 1.25rem; } + .reference-column { display: flex; } + .reference-card { padding: 1.2rem 1.25rem; } + .reference-card h2 { font-size: .9375rem; } + .reference-description { font-size: .75rem; } + .source-link strong, .evidence-title { font-size: .8125rem; } + .source-link span, .evidence-disclaimer, .evidence-item .evidence-date { font-size: .625rem; } + .evidence-company { font-size: .8125rem; } + .evidence-item p, .technology-tags span, .evidence-status { font-size: .6875rem; } + .evidence-card > .small-label { font-size: .5625rem; } + .next-chapter { display: block; padding: .75rem .25rem; } + .next-chapter h3, .next-chapter p { margin-top: .3rem; } + .next-chapter .planned-badge { margin-top: .65rem; } + .site-footer { margin-top: 1.5rem; padding-top: 1.3rem; gap: .5rem 1rem; } + .site-footer button { margin-left: 0; flex-basis: 100%; font-size: .625rem; } + .lab-header h3 { flex-basis: 100%; } + .lab-mode { font-size: .625rem; } + .runtime-line { font-size: .6875rem; } + .runtime-card p { font-size: .75rem; } + .code-editor { padding: 1rem; height: 330px; font-size: .75rem; } + .editor-toolbar { padding: .45rem .65rem; } + .editor-tools button { font-size: .625rem; } + .editor-filename { font-size: .6875rem; } + .editor-status { font-size: .5625rem; padding: .45rem .65rem; } + .execution-disclosure { font-size: .625rem; } + .lab-actions .button { flex: 1 1 auto; padding: .65rem .7rem; font-size: .6875rem; } + .lab-actions .execution-state { margin-left: 0; flex-basis: 100%; font-size: .625rem; } + .execution-output-body { padding: .85rem; } + .app-dialog { padding: 1.3rem; } + .app-dialog h2 { font-size: 1.2rem; } + .dialog-actions .button { flex: 1; } +} +@media (max-width: 359px) { + .page-shell { padding: 0 .75rem; } + .site-header { padding-right: .75rem; padding-left: .75rem; gap: .7rem; } + .intro h1 { font-size: 1.9rem; } + .intro-description { font-size: .75rem; } + .intro-facts { gap: .65rem; } + .intro-facts > span + span { padding-left: .65rem; } + .official-note { display: none; } + .lesson-header { padding: 1.2rem 1rem 1rem; } + .lesson-panel { padding: 1.2rem 1rem; } + .lesson-tabs { padding: 0 .55rem; } + .lesson-tabs button { padding-right: .5rem; padding-left: .5rem; } + .lesson-header h2 { font-size: 1.35rem; } + .button { padding-right: .8rem; padding-left: .8rem; } + .quiz-option { padding: .65rem .7rem; gap: .5rem; } + .option-letter { display: none; } + .lesson-footer { padding-right: 1rem; padding-left: 1rem; } +} +@media (max-height: 600px) { + .curriculum-sidebar { top: .75rem; } + .code-editor { min-height: 180px; max-height: 70vh; } + .app-dialog { max-height: calc(100dvh - 1rem); } +} +@media (max-width: 899px) and (max-height: 600px) { + .curriculum-sidebar { top: 0; } +} +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } +} +@media print { + body { background: white; font-size: 12pt; } + .site-header, .intro, .curriculum-sidebar, .lesson-tabs, .site-footer, .next-step-row, .lesson-footer, .toast { display: none !important; } + .page-shell { padding: 0; } + .workspace-grid { display: block; } + .lesson-card { border: 0; box-shadow: none; } + .reference-column { display: block; margin-top: 1rem; } + .code-block, .code-editor { white-space: pre-wrap; color: black; background: white; } +} diff --git a/docs/curriculum.md b/docs/curriculum.md new file mode 100644 index 0000000..5062389 --- /dev/null +++ b/docs/curriculum.md @@ -0,0 +1,92 @@ +# Java 공통 코어: 교재와 실습 계약 + +기준일: 2026-10-12. 대상은 다른 언어로 프로그래밍 경험이 있는 Java 전환 학습자와 현직 Java 개발자다. 이 문서는 현재 작성한 12개 시작 수업의 범위와 편집·평가 기준을 설명한다. 완전한 Java·Spring 실무 교육과정의 완성 선언이 아니다. 전체 16단계 교육 로드맵과 제품 기획은 별도 문서를 따른다. + +## 학습 흐름 + +각 수업은 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩의 다섯 단계로 구성한다. 실행 가능한 IDE는 첫 공개 MVP의 필수 기능이다. 수업 데이터와 채점 데이터가 준비되었다는 사실은 실행 서버·브라우저·격리 환경의 통합 검증 완료를 뜻하지 않는다. + +전환 학습자는 값 전달과 객체 모델에서 시작하여 OOP·타입 안전성·실패 처리를 공통 코어로 학습한다. 현직자는 코드 해석·수정·설계 설명의 진단으로 통과한 단원을 건너뛰고 부족한 코어와 버전 심화를 연결한다. 경력·객관식 점수 하나만으로 단원을 면제하지 않는다. + +권장 선수 관계는 다음과 같다. + +- Java 객체·타입 모델 → 책임·불변식 → 다형성·계약·의존 관계. +- 객체 모델 → 컬렉션의 동등성·변경 계약 → 제네릭 API 경계. +- 객체·컬렉션 → 예외·자원·Stream → 테스트 설계. +- 가변 상태·실패 처리·테스트 → JMM·동시성. +- 공통 코어 → Java 11 API → Java 17 결과 모델 → Java 21 패턴·가상 스레드. + +테스트 설계는 데이터 목록의 표시 위치와 관계없이 동시성 심화 전에 학습하거나 진단으로 통과하는 것을 권장한다. 학습 시간은 난이도와 경험에 따른 참고치이며 고정 수료 기간이 아니다. + +## 현재 수업과 직접 코딩 과제 + +| 수업 ID | 학습 초점 | 예제 대상 | 과제 형태 | 주요 완료 증거 | +| --- | --- | --- | --- | --- | +| java-basics | 값 전달·참조·boxing·내용 동등성 | 8 | 메서드 본문 | null을 포함한 문자열 동등성 계약 | +| oop | 책임·불변식·캡슐화·실패 후 상태 | 8 | 클래스 골격 | 유효한 예약과 실패 후 재고 보존 | +| polymorphism | LSP·상속/합성·의존성·수동 DI | 8 | 클래스 골격 | 정책 교체와 할인 범위·호출 계약 | +| collections | 순서·중복·equals/hashCode·복사 | 8 | 메서드 본문 | 최초 순서·내용 동등성·입력 보존 | +| generics | 불공변성·PECS·타입 소거 | 8 | 메서드 본문 | 안전한 source/target 타입과 복사 | +| exceptions | 실패 책임·원인·자원 소유권 | 8 | 메서드 본문 | 유효 범위·거절·cause 보존 | +| streams | 지연 연산·최종 연산·부수 효과 | 8 | 메서드 본문 | 조건 집계·입력 보존·설계 설명 | +| concurrency | JMM·원자성·완료 대기·종료 | 8 | 클래스 골격 | 반환 계약과 별도 동시성 설계 검토 | +| modern-apis | String·HttpClient·API Since | 11 | 메서드 본문 | 줄·유니코드 공백·null 입력 처리 | +| records-sealed | 값 모델·닫힌 결과 타입 | 17 | 클래스 골격 | 성공·거절 이유와 record 값 동등성 | +| java21 | record pattern·when·가상 스레드 | 21 | 메서드 본문 | 타입·가드·null 분기 | +| testing-design | 계약·경계·테스트 판별력 | 8 | 메서드 본문 | 정상 정책 수용·잘못된 정책 거절 | + +예제는 독립적인 `Main.java`이며 외부 라이브러리 의존성이 없다. 웹 실습은 전체 `class Solution`을 편집한다. 메서드 본문형에도 클래스·메서드 시그니처를 제공하지만 이 단계의 에디터는 수정 범위를 강제 잠그지 않는다. 클래스 골격형은 외부 호출 계약을 문제 설명에 명시하며 내부 필드와 협력 구조는 학습자가 설계한다. + +제네릭 복사 과제는 source와 target이 저장소를 공유하는 뷰·래퍼 관계가 없고, target이 source의 모든 요소를 수용할 수 있다는 전제에서 수행한다. 서로 다른 객체라는 조건만으로 구조 변경의 독립성이 보장되지는 않는다. [Java SE 8 List.subList 계약](https://docs.oracle.com/javase/8/docs/api/java/util/List.html#subList-int-int-)에서 원본과 뷰의 관계를 확인한다. + +## 평가를 구분하는 원칙 + +1. **컴파일·실행 성공**: 코드가 대상 버전으로 컴파일되고 표시된 JVM에서 실행되었다. +2. **행동 검증 통과**: 명시한 자동 테스트에서 기대 행동이 확인되었다. +3. **설계 검토**: 책임·캡슐화·의존 관계·타입 사용·동시성 근거를 설명 루브릭으로 검토한다. + +자동 테스트 통과만으로 객체 설계나 동시성 숙련을 인증하지 않는다. 예를 들어 concurrency 과제에서 단순히 workers × iterations를 반환하면 일부 행동 검사를 통과할 수 있다. 실제 작업 제출·원자 연산·완료 대기·실행기 종료는 설계 검토 대상임을 공개한다. 제네릭의 raw type 우회, Stream을 사용하라는 학습 요구도 동작 테스트만으로 완전히 판단할 수 없다. + +실행 결과와 설계 검토 대기를 화면에서 구분해야 한다. 설명란에 글자를 입력했다는 사실만으로 설계 평가를 통과 처리하지 않는다. 실패 피드백은 컴파일 오류, 실행 오류, 기대 행동 불일치, 환경·지원 버전 문제를 구분한다. 학습자는 코드를 보존하고 재시도할 수 있어야 한다. + +객관식은 수업당 두 문항이며 정답과 해설을 함께 제공한다. 코드 실습은 공개 샘플 검사와 추가 행동 검사로 구성한다. 검사 대상은 명시된 입력 계약이며, 문서에 없는 조건으로 학습자를 실패시키지 않는다. 공개 저장소의 기준 답안과 검사 파일을 비밀 평가 장치라고 표현하지 않는다. + +## 공식 근거와 버전 편집 + +- 언어 의미는 해당 Java SE의 JLS, API는 API Specification의 계약·Since, 실행 모델은 공식 가이드를 우선한다. +- sources에는 title, url, edition, checkedAt을 기록한다. checkedAt은 자료 확인일이며 기능 출시일이 아니다. +- minVersion은 8·11·17·21 중 이 수업 예제와 실습을 지원하는 최소 대상이다. +- featureSince는 대표 기능의 최초 도입 버전이다. 서로 다른 도입 시점의 공통 개념을 묶은 경우 null로 두고 본문에 개별 기능의 버전을 적는다. +- 제네릭은 5, 지역 변수 var는 10, 람다 매개변수 var는 11, switch 표현식은 14, text block은 15, record와 instanceof 패턴은 16, sealed는 17, record pattern·pattern switch는 21에서 정식 도입되었다. +- Java 8 예제에 List.of·Stream.toList·Optional.isEmpty를 섞지 않는다. Java 17에 preview pattern switch를 섞지 않는다. +- 모든 현재 수업은 preview: false다. record의 깊은 불변성, volatile의 복합 연산 원자성, 가상 스레드의 자동 CPU 가속을 주장하지 않는다. +- 설계 원칙·LSP·SOLID의 적용 판단은 교육적 해석이다. Java 명세가 특정 설계 패턴을 의무화한다고 설명하지 않는다. + +원문을 그대로 옮기는 방식보다 한국어 설명·자체 예제·반례를 작성하고 근거 문서를 연결한다. 예제의 출력이 바뀌면 교재 코드, 독립 Main.java, expected-output.json을 함께 수정한다. 과제 계약이 바뀌면 공개 metadata, trusted 검사, 기준 답안을 함께 갱신한다. + +## 실무 연결 범위 + +현재 12개 실행 과제는 순수 Java이다. 수동 의존성 주입을 Spring 실행으로, 직접 AssertionError를 던지는 검사를 JUnit 실행으로 표시하지 않는다. Spring·Lombok·MyBatis·JPA·OAuth 2.0·OIDC·Redis·Kafka 등은 전체 로드맵에서 의존성·DB·HTTP 통합 실행 능력이 준비된 단원으로 확장한다. + +jobTags는 교과와 공고 근거를 연결하기 위한 분류다. 태그가 있다는 사실만으로 해당 수업 내용을 특정 기업이 요구했거나 출제했다는 뜻은 아니다. 실제 공고 관찰은 별도 job-evidence 자료의 URL·확인일·요구/우대·접근 한계를 따른다. 교육상 필요한 보완 기술과 공고 본문에서 실제 확인한 기술을 구분한다. + +## 작성 산출물과 직접 검증 + +- dist/data/curriculum.js: 12수업, 이론 36항목, 객관식 24문항, 독립 예제·실무 과제·공식 출처. +- dist/data/exercises.js: 공개 실습 12개, 과제 계약·초기 코드·샘플 기대 결과·설명 루브릭. +- server/exercises.mjs: 클라이언트 입력과 분리된 trusted 검사 73개와 샘플 검사. +- examples/java8·java11·java17·java21: 독립 Main.java 12개. +- examples/expected-output.json: 독립 예제의 대상 버전·mainClass·기대 stdout. +- examples/solutions.json: 과제 ID별 기준 Solution 코드 12개. + +작성 시 데이터 연결·예제 동일성 검사와 설치된 JDK의 컴파일·출력 비교를 수행한다. 실제 실행한 환경·대상·검사 수는 통합 검증 기록을 따른다. JDK 21에서 --release 8로 컴파일한 결과와 실제 JDK 8에서 실행한 결과는 다른 검증으로 기록한다. 브라우저 IDE 통합·실행 격리·접근성 검수는 별도 담당 결과를 확인한 뒤 공개 준비 여부를 판정한다. + +2026-10-12 교육 데이터 작성자의 직접 검사 결과: + +- JDK 8u265: Java 8 독립 예제 9개 컴파일·실행·기대 stdout 비교 통과. +- JDK 8u265: 독립 검토 수정 후 Java 8 기준 답안 9개를 다시 컴파일·실행하고 행동 검사 56개 통과. +- 알려진 결함 7종(참조 비교, 0 예약 허용, 정책 중복 호출, 순서 미보장 Set, cause 누락, 음수 합산, 기대값 검사 생략)을 넣은 변형 답안은 모두 관련 검사에서 실패함을 확인. +- 독립 검토에서 발견한 testing-design의 0·30001 검사 누락을 보완했다. 0만 생략한 답안, 30001만 생략한 답안, 둘 다 생략한 답안은 이전 6개 검사에서 통과했지만 수정된 8개 검사에서 모두 거절됨을 실제 실행으로 확인했다. +- 12수업·24퀴즈·12과제의 ID 연결, 필수 필드, 대상 버전, 교재 예제와 독립 파일 동일성 검사 통과. +- Java 11·17·21 예제와 답안은 이 로컬 JDK 8 검사에서 실행하지 않았다. 이후 통합 담당자의 실행 환경 검증과 구분한다. +- 위 결과는 웹 IDE 실행 서버·브라우저·격리 경계·설계 설명 평가의 통합 통과를 의미하지 않는다. diff --git a/docs/milestones.md b/docs/milestones.md index f989bde..1ba9a0a 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -4,7 +4,7 @@ | ID | 범위 | 완료 게이트 | 현재 상태 | | --- | --- | --- | --- | -| M1 | 기획·교육 로드맵·버전/실행/협업 계약 | 독립 문서 리뷰·형식/링크/범위 확인·커밋·원격 ref 확인 | 독립 문서 리뷰 PASS·커밋/푸시 진행 | +| M1 | 기획·교육 로드맵·버전/실행/협업 계약 | 독립 문서 리뷰·형식/링크/범위 확인·커밋·원격 ref 확인 | 완료: `796b4cf` 원격 반영 | | M2 | Java 코어12수업·과제·UI·진도·출처·CI | 콘텐츠 정합성·예제/답안·UI mock 검사·독립 리뷰·커밋/푸시 | 구현 중 | | M3 | 실제 JVM21 외부 실행·샘플/제출 API·Worker 패키징 | API 단위 검사·실제 코드 실행·오류/버전 계약·독립 리뷰·커밋/푸시 | 구현 중 | | M4 | 프로토타입 통합·배포 | 반응형·키보드·진도·오래된 응답 무효화·합성 화면·CI 확인·출시 커밋/푸시·배포 성공 | 대기 | diff --git a/examples/expected-output.json b/examples/expected-output.json new file mode 100644 index 0000000..aab2e6d --- /dev/null +++ b/examples/expected-output.json @@ -0,0 +1,62 @@ +{ + "java8/java-basics/Main.java": { + "minVersion": 8, + "mainClass": "Main", + "stdout": "5\n15\nfalse\ntrue\n" + }, + "java8/oop/Main.java": { + "minVersion": 8, + "mainClass": "Main", + "stdout": "true\nfalse\n60\n" + }, + "java8/polymorphism/Main.java": { + "minVersion": 8, + "mainClass": "Main", + "stdout": "[stock]\nreport:stock\n" + }, + "java8/collections/Main.java": { + "minVersion": 8, + "mainClass": "Main", + "stdout": "2\njava\nother\n" + }, + "java8/generics/Main.java": { + "minVersion": 8, + "mainClass": "Main", + "stdout": "6.0\n2.0\n" + }, + "java8/exceptions/Main.java": { + "minVersion": 8, + "mainClass": "Main", + "stdout": "12\nNumberFormatException\n" + }, + "java8/streams/Main.java": { + "minVersion": 8, + "mainClass": "Main", + "stdout": "[JAVA, JVM]\n4\n" + }, + "java8/concurrency/Main.java": { + "minVersion": 8, + "mainClass": "Main", + "stdout": "2000\n" + }, + "java11/modern-apis/Main.java": { + "minVersion": 11, + "mainClass": "Main", + "stdout": "2\n3\n" + }, + "java17/records-sealed/Main.java": { + "minVersion": 17, + "mainClass": "Main", + "stdout": "true\nFailure[reason=positive-required]\n" + }, + "java21/java21/Main.java": { + "minVersion": 21, + "mainClass": "Main", + "stdout": "sync:2\nmissing\n" + }, + "java8/testing-design/Main.java": { + "minVersion": 8, + "mainClass": "Main", + "stdout": "4 contract checks passed\n" + } +} diff --git a/examples/java11/modern-apis/Main.java b/examples/java11/modern-apis/Main.java new file mode 100644 index 0000000..86ffc79 --- /dev/null +++ b/examples/java11/modern-apis/Main.java @@ -0,0 +1,15 @@ +class Main { + public static void main(String[] args) { + String text = " build \n\n test "; + long meaningfulLines = text.lines() + .map(String::strip) + .filter(line -> !line.isBlank()) + .count(); + java.net.http.HttpClient client = + java.net.http.HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(3)) + .build(); + System.out.println(meaningfulLines); + System.out.println(client.connectTimeout().get().getSeconds()); + } +} diff --git a/examples/java17/records-sealed/Main.java b/examples/java17/records-sealed/Main.java new file mode 100644 index 0000000..a40249b --- /dev/null +++ b/examples/java17/records-sealed/Main.java @@ -0,0 +1,17 @@ +class Main { + sealed interface Result permits Success, Failure {} + record Success(int value) implements Result {} + record Failure(String reason) implements Result {} + + static Result doublePositive(int value) { + if (value <= 0) { + return new Failure("positive-required"); + } + return new Success(Math.multiplyExact(value, 2)); + } + + public static void main(String[] args) { + System.out.println(doublePositive(3).equals(new Success(6))); + System.out.println(doublePositive(0)); + } +} diff --git a/examples/java21/java21/Main.java b/examples/java21/java21/Main.java new file mode 100644 index 0000000..6d4c307 --- /dev/null +++ b/examples/java21/java21/Main.java @@ -0,0 +1,23 @@ +class Main { + record Job(String name, int attempts) {} + + static String label(Object value) { + return switch (value) { + case Job(String name, int attempts) when attempts > 0 -> + name + ":" + attempts; + case Job job -> "not-started"; + case null -> "missing"; + default -> "unknown"; + }; + } + + public static void main(String[] args) throws Exception { + try (java.util.concurrent.ExecutorService executor = + java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { + java.util.concurrent.Future result = + executor.submit(() -> label(new Job("sync", 2))); + System.out.println(result.get()); + System.out.println(label(null)); + } + } +} diff --git a/examples/java8/collections/Main.java b/examples/java8/collections/Main.java new file mode 100644 index 0000000..fb12124 --- /dev/null +++ b/examples/java8/collections/Main.java @@ -0,0 +1,18 @@ +class Main { + public static void main(String[] args) { + java.util.List tags = + java.util.Arrays.asList("java", "sql", "java"); + java.util.Map counts = + new java.util.LinkedHashMap(); + for (String tag : tags) { + counts.put(tag, counts.getOrDefault(tag, 0) + 1); + } + java.util.List snapshot = + java.util.Collections.unmodifiableList( + new java.util.ArrayList(tags)); + tags.set(0, "other"); + System.out.println(counts.get("java")); + System.out.println(snapshot.get(0)); + System.out.println(tags.get(0)); + } +} diff --git a/examples/java8/concurrency/Main.java b/examples/java8/concurrency/Main.java new file mode 100644 index 0000000..70ca242 --- /dev/null +++ b/examples/java8/concurrency/Main.java @@ -0,0 +1,25 @@ +class Main { + public static void main(String[] args) throws Exception { + java.util.concurrent.ExecutorService executor = + java.util.concurrent.Executors.newFixedThreadPool(2); + java.util.concurrent.atomic.AtomicInteger total = + new java.util.concurrent.atomic.AtomicInteger(); + try { + java.util.concurrent.Future first = executor.submit(() -> { + for (int index = 0; index < 1000; index++) { + total.incrementAndGet(); + } + }); + java.util.concurrent.Future second = executor.submit(() -> { + for (int index = 0; index < 1000; index++) { + total.incrementAndGet(); + } + }); + first.get(); + second.get(); + System.out.println(total.get()); + } finally { + executor.shutdownNow(); + } + } +} diff --git a/examples/java8/exceptions/Main.java b/examples/java8/exceptions/Main.java new file mode 100644 index 0000000..3563ef9 --- /dev/null +++ b/examples/java8/exceptions/Main.java @@ -0,0 +1,25 @@ +class Main { + static int readQuantity(String text) throws java.io.IOException { + try (java.io.BufferedReader reader = + new java.io.BufferedReader(new java.io.StringReader(text))) { + String line = reader.readLine(); + if (line == null) { + throw new IllegalArgumentException("quantity is required"); + } + try { + return Integer.parseInt(line); + } catch (NumberFormatException cause) { + throw new IllegalArgumentException("invalid quantity", cause); + } + } + } + + public static void main(String[] args) throws java.io.IOException { + System.out.println(readQuantity("12")); + try { + readQuantity("many"); + } catch (IllegalArgumentException failure) { + System.out.println(failure.getCause().getClass().getSimpleName()); + } + } +} diff --git a/examples/java8/generics/Main.java b/examples/java8/generics/Main.java new file mode 100644 index 0000000..f5fb7ad --- /dev/null +++ b/examples/java8/generics/Main.java @@ -0,0 +1,14 @@ +class Main { + static double sum(java.util.List values) { + double total = 0; + for (Number value : values) { + total += value.doubleValue(); + } + return total; + } + + public static void main(String[] args) { + System.out.println(sum(java.util.Arrays.asList(1, 2, 3))); + System.out.println(sum(java.util.Arrays.asList(0.5, 1.5))); + } +} diff --git a/examples/java8/java-basics/Main.java b/examples/java8/java-basics/Main.java new file mode 100644 index 0000000..a6c50c1 --- /dev/null +++ b/examples/java8/java-basics/Main.java @@ -0,0 +1,28 @@ +class Main { + static class Box { + int value; + Box(int value) { this.value = value; } + } + + static void replaceNumber(int value) { + value = 99; + } + + static void update(Box box) { + box.value += 10; + box = new Box(99); + } + + public static void main(String[] args) { + int number = 5; + Box box = new Box(5); + replaceNumber(number); + update(box); + String left = new String("java"); + String right = new String("java"); + System.out.println(number); + System.out.println(box.value); + System.out.println(left == right); + System.out.println(left.equals(right)); + } +} diff --git a/examples/java8/oop/Main.java b/examples/java8/oop/Main.java new file mode 100644 index 0000000..59309dc --- /dev/null +++ b/examples/java8/oop/Main.java @@ -0,0 +1,29 @@ +class Main { + static final class Credit { + private int balance; + + Credit(int initialBalance) { + if (initialBalance < 0) { + throw new IllegalArgumentException("negative balance"); + } + balance = initialBalance; + } + + boolean spend(int amount) { + if (amount <= 0 || amount > balance) { + return false; + } + balance -= amount; + return true; + } + + int balance() { return balance; } + } + + public static void main(String[] args) { + Credit credit = new Credit(100); + System.out.println(credit.spend(40)); + System.out.println(credit.spend(70)); + System.out.println(credit.balance()); + } +} diff --git a/examples/java8/polymorphism/Main.java b/examples/java8/polymorphism/Main.java new file mode 100644 index 0000000..6369933 --- /dev/null +++ b/examples/java8/polymorphism/Main.java @@ -0,0 +1,24 @@ +class Main { + interface Formatter { + String format(String value); + } + + static final class Report { + private final Formatter formatter; + + Report(Formatter formatter) { + this.formatter = java.util.Objects.requireNonNull(formatter); + } + + String render(String value) { + return formatter.format(value); + } + } + + public static void main(String[] args) { + Report bracketed = new Report(value -> "[" + value + "]"); + Report prefixed = new Report(value -> "report:" + value); + System.out.println(bracketed.render("stock")); + System.out.println(prefixed.render("stock")); + } +} diff --git a/examples/java8/streams/Main.java b/examples/java8/streams/Main.java new file mode 100644 index 0000000..038a9f4 --- /dev/null +++ b/examples/java8/streams/Main.java @@ -0,0 +1,13 @@ +class Main { + public static void main(String[] args) { + java.util.List names = + java.util.Arrays.asList("java", "sql", "jvm", "java"); + java.util.List selected = names.stream() + .filter(name -> name.startsWith("j")) + .distinct() + .map(name -> name.toUpperCase(java.util.Locale.ROOT)) + .collect(java.util.stream.Collectors.toList()); + System.out.println(selected); + System.out.println(names.size()); + } +} diff --git a/examples/java8/testing-design/Main.java b/examples/java8/testing-design/Main.java new file mode 100644 index 0000000..c038d4f --- /dev/null +++ b/examples/java8/testing-design/Main.java @@ -0,0 +1,27 @@ +class Main { + static int shippingFee(int subtotal) { + if (subtotal < 0) { + throw new IllegalArgumentException("negative subtotal"); + } + return subtotal >= 30000 ? 0 : 3000; + } + + static void check(int expected, int actual) { + if (expected != actual) { + throw new AssertionError("expected=" + expected + ", actual=" + actual); + } + } + + public static void main(String[] args) { + check(3000, shippingFee(29999)); + check(0, shippingFee(30000)); + check(0, shippingFee(30001)); + try { + shippingFee(-1); + throw new AssertionError("negative input must fail"); + } catch (IllegalArgumentException expected) { + // Expected contract failure. + } + System.out.println("4 contract checks passed"); + } +} diff --git a/examples/solutions.json b/examples/solutions.json new file mode 100644 index 0000000..0037bff --- /dev/null +++ b/examples/solutions.json @@ -0,0 +1,14 @@ +{ + "java-basics-lab": "class Solution {\n public boolean sameCode(String left, String right) {\n return java.util.Objects.equals(left, right);\n }\n}", + "oop-lab": "class Solution {\n private int stock;\n\n public Solution(int initialStock) {\n if (initialStock < 0) {\n throw new IllegalArgumentException(\"initialStock must be non-negative\");\n }\n stock = initialStock;\n }\n\n public boolean reserve(int quantity) {\n if (quantity <= 0 || quantity > stock) {\n return false;\n }\n stock -= quantity;\n return true;\n }\n\n public int remaining() {\n return stock;\n }\n}", + "polymorphism-lab": "class Solution {\n public interface DiscountPolicy {\n int discount(int subtotal);\n }\n\n public int payable(int subtotal, DiscountPolicy policy) {\n if (subtotal < 0) {\n throw new IllegalArgumentException(\"subtotal must be non-negative\");\n }\n java.util.Objects.requireNonNull(policy, \"policy\");\n int discount = policy.discount(subtotal);\n if (discount < 0 || discount > subtotal) {\n throw new IllegalArgumentException(\"discount outside contract\");\n }\n return subtotal - discount;\n }\n}", + "collections-lab": "class Solution {\n public java.util.List uniqueCodes(java.util.List codes) {\n return new java.util.ArrayList(\n new java.util.LinkedHashSet(codes));\n }\n}", + "generics-lab": "class Solution {\n public void copy(java.util.List source,\n java.util.List target) {\n for (T item : source) {\n target.add(item);\n }\n }\n}", + "exceptions-lab": "class Solution {\n public int parsePort(String text) {\n if (text == null) {\n throw new IllegalArgumentException(\"port is required\");\n }\n final int port;\n try {\n port = Integer.parseInt(text.trim());\n } catch (NumberFormatException cause) {\n throw new IllegalArgumentException(\"port must be an integer\", cause);\n }\n if (port < 1 || port > 65535) {\n throw new IllegalArgumentException(\"port outside range\");\n }\n return port;\n }\n}", + "streams-lab": "class Solution {\n public int totalPositiveEven(java.util.List values) {\n return values.stream()\n .filter(java.util.Objects::nonNull)\n .filter(value -> value > 0 && value % 2 == 0)\n .mapToInt(Integer::intValue)\n .sum();\n }\n}", + "concurrency-lab": "class Solution {\n public int count(int workers, int iterations) {\n if (workers < 1 || workers > 8 || iterations < 0 || iterations > 10000) {\n throw new IllegalArgumentException(\"arguments outside exercise bounds\");\n }\n java.util.concurrent.ExecutorService executor =\n java.util.concurrent.Executors.newFixedThreadPool(workers);\n java.util.concurrent.atomic.AtomicInteger counter =\n new java.util.concurrent.atomic.AtomicInteger();\n try {\n java.util.List> futures =\n new java.util.ArrayList>();\n for (int worker = 0; worker < workers; worker++) {\n futures.add(executor.submit(() -> {\n for (int index = 0; index < iterations; index++) {\n counter.incrementAndGet();\n }\n }));\n }\n for (java.util.concurrent.Future future : futures) {\n future.get();\n }\n return counter.get();\n } catch (InterruptedException cause) {\n Thread.currentThread().interrupt();\n throw new IllegalStateException(\"count interrupted\", cause);\n } catch (java.util.concurrent.ExecutionException cause) {\n throw new IllegalStateException(\"worker failed\", cause);\n } finally {\n executor.shutdownNow();\n }\n }\n}", + "modern-apis-lab": "class Solution {\n public java.util.List normalizedLines(String text) {\n if (text == null) {\n throw new IllegalArgumentException(\"text is required\");\n }\n return text.lines()\n .map(String::strip)\n .filter(line -> !line.isBlank())\n .collect(java.util.stream.Collectors.toList());\n }\n}", + "records-sealed-lab": "class Solution {\n public sealed interface Result permits Accepted, Rejected {}\n public record Accepted(int remaining) implements Result {}\n public record Rejected(String reason) implements Result {}\n\n public Result reserve(int stock, int requested) {\n if (stock < 0) {\n return new Rejected(\"invalid-stock\");\n }\n if (requested <= 0) {\n return new Rejected(\"invalid-quantity\");\n }\n if (requested > stock) {\n return new Rejected(\"insufficient-stock\");\n }\n return new Accepted(stock - requested);\n }\n}", + "java21-lab": "class Solution {\n public record Order(String id, int quantity) {}\n\n public String describe(Object value) {\n return switch (value) {\n case Order(String id, int quantity) when quantity > 0 -> id + \":\" + quantity;\n case Order order -> \"empty\";\n case null -> \"missing\";\n default -> \"unsupported\";\n };\n }\n}", + "testing-design-lab": "class Solution {\n public boolean acceptsPolicy(java.util.function.IntUnaryOperator policy) {\n int[] inputs = {0, 29999, 30000, 30001};\n int[] expected = {3000, 3000, 0, 0};\n try {\n for (int index = 0; index < inputs.length; index++) {\n if (policy.applyAsInt(inputs[index]) != expected[index]) {\n return false;\n }\n }\n try {\n policy.applyAsInt(-1);\n return false;\n } catch (IllegalArgumentException expectedFailure) {\n return true;\n }\n } catch (RuntimeException unexpectedFailure) {\n return false;\n }\n }\n}" +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..76bc232 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,550 @@ +{ + "name": "java-edu", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "java-edu", + "version": "0.1.0", + "devDependencies": { + "esbuild": "0.28.2", + "playwright": "1.62.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c35cc45 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "java-edu", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Java 공식 문서 기반 전환 학습과 버전별 실무 심화", + "scripts": { + "dev": "node scripts/serve.mjs", + "test": "node --test tests/*.test.mjs", + "build": "node scripts/build-worker.mjs", + "test:worker": "node scripts/check-worker.mjs", + "test:java": "node scripts/check-java.mjs", + "test:browser": "node tests/browser.mjs", + "check": "node --check dist/app.js && node --check dist/data/curriculum.js && node --check dist/data/job-evidence.js && npm test" + }, + "engines": { + "node": ">=22" + }, + "devDependencies": { + "esbuild": "0.28.2", + "playwright": "1.62.1" + } +} diff --git a/scripts/check-java.mjs b/scripts/check-java.mjs new file mode 100644 index 0000000..ba4746b --- /dev/null +++ b/scripts/check-java.mjs @@ -0,0 +1,72 @@ +import { readFile, writeFile, mkdtemp, rm, realpath } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; +import { exerciseDefinitions } from '../server/exercises.mjs'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const manifest = JSON.parse(await readFile(path.join(root, 'examples/expected-output.json'), 'utf8')); +const javaPath = process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') : 'java'; +const javacPath = process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac') : 'javac'; +const versionText = execFileSync(javacPath, ['-version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +// Some Java 8 distributions print the compiler version on stderr. +const compilerVersion = (() => { + try { return versionText.trim(); } catch { return ''; } +})(); +let rawVersion = compilerVersion; +if (!rawVersion) { + const { spawnSync } = await import('node:child_process'); + const result = spawnSync(javacPath, ['-version'], { encoding: 'utf8' }); + rawVersion = result.stdout + result.stderr; +} +const match = rawVersion.match(/javac (?:1\.)?(\d+)/); +assert.ok(match, `Cannot identify javac version: ${rawVersion}`); +const major = Number(match[1]); +let passed = 0; +let skipped = 0; +const temporaryRoot = await realpath(tmpdir()); +async function removeOwnedDirectory(directory) { + const resolved = await realpath(directory); + assert.equal(path.dirname(resolved), temporaryRoot); + assert.ok(path.basename(resolved).startsWith('java-edu-check-')); + await rm(resolved, { recursive: true, force: true }); +} +for (const [relativeFile, expected] of Object.entries(manifest)) { + if (expected.minVersion > major) { + console.log(`SKIP ${relativeFile}: requires Java ${expected.minVersion}; installed ${major}`); + skipped += 1; + continue; + } + const outputDirectory = await mkdtemp(path.join(tmpdir(), 'java-edu-check-')); + try { + const flags = major >= 9 ? ['--release', String(expected.minVersion)] : ['-source', '1.8', '-target', '1.8']; + execFileSync(javacPath, [...flags, '-encoding', 'UTF-8', '-d', outputDirectory, path.join(root, 'examples', relativeFile)], { stdio: 'pipe' }); + const actual = execFileSync(javaPath, ['-Dfile.encoding=UTF-8', '-cp', outputDirectory, expected.mainClass], { encoding: 'utf8', timeout: 15000 }); + assert.equal(actual.replaceAll('\r\n', '\n').trimEnd(), expected.stdout.replaceAll('\r\n', '\n').trimEnd(), relativeFile); + console.log(`PASS ${relativeFile} on Java ${major} (target ${expected.minVersion})`); + passed += 1; + } finally { + await removeOwnedDirectory(outputDirectory); + } +} +const solutions = JSON.parse(await readFile(path.join(root, 'examples/solutions.json'), 'utf8')); +for (const exercise of exerciseDefinitions) { + if (exercise.minVersion > major) { skipped += 1; continue; } + const outputDirectory = await mkdtemp(path.join(temporaryRoot, 'java-edu-check-')); + try { + const checks = exercise.checks.map((check, index) => `if (!(${check.expression})) throw new AssertionError("check ${index + 1}");`).join('\n'); + const source = `${solutions[exercise.id]}\nclass Main { public static void main(String[] args) throws Exception {\n${checks}\nSystem.out.println("REFERENCE_PASS");\n} }`; + const sourceFile = path.join(outputDirectory, 'Main.java'); + await writeFile(sourceFile, source); + const flags = major >= 9 ? ['--release', String(exercise.minVersion)] : ['-source', '1.8', '-target', '1.8']; + execFileSync(javacPath, [...flags, '-encoding', 'UTF-8', '-d', outputDirectory, sourceFile], { stdio: 'pipe' }); + const actual = execFileSync(javaPath, ['-Dfile.encoding=UTF-8', '-cp', outputDirectory, 'Main'], { encoding: 'utf8', timeout: 15000 }); + assert.ok(actual.includes('REFERENCE_PASS'), exercise.id); + console.log(`PASS ${exercise.id}: ${exercise.checks.length} checks on Java ${major}`); + passed += 1; + } finally { await removeOwnedDirectory(outputDirectory); } +} +assert.ok(passed > 0, 'No compatible examples were tested'); +console.log(`Java ${major}: ${passed} passed, ${skipped} skipped`); diff --git a/scripts/serve.mjs b/scripts/serve.mjs new file mode 100644 index 0000000..3064774 --- /dev/null +++ b/scripts/serve.mjs @@ -0,0 +1,48 @@ +import http from 'node:http'; +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../dist/', import.meta.url)); +const port = Number(process.env.PORT || 4173); +const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json; charset=utf-8' }; +const server = http.createServer(async (request, response) => { + try { + const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); + if (pathname.startsWith('/api/')) { + const { handleApi } = await import('../server/executor.mjs'); + const controller = new AbortController(); + response.on('close', () => { if (!response.writableEnded) controller.abort(); }); + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 33 * 1024) { + response.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: { code: 'request_too_large', message: '요청 크기 한도를 넘었습니다.' } })); + return; + } + chunks.push(chunk); + } + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(',') : value); + const webRequest = new Request(`http://${request.headers.host || `127.0.0.1:${port}`}${request.url}`, { + method: request.method, headers, signal: controller.signal, + ...(request.method !== 'GET' && request.method !== 'HEAD' ? { body: Buffer.concat(chunks) } : {}), + }); + const result = await handleApi(webRequest, { REQUIRE_IDENTITY: false }); + response.writeHead(result.status, Object.fromEntries(result.headers)); + response.end(Buffer.from(await result.arrayBuffer())); + return; + } + const file = path.resolve(root, '.' + (pathname === '/' ? '/index.html' : pathname)); + if (!file.startsWith(root) || pathname.startsWith('/server/') || pathname.startsWith('/.openai/') || !(await stat(file)).isFile()) throw new Error('not found'); + response.writeHead(200, { 'Content-Type': types[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }); + response.end(await readFile(file)); + } catch { + response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Not found'); + } +}); +server.listen(port, '127.0.0.1', () => console.log(`Java Edu: http://127.0.0.1:${port}`)); +server.on('error', (error) => { console.error(`미리보기 서버를 시작하지 못했습니다: ${error.message}`); process.exitCode = 1; }); diff --git a/server/exercises.mjs b/server/exercises.mjs new file mode 100644 index 0000000..ebf19fc --- /dev/null +++ b/server/exercises.mjs @@ -0,0 +1,331 @@ +import { exercises } from '../dist/data/exercises.js'; + +// Trusted educational test expressions. Never evaluate expressions received from clients. +const checksById = { + "java-basics-lab": [ + { + "name": "내용이 같은 별도 객체", + "expression": "new Solution().sameCode(new String(\"JAVA\"), new String(\"JAVA\"))" + }, + { + "name": "내용이 다른 코드", + "expression": "!new Solution().sameCode(\"JAVA\", \"java\")" + }, + { + "name": "둘 다 null", + "expression": "new Solution().sameCode(null, null)" + }, + { + "name": "왼쪽만 null", + "expression": "!new Solution().sameCode(null, \"JAVA\")" + }, + { + "name": "오른쪽만 null", + "expression": "!new Solution().sameCode(\"JAVA\", null)" + }, + { + "name": "빈 문자열", + "expression": "new Solution().sameCode(\"\", \"\")" + } + ], + "oop-lab": [ + { + "name": "예약 성공과 초과 예약 실패", + "expression": "((java.util.function.Supplier) () -> { Solution inventory = new Solution(5); return inventory.reserve(3) && inventory.remaining() == 2 && !inventory.reserve(3) && inventory.remaining() == 2; }).get()" + }, + { + "name": "전량 예약", + "expression": "((java.util.function.Supplier) () -> { Solution inventory = new Solution(5); return inventory.reserve(5) && inventory.remaining() == 0; }).get()" + }, + { + "name": "0과 음수 예약의 상태 보존", + "expression": "((java.util.function.Supplier) () -> { Solution inventory = new Solution(5); return !inventory.reserve(0) && !inventory.reserve(-2) && inventory.remaining() == 5; }).get()" + }, + { + "name": "빈 재고", + "expression": "((java.util.function.Supplier) () -> { Solution inventory = new Solution(0); return !inventory.reserve(1) && inventory.remaining() == 0; }).get()" + }, + { + "name": "음수 초기 재고 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution(-1); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + } + ], + "polymorphism-lab": [ + { + "name": "정액 정책 주입", + "expression": "new Solution().payable(10000, amount -> 1000) == 9000" + }, + { + "name": "비율 정책 주입", + "expression": "new Solution().payable(20000, amount -> amount / 10) == 18000" + }, + { + "name": "전액 할인", + "expression": "new Solution().payable(5000, amount -> amount) == 0" + }, + { + "name": "음수 할인 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().payable(1000, amount -> -1); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + }, + { + "name": "소계 초과 할인 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().payable(1000, amount -> 1001); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + }, + { + "name": "음수 소계 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().payable(-1, amount -> 0); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + }, + { + "name": "null 정책 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().payable(1000, null); return false; } catch (NullPointerException expected) { return true; } }).get()" + }, + { + "name": "정책 한 번 호출", + "expression": "((java.util.function.Supplier) () -> { java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger(); int price = new Solution().payable(500, amount -> { calls.incrementAndGet(); return 100; }); return price == 400 && calls.get() == 1; }).get()" + } + ], + "collections-lab": [ + { + "name": "최초 등장 순서", + "expression": "new Solution().uniqueCodes(java.util.Arrays.asList(\"B\", \"A\", \"B\")).equals(java.util.Arrays.asList(\"B\", \"A\"))" + }, + { + "name": "내용으로 중복 제거", + "expression": "new Solution().uniqueCodes(java.util.Arrays.asList(new String(\"A\"), new String(\"A\"))).equals(java.util.Arrays.asList(\"A\"))" + }, + { + "name": "null 항목 한 번 유지", + "expression": "new Solution().uniqueCodes(java.util.Arrays.asList(null, \"A\", null)).equals(java.util.Arrays.asList(null, \"A\"))" + }, + { + "name": "빈 입력", + "expression": "new Solution().uniqueCodes(java.util.Collections.emptyList()).isEmpty()" + }, + { + "name": "입력 보존과 새 리스트", + "expression": "((java.util.function.Supplier) () -> { java.util.List input = new java.util.ArrayList(java.util.Arrays.asList(\"A\", \"A\", \"B\")); java.util.List result = new Solution().uniqueCodes(input); result.add(\"C\"); return result != input && input.equals(java.util.Arrays.asList(\"A\", \"A\", \"B\")); }).get()" + } + ], + "generics-lab": [ + { + "name": "Integer에서 Number로 복사", + "expression": "((java.util.function.Supplier) () -> { java.util.List source = java.util.Arrays.asList(1, 2); java.util.List target = new java.util.ArrayList(); target.add(0.5); new Solution().copy(source, target); return target.equals(java.util.Arrays.asList(0.5, 1, 2)); }).get()" + }, + { + "name": "String에서 Object로 복사", + "expression": "((java.util.function.Supplier) () -> { java.util.List target = new java.util.ArrayList(); new Solution().copy(java.util.Arrays.asList(\"A\", \"B\"), target); return target.equals(java.util.Arrays.asList(\"A\", \"B\")); }).get()" + }, + { + "name": "빈 source와 기존 항목 유지", + "expression": "((java.util.function.Supplier) () -> { java.util.List target = new java.util.ArrayList(java.util.Arrays.asList(7)); new Solution().copy(java.util.Collections.emptyList(), target); return target.equals(java.util.Arrays.asList(7)); }).get()" + }, + { + "name": "source 보존", + "expression": "((java.util.function.Supplier) () -> { java.util.List source = new java.util.ArrayList(java.util.Arrays.asList(3, 3)); java.util.List target = new java.util.ArrayList(); new Solution().copy(source, target); return source.equals(java.util.Arrays.asList(3, 3)) && target.equals(source); }).get()" + } + ], + "exceptions-lab": [ + { + "name": "공백을 포함한 유효 설정", + "expression": "new Solution().parsePort(\" 8080 \") == 8080" + }, + { + "name": "양쪽 유효 경계", + "expression": "new Solution().parsePort(\"1\") == 1 && new Solution().parsePort(\"65535\") == 65535" + }, + { + "name": "숫자 형식 오류의 원인 보존", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().parsePort(\"eighty\"); return false; } catch (IllegalArgumentException expected) { return expected.getCause() instanceof NumberFormatException; } }).get()" + }, + { + "name": "빈 문자열 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().parsePort(\" \"); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + }, + { + "name": "null 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().parsePort(null); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + }, + { + "name": "아래쪽 범위 위반", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().parsePort(\"0\"); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + }, + { + "name": "위쪽 범위 위반", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().parsePort(\"65536\"); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + } + ], + "streams-lab": [ + { + "name": "조건 필터와 합계", + "expression": "new Solution().totalPositiveEven(java.util.Arrays.asList(2, -4, 3, null, 6, 0)) == 8" + }, + { + "name": "빈 입력의 항등값", + "expression": "new Solution().totalPositiveEven(java.util.Collections.emptyList()) == 0" + }, + { + "name": "모두 제외되는 입력", + "expression": "new Solution().totalPositiveEven(java.util.Arrays.asList(null, -2, 0, 1)) == 0" + }, + { + "name": "중복 값 유지", + "expression": "new Solution().totalPositiveEven(java.util.Arrays.asList(2, 2, 4)) == 8" + }, + { + "name": "입력 보존", + "expression": "((java.util.function.Supplier) () -> { java.util.List input = new java.util.ArrayList(java.util.Arrays.asList(2, 3, 4)); int result = new Solution().totalPositiveEven(input); return result == 6 && input.equals(java.util.Arrays.asList(2, 3, 4)); }).get()" + } + ], + "concurrency-lab": [ + { + "name": "두 작업의 완료 합계", + "expression": "new Solution().count(2, 500) == 1000" + }, + { + "name": "증가 횟수 0", + "expression": "new Solution().count(3, 0) == 0" + }, + { + "name": "단일 작업", + "expression": "new Solution().count(1, 17) == 17" + }, + { + "name": "여러 작업의 누적", + "expression": "new Solution().count(4, 2000) == 8000" + }, + { + "name": "workers 아래 경계 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().count(0, 1); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + }, + { + "name": "workers 위 경계 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().count(9, 1); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + }, + { + "name": "음수 반복 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().count(1, -1); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + }, + { + "name": "반복 상한 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().count(1, 10001); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + } + ], + "modern-apis-lab": [ + { + "name": "빈 줄 제거와 순서", + "expression": "new Solution().normalizedLines(\" Java\\n\\n SQL \").equals(java.util.Arrays.asList(\"Java\", \"SQL\"))" + }, + { + "name": "유니코드 공백", + "expression": "new Solution().normalizedLines(\"\\u2003Java\\u2003\\n\\u2003\").equals(java.util.Arrays.asList(\"Java\"))" + }, + { + "name": "CRLF와 중복 줄", + "expression": "new Solution().normalizedLines(\"A\\r\\nA\\r\\n B \").equals(java.util.Arrays.asList(\"A\", \"A\", \"B\"))" + }, + { + "name": "빈 입력", + "expression": "new Solution().normalizedLines(\"\").isEmpty()" + }, + { + "name": "null 입력 거절", + "expression": "((java.util.function.Supplier) () -> { try { new Solution().normalizedLines(null); return false; } catch (IllegalArgumentException expected) { return true; } }).get()" + } + ], + "records-sealed-lab": [ + { + "name": "성공 결과의 값 동등성", + "expression": "new Solution().reserve(5, 2).equals(new Solution.Accepted(3))" + }, + { + "name": "전량 예약", + "expression": "new Solution().reserve(5, 5).equals(new Solution.Accepted(0))" + }, + { + "name": "초과 요청의 구체적 이유", + "expression": "new Solution().reserve(2, 3).equals(new Solution.Rejected(\"insufficient-stock\"))" + }, + { + "name": "0 이하 요청", + "expression": "new Solution().reserve(3, 0).equals(new Solution.Rejected(\"invalid-quantity\")) && new Solution().reserve(3, -1).equals(new Solution.Rejected(\"invalid-quantity\"))" + }, + { + "name": "초기 재고 검증 우선", + "expression": "new Solution().reserve(-1, -1).equals(new Solution.Rejected(\"invalid-stock\"))" + }, + { + "name": "record 접근자", + "expression": "new Solution.Accepted(7).remaining() == 7" + } + ], + "java21-lab": [ + { + "name": "양수 주문 패턴", + "expression": "\"A-21:3\".equals(new Solution().describe(new Solution.Order(\"A-21\", 3)))" + }, + { + "name": "0 수량 주문", + "expression": "\"empty\".equals(new Solution().describe(new Solution.Order(\"A\", 0)))" + }, + { + "name": "음수 수량 주문", + "expression": "\"empty\".equals(new Solution().describe(new Solution.Order(\"A\", -1)))" + }, + { + "name": "null 분기", + "expression": "\"missing\".equals(new Solution().describe(null))" + }, + { + "name": "다른 참조 타입", + "expression": "\"unsupported\".equals(new Solution().describe(\"A\"))" + }, + { + "name": "다른 숫자 타입", + "expression": "\"unsupported\".equals(new Solution().describe(Integer.valueOf(3)))" + } + ], + "testing-design-lab": [ + { + "name": "계약을 만족하는 정책 수용", + "expression": "new Solution().acceptsPolicy(total -> { if (total < 0) throw new IllegalArgumentException(); return total >= 30000 ? 0 : 3000; })" + }, + { + "name": "무료 배송 시작점 결함 발견", + "expression": "!new Solution().acceptsPolicy(total -> { if (total < 0) throw new IllegalArgumentException(); return total > 30000 ? 0 : 3000; })" + }, + { + "name": "너무 이른 무료 배송 발견", + "expression": "!new Solution().acceptsPolicy(total -> { if (total < 0) throw new IllegalArgumentException(); return total >= 29999 ? 0 : 3000; })" + }, + { + "name": "주문액 0만 틀린 정책 발견", + "expression": "!new Solution().acceptsPolicy(total -> { if (total < 0) throw new IllegalArgumentException(); if (total == 0) return 0; return total >= 30000 ? 0 : 3000; })" + }, + { + "name": "주문액 30001만 틀린 정책 발견", + "expression": "!new Solution().acceptsPolicy(total -> { if (total < 0) throw new IllegalArgumentException(); if (total == 30001) return 3000; return total >= 30000 ? 0 : 3000; })" + }, + { + "name": "음수 처리 누락 발견", + "expression": "!new Solution().acceptsPolicy(total -> total >= 30000 ? 0 : 3000)" + }, + { + "name": "고정 배송비 결함 발견", + "expression": "!new Solution().acceptsPolicy(total -> { if (total < 0) throw new IllegalArgumentException(); return 3000; })" + }, + { + "name": "예상 밖 런타임 오류 발견", + "expression": "!new Solution().acceptsPolicy(total -> { throw new IllegalStateException(); })" + } + ] +}; + +export const exerciseDefinitions = exercises.map(({ id, lessonId, minVersion, template, mode }) => ({ + id, + lessonId, + minVersion, + template, + mode, + checks: checksById[id], + sampleChecks: [checksById[id][0]], +})); diff --git a/tests/browser.mjs b/tests/browser.mjs new file mode 100644 index 0000000..8571b5f --- /dev/null +++ b/tests/browser.mjs @@ -0,0 +1,816 @@ +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; + +// Browser interaction tests use an isolated context and mocked execution API. +// They do not submit source code to the real execution provider. +const projectRoot = fileURLToPath(new URL('../', import.meta.url)); +const baseUrl = process.env.QA_BASE_URL || 'http://127.0.0.1:4173'; +const artifactRoot = process.env.QA_ARTIFACT_DIR || path.resolve(projectRoot, '../../work/java-edu-qa'); +const storageKey = 'java-edu:progress:v1'; +const filter = process.env.QA_FILTER ? new RegExp(process.env.QA_FILTER) : null; +const results = []; + +async function waitForServer() { + const deadline = Date.now() + 30_000; + let lastError; + while (Date.now() < deadline) { + try { + const response = await fetch(baseUrl, { signal: AbortSignal.timeout(2_000) }); + if (response.ok) return; + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error.cause || error; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + throw new Error(`Start the parent-owned development server at ${baseUrl} before running browser QA. ${lastError?.message || ''}`); +} + +function expectEventually(read, expected, message, timeout = 5_000) { + return (async () => { + const deadline = Date.now() + timeout; + let actual; + do { + actual = await read(); + if (typeof expected === 'function' ? expected(actual) : actual === expected) return actual; + await new Promise(resolve => setTimeout(resolve, 50)); + } while (Date.now() < deadline); + assert.fail(`${message}; received ${JSON.stringify(actual)}`); + })(); +} + +async function noViewportOverflow(page) { + const dimensions = await page.evaluate(() => ({ + client: document.documentElement.clientWidth, + scroll: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth), + })); + assert.ok(dimensions.scroll <= dimensions.client + 1, `viewport overflow: ${JSON.stringify(dimensions)}`); +} + +async function screenshot(page, name, fullPage = true) { + const position = await page.evaluate(() => ({ x: scrollX, y: scrollY })); + if (fullPage) await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })); + if (process.env.QA_TRACE_SCREENSHOTS === '1') { + console.log('CAPTURE', name, JSON.stringify(await page.evaluate(() => { + const skip = document.querySelector('.skip-link'); + const rect = skip?.getBoundingClientRect(); + return { active: document.activeElement?.id || document.activeElement?.tagName, scrollY, skipFocused: document.activeElement === skip, skipTop: rect?.top, skipBottom: rect?.bottom }; + }))); + } + try { + await page.screenshot({ path: path.join(artifactRoot, `${name}.png`), fullPage }); + } finally { + if (fullPage) await page.evaluate(({ x, y }) => window.scrollTo({ top: y, left: x, behavior: 'instant' }), position); + } +} + +function executionResult(request, overrides = {}) { + return { + requestId: request.requestId, + status: 'passed', + runtimeVersion: 21, + targetVersion: 8, + stdout: 'QA sample output\n', + stderr: '', + compilerOutput: '', + checks: [{ name: '내용 비교', passed: true }, { name: 'null 경계', passed: true }], + passed: 2, + total: 2, + provider: 'Wandbox', + durationMs: 120, + ...overrides, + }; +} + +async function mockApi(context, options = {}) { + const state = { + requests: [], + settled: [], + unexpected: [], + respond: request => ({ status: 200, body: executionResult(request) }), + ...options, + }; + await context.route('**/api/**', async route => { + const url = new URL(route.request().url()); + if (url.origin !== new URL(baseUrl).origin) { + state.unexpected.push(url.toString()); + return route.abort(); + } + if (url.pathname === '/api/runtime') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + provider: 'Wandbox', + available: true, + runtime: { javaVersion: 21, compiler: 'openjdk-jdk-21+35' }, + supportedTargets: [8, 11, 17, 21], + notice: '코드는 실행·제출 시 외부 제공자 Wandbox에 전송됩니다.', + checkedAt: '2026-10-12T03:00:00.000Z', + ...state.runtime, + }), + }); + } + if (url.pathname === '/api/execute') { + const request = route.request().postDataJSON(); + state.requests.push(request); + const response = await state.respond(request); + if (response.abort) return route.abort('failed'); + await route.fulfill({ + status: response.status || 200, + contentType: 'application/json', + body: response.raw ?? JSON.stringify(response.body), + }).catch(error => { + // A stale request may be aborted by the application or context teardown. + if (!/closed|disposed|cancel|intercept/i.test(error.message)) throw error; + }); + state.settled.push(request.requestId); + return; + } + state.unexpected.push(url.toString()); + return route.abort(); + }); + return state; +} + +function deferred() { + let resolve; + const promise = new Promise(resolvePromise => { resolve = resolvePromise; }); + return { promise, resolve }; +} + +async function openLesson(page, lesson, step = 'theory', version = 21) { + await page.goto(`${baseUrl}/#lesson=${encodeURIComponent(lesson.id)}&step=${step}&version=${version}`); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + await expectEventually(() => page.locator('#lesson-title').textContent(), value => value?.includes(lesson.title), 'requested lesson title'); + await expectEventually(() => page.locator(`[data-step="${step}"]`).getAttribute('aria-selected'), 'true', 'requested lesson step'); +} + +async function stepTo(page, step) { + await page.locator(`[data-step="${step}"]`).click(); + await expectEventually(() => page.locator(`[data-step="${step}"]`).getAttribute('aria-selected'), 'true', 'selected step'); + assert.equal(await page.locator('#lesson-panel').getAttribute('aria-labelledby'), `tab-${step}`); +} + +async function storedProgress(page) { + return page.evaluate(key => { + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + }, storageKey); +} + +async function visibleLessonIds(page) { + return page.locator('#lesson-list [data-lesson-id]').evaluateAll(nodes => nodes.map(node => node.dataset.lessonId)); +} + +async function achievement(page, kind) { + return page.locator(`#lesson-achievements [data-achievement="${kind}"]`).getAttribute('data-complete'); +} + +async function answerQuiz(page, lesson, correct = true) { + for (const question of lesson.quiz) { + const selected = correct ? question.correctIndex : (question.correctIndex + 1) % question.options.length; + await page.locator(`#quiz-form input[name="quiz-${question.id}"][value="${selected}"]`).check(); + } +} + +async function completeQuiz(page, lesson) { + await stepTo(page, 'quiz'); + await answerQuiz(page, lesson); + await page.locator('#quiz-submit').click(); + await expectEventually(() => achievement(page, 'quiz'), 'true', 'all quiz answers grant quiz achievement'); +} + +async function waitForOutput(page, expected) { + return expectEventually(() => page.locator('#execution-output').getAttribute('data-status'), expected, 'execution output state'); +} + +async function run(browser, name, test, options = {}) { + if (filter && !filter.test(name)) return; + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ...options }); + const page = await context.newPage(); + page.setDefaultTimeout(5_000); + const pageErrors = []; + page.on('pageerror', error => pageErrors.push(error.message)); + const started = Date.now(); + try { + await test({ context, page }); + assert.deepEqual(pageErrors, [], 'uncaught browser errors'); + results.push({ name, status: 'PASS', durationMs: Date.now() - started }); + console.log(`PASS ${name}`); + } catch (error) { + const evidence = `failure-${name.replace(/[^a-z0-9-]/gi, '-')}`; + await screenshot(page, evidence).catch(() => {}); + results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - started, screenshot: `${evidence}.png` }); + console.error(`FAIL ${name}: ${error.stack}`); + } finally { + await context.close(); + } +} + +async function main() { + await waitForServer(); + await mkdir(artifactRoot, { recursive: true }); + const { lessons } = await import('../dist/data/curriculum.js'); + const { exercises } = await import('../dist/data/exercises.js'); + assert.ok(lessons.length >= 2, 'real lessons are present'); + const first = lessons.find(lesson => lesson.minVersion === 8); + const other = lessons.find(lesson => lesson.id !== first.id && lesson.minVersion === 8); + const firstExercise = exercises.find(exercise => exercise.lessonId === first.id); + const browser = await chromium.launch({ + headless: process.env.PLAYWRIGHT_HEADLESS !== '0', + ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}), + }); + const browserVersion = browser.version(); + try { + await run(browser, 'lesson-steps-sources-and-disclosure', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first); + assert.equal(await page.locator('[role="tab"]').count(), 5); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + assert.ok(await page.locator('#source-list a[href^="https://"]').count() > 0, 'official references are visible'); + assert.match(await page.locator('.evidence-disclaimer').textContent(), /통계가 아닙니다/); + await screenshot(page, 'desktop-theory'); + await screenshot(page, 'desktop-top', false); + for (const step of ['example', 'quiz', 'practice', 'lab']) { + await stepTo(page, step); + assert.ok((await page.locator('#lesson-panel').innerText()).trim().length > 50, `${step} has substantive content`); + await noViewportOverflow(page); + if (step === 'example') await screenshot(page, 'desktop-example'); + if (step === 'lab') { + assert.equal(await page.locator('#code-editor').inputValue(), firstExercise.template); + const labText = await page.locator('#lesson-panel').innerText(); + assert.match(labText, /Wandbox/); + assert.match(labText, /전송/); + assert.match(labText, /21/); + await screenshot(page, 'desktop-ide'); + } + } + assert.equal(api.requests.length, 0, 'navigation never submits code automatically'); + assert.deepEqual(api.unexpected, []); + }); + + await run(browser, 'all-lessons-render-five-learning-steps', async ({ context, page }) => { + const api = await mockApi(context); + for (const lesson of lessons) { + await openLesson(page, lesson); + for (const step of ['example', 'quiz', 'practice', 'lab']) { + await stepTo(page, step); + assert.ok((await page.locator('#lesson-panel').innerText()).trim().length > 40, `${lesson.id}/${step} is not empty`); + } + const exercise = exercises.find(item => item.lessonId === lesson.id); + assert.ok(exercise, `${lesson.id} has a coding task`); + assert.equal(await page.locator('#code-editor').inputValue(), exercise.template); + } + assert.equal(api.requests.length, 0); + }); + + await run(browser, 'versions-search-and-empty-result-recovery', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first); + for (const version of [8, 11, 17, 21]) { + await page.locator('#version-select').selectOption(String(version)); + const expected = lessons.filter(lesson => lesson.minVersion <= version).map(lesson => lesson.id).sort(); + await expectEventually(() => visibleLessonIds(page), ids => JSON.stringify(ids.sort()) === JSON.stringify(expected), `available lessons for Java ${version}`); + } + await page.locator('#lesson-search').fill('no-result-qa-unique-token'); + await expectEventually(() => visibleLessonIds(page), ids => ids.length === 0, 'search has no matches'); + assert.ok((await page.locator('#lesson-list').innerText()).trim().length > 0, 'empty search has an explanatory state'); + assert.ok((await page.locator('#lesson-title').textContent()).trim().length > 0, 'current lesson stays available'); + await page.locator('#lesson-search').fill(''); + assert.equal(await page.evaluate(() => window.qaSearchExecuted), undefined); + await page.locator('#lesson-search').fill(''); + await expectEventually(() => visibleLessonIds(page), ids => ids.length === lessons.length, 'clearing search restores lessons'); + await page.locator('#lesson-search').fill(other.title); + await expectEventually(() => visibleLessonIds(page), ids => ids.includes(other.id), 'title search finds intended lesson'); + await page.locator('#lesson-search').fill(''); + for (const track of ['transition', 'advanced', 'all']) { + await page.locator(`[data-track="${track}"]`).click(); + const expected = lessons.filter(lesson => track === 'all' || lesson.track === track || lesson.track === 'both').map(lesson => lesson.id).sort(); + await expectEventually(() => visibleLessonIds(page), ids => JSON.stringify(ids.sort()) === JSON.stringify(expected), `${track} track membership`); + assert.equal(await page.locator(`[data-track="${track}"]`).getAttribute('aria-pressed'), 'true'); + } + }); + + await run(browser, 'deeplinks-invalid-values-and-version-gating', async ({ context, page }) => { + await mockApi(context); + for (const fragment of ['lesson=not-a-lesson&step=lab&version=21', 'lesson=java-basics&step=unknown&version=21', 'lesson=java-basics&step=theory&version=999', 'lesson=%3Cscript%3E&step=%00&version=8']) { + await page.goto(`${baseUrl}/#${fragment}`); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + const title = await page.locator('#lesson-title').textContent(); + assert.ok(lessons.some(lesson => title.includes(lesson.title)), `invalid fragment recovers a known lesson: ${fragment}`); + assert.equal(await page.locator('[role="tab"][aria-selected="true"]').count(), 1); + assert.ok(['8', '11', '17', '21'].includes(await page.locator('#version-select').inputValue())); + } + const newest = [...lessons].sort((a, b) => b.minVersion - a.minVersion)[0]; + assert.ok(newest.minVersion > 8, 'version boundary is represented'); + await page.goto(`${baseUrl}/#lesson=${newest.id}&step=lab&version=8`); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#version-select').inputValue(), '8'); + assert.ok(!(await page.locator('#lesson-title').textContent()).includes(newest.title), 'unsupported lesson is not accessible under Java 8'); + assert.ok((await page.locator('#lesson-panel').innerText()).trim().length > 0); + await openLesson(page, newest, 'lab', newest.minVersion); + }); + + await run(browser, 'quiz-incomplete-wrong-retry-and-persistence', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'quiz'); + await page.locator('#quiz-submit').click(); + assert.equal(await achievement(page, 'quiz'), 'false', 'incomplete quiz is not an achievement'); + await answerQuiz(page, first, false); + await stepTo(page, 'theory'); + await stepTo(page, 'quiz'); + assert.equal(await page.locator('#quiz-form input:checked').count(), first.quiz.length, 'answers survive tab navigation'); + await page.locator('#quiz-submit').click(); + await page.locator('#quiz-retry').waitFor({ state: 'visible' }); + assert.equal(await achievement(page, 'quiz'), 'false', 'wrong answers do not grant achievement'); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + await page.locator('#quiz-retry').click(); + await answerQuiz(page, first, true); + await page.locator('#quiz-submit').click(); + await expectEventually(() => achievement(page, 'quiz'), 'true', 'correct quiz is recorded'); + assert.equal(await achievement(page, 'lab'), 'false'); + assert.equal(await page.locator('#overall-completed').textContent(), '0', 'quiz alone is not a completed lesson'); + await page.reload(); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + assert.equal(await achievement(page, 'quiz'), 'true', 'quiz achievement survives reload'); + assert.equal(await achievement(page, 'lab'), 'false'); + }); + + await run(browser, 'browser-history-and-continue-learning', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first); + await stepTo(page, 'example'); + await page.locator(`#lesson-list [data-lesson-id="${other.id}"]`).click(); + await page.goBack(); + await expectEventually(() => page.locator('#lesson-title').textContent(), value => value.includes(first.title), 'back restores previous lesson'); + assert.equal(await page.locator('#tab-example').getAttribute('aria-selected'), 'true', 'back restores the previous step'); + await page.goForward(); + await expectEventually(() => page.locator('#lesson-title').textContent(), value => value.includes(other.title), 'forward restores next lesson'); + await page.locator(`#lesson-list [data-lesson-id="${first.id}"]`).click(); + await completeQuiz(page, first); + await page.locator('#lesson-search').fill('no-result-for-continue-qa'); + await page.locator('#continue-button').click(); + await expectEventually(() => page.locator('#lesson-title').textContent(), value => value.includes(first.title), 'continue restores the unfinished lesson'); + assert.equal(await page.locator('#lesson-search').inputValue(), '', 'continue recovers from incompatible search filters'); + assert.equal(await page.locator('#tab-lab').getAttribute('aria-selected'), 'true', 'quiz achiever continues to the outstanding code task'); + }); + + await run(browser, 'design-notes-persist-without-automatic-achievement', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'practice'); + const note = 'QA 설계 판단: null과 실패 계약을 설명합니다. 그대로 보존'; + await page.locator('#design-notes').fill(note); + await stepTo(page, 'lab'); + await stepTo(page, 'practice'); + assert.equal(await page.locator('#design-notes').inputValue(), note); + assert.equal(await achievement(page, 'quiz'), 'false'); + assert.equal(await achievement(page, 'lab'), 'false'); + await page.reload(); + await page.locator('#design-notes').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#design-notes').inputValue(), note); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + await screenshot(page, 'desktop-practice-long-content'); + }); + + await run(browser, 'code-drafts-lesson-isolation-reset-and-undo', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + const draft = `${firstExercise.template}\n// QA draft: 한글과 보존`; + await page.locator('#code-editor').fill(draft); + await stepTo(page, 'practice'); + await stepTo(page, 'lab'); + assert.equal(await page.locator('#code-editor').inputValue(), draft); + await page.locator(`#lesson-list [data-lesson-id="${other.id}"]`).click(); + await stepTo(page, 'lab'); + assert.equal(await page.locator('#code-editor').inputValue(), exercises.find(item => item.lessonId === other.id).template, 'another lesson keeps its own source'); + await page.locator(`#lesson-list [data-lesson-id="${first.id}"]`).click(); + await stepTo(page, 'lab'); + assert.equal(await page.locator('#code-editor').inputValue(), draft); + await page.reload(); + await page.locator('#code-editor').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#code-editor').inputValue(), draft, 'source survives reload'); + await page.locator('#lab-reset').click(); + assert.equal(await page.locator('#code-editor').inputValue(), firstExercise.template); + await page.locator('#lab-undo').click(); + assert.equal(await page.locator('#code-editor').inputValue(), draft, 'undo restores the exact source'); + const immediateDraft = `${draft}\n// immediate reload`; + await page.locator('#code-editor').fill(immediateDraft); + await page.reload(); + await page.locator('#code-editor').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#code-editor').inputValue(), immediateDraft, 'immediate reload flushes the latest edit'); + assert.equal(api.requests.length, 0, 'editing or reset never sends source'); + }); + + await run(browser, 'run-submit-and-two-achievement-completion', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first); + await completeQuiz(page, first); + await stepTo(page, 'lab'); + const submitted = `${firstExercise.template}\n// QA source snapshot`; + await page.locator('#code-editor').fill(submitted); + await page.locator('#run-code').click(); + await waitForOutput(page, 'passed'); + assert.equal(api.requests.at(-1).action, 'run'); + assert.equal(api.requests.at(-1).source, submitted); + assert.equal(api.requests.at(-1).exerciseId, firstExercise.id); + assert.match(api.requests.at(-1).requestId, /^[A-Za-z0-9_-]{1,80}$/); + assert.equal(await achievement(page, 'lab'), 'false', 'sample execution does not grant submit achievement'); + api.respond = request => ({ body: executionResult(request, { status: 'failed', checks: [{ name: '정상 입력', passed: true }, { name: 'null 입력', passed: false }], passed: 1 }) }); + await page.locator('#submit-code').click(); + await waitForOutput(page, 'failed'); + assert.equal(await achievement(page, 'lab'), 'false', 'partial success is not completion'); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + api.respond = request => ({ body: executionResult(request) }); + await page.locator('#submit-code').click(); + await waitForOutput(page, 'passed'); + assert.equal(api.requests.at(-1).action, 'submit'); + await expectEventually(() => achievement(page, 'lab'), 'true', 'all submit checks grant code achievement'); + assert.equal(await page.locator('#overall-completed').textContent(), '1'); + assert.equal(await page.locator(`#lesson-list [data-lesson-id="${first.id}"]`).getAttribute('data-complete'), 'true'); + await page.reload(); + await page.locator('#code-editor').waitFor({ state: 'visible' }); + assert.equal(await achievement(page, 'quiz'), 'true'); + assert.equal(await achievement(page, 'lab'), 'true'); + assert.equal(await page.locator('#overall-completed').textContent(), '1'); + await page.locator('#code-editor').fill(`${submitted}\n// edited after passing`); + await expectEventually(() => achievement(page, 'lab'), 'false', 'a changed draft is not the previously passed source'); + assert.equal(await achievement(page, 'quiz'), 'true'); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + }); + + await run(browser, 'execution-error-states-and-safe-output', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + for (const status of ['compile_error', 'runtime_error', 'timeout', 'provider_error']) { + const marker = `QA ${status} diagnostic`; + api.respond = request => ({ body: executionResult(request, { status, stdout: '', stderr: marker, compilerOutput: marker, checks: [], passed: 0, total: 0 }) }); + await page.locator('#submit-code').click(); + await waitForOutput(page, status); + assert.ok((await page.locator('#execution-output').innerText()).includes(marker), `${status} retains diagnostic evidence`); + assert.equal(await achievement(page, 'lab'), 'false'); + } + const payload = ''; + api.respond = request => ({ body: executionResult(request, { status: 'runtime_error', stdout: payload, stderr: payload, compilerOutput: payload, checks: [], passed: 0, total: 0 }) }); + await page.locator('#run-code').click(); + await waitForOutput(page, 'runtime_error'); + assert.ok((await page.locator('#execution-output').innerText()).includes(payload), 'provider output appears literally'); + assert.equal(await page.locator('#execution-output img, #execution-output script').count(), 0); + assert.equal(await page.evaluate(() => window.qaOutputExecuted), undefined); + assert.equal(await achievement(page, 'lab'), 'false'); + }); + + await run(browser, 'new-failing-submit-revokes-the-same-source-achievement', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first); + await completeQuiz(page, first); + await stepTo(page, 'lab'); + const source = await page.locator('#code-editor').inputValue(); + for (const status of ['failed', 'compile_error', 'runtime_error', 'timeout']) { + api.respond = request => ({ body: executionResult(request) }); + await page.locator('#submit-code').click(); + await waitForOutput(page, 'passed'); + assert.equal(await achievement(page, 'lab'), 'true'); + assert.equal(await page.locator('#overall-completed').textContent(), '1'); + api.respond = request => ({ body: executionResult(request, { status, checks: [{ name: '재검증 실패', passed: false }], passed: 0, total: 1 }) }); + await page.locator('#submit-code').click(); + await waitForOutput(page, status); + assert.equal(await page.locator('#code-editor').inputValue(), source, 'same source is re-evaluated'); + assert.equal(await achievement(page, 'lab'), 'false', `${status} revokes current code achievement`); + assert.equal(await achievement(page, 'quiz'), 'true'); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + } + await page.reload(); + await page.locator('#code-editor').waitFor({ state: 'visible' }); + assert.equal(await achievement(page, 'lab'), 'false', 'revocation survives reload'); + }); + + await run(browser, 'empty-source-and-utf8-payload-limit', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + await page.locator('#code-editor').fill(''); + await page.locator('#run-code').click(); + assert.match(await page.locator('#toast').textContent(), /코드를 입력/); + assert.equal(await page.locator('#code-editor').evaluate(element => document.activeElement === element), true, 'empty source returns focus to editor'); + assert.equal(api.requests.length, 0, 'empty source is rejected before network submission'); + await page.locator('#code-editor').fill(`class Solution { /* ${'한'.repeat(12_000)} */ }`); + await page.locator('#run-code').click(); + await waitForOutput(page, 'validation_error'); + assert.equal(api.requests.length, 0, 'oversized UTF-8 source is rejected before network submission'); + assert.equal(await achievement(page, 'lab'), 'false'); + }); + + await run(browser, 'http-network-and-invalid-json-errors', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + for (const status of [400, 413, 429, 502, 503]) { + const message = `QA provider refusal ${status}`; + api.respond = request => ({ status, body: { requestId: request.requestId, error: { code: `QA_${status}`, message } } }); + await page.locator('#submit-code').click(); + await expectEventually(() => page.locator('#execution-output').innerText(), value => value.includes(message), `HTTP ${status} error is explained`); + assert.notEqual(await page.locator('#execution-output').getAttribute('data-status'), 'passed'); + assert.equal(await achievement(page, 'lab'), 'false'); + } + for (const response of [{ abort: true }, { raw: '{ not-json' }]) { + const before = api.requests.length; + api.respond = () => response; + await page.locator('#submit-code').click(); + await expectEventually(() => api.requests.length, before + 1, 'failed request was attempted once'); + await expectEventually(() => page.locator('#submit-code').isEnabled(), true, 'execution controls recover after response failure'); + assert.notEqual(await page.locator('#execution-output').getAttribute('data-status'), 'passed'); + assert.equal(await achievement(page, 'lab'), 'false'); + } + }); + + await run(browser, 'unavailable-runtime-keeps-learning-and-draft-usable', async ({ context, page }) => { + const api = await mockApi(context, { runtime: { available: false, supportedTargets: [], notice: 'QA runtime unavailable' } }); + await openLesson(page, first, 'lab'); + await page.locator('#runtime-retry').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + const draft = `${firstExercise.template}\n// offline draft`; + await page.locator('#code-editor').fill(draft); + await stepTo(page, 'theory'); + await stepTo(page, 'lab'); + assert.equal(await page.locator('#code-editor').inputValue(), draft); + api.runtime = {}; + await page.locator('#runtime-retry').click(); + await expectEventually(() => page.locator('#run-code').isEnabled(), true, 'runtime retry restores execution capability'); + assert.equal(await page.locator('#code-editor').inputValue(), draft); + assert.equal(api.requests.length, 0, 'availability checks do not send the source'); + }); + + await run(browser, 'clipboard-success-and-denied-fallback', async ({ context, page }) => { + await mockApi(context); + // Mock only the clipboard API to avoid overwriting the user's OS clipboard. + await context.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { + writeText: async text => { + if (window.qaClipboardDenied) throw new DOMException('QA denied', 'NotAllowedError'); + window.qaClipboardText = text; + }, + } }); + }); + await openLesson(page, first, 'example'); + await page.locator('#copy-example').click(); + assert.equal(await page.evaluate(() => window.qaClipboardText), first.example.code); + await page.evaluate(() => { window.qaClipboardDenied = true; }); + await page.locator('#copy-example').click(); + assert.equal(await page.evaluate(() => window.getSelection().toString()), first.example.code, 'copy denial selects example text for manual copying'); + await stepTo(page, 'lab'); + await page.locator('#copy-code').click(); + const selection = await page.locator('#code-editor').evaluate(element => ({ start: element.selectionStart, end: element.selectionEnd, value: element.value, focused: document.activeElement === element })); + assert.equal(selection.start, 0); + assert.equal(selection.end, selection.value.length); + assert.equal(selection.focused, true); + assert.match(await page.locator('#toast').textContent(), /직접 복사/); + }); + + await run(browser, 'inconsistent-success-response-does-not-complete', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + for (const overrides of [ + { passed: 0, total: 0, checks: [] }, + { passed: 2, total: 2, checks: [{ name: '정상 입력', passed: true }, { name: '경계 입력', passed: false }] }, + { requestId: 'unrelated-response-id' }, + ]) { + const before = api.requests.length; + api.respond = request => ({ body: executionResult(request, overrides) }); + await page.locator('#submit-code').click(); + await expectEventually(() => api.requests.length, before + 1, 'submit request reaches mock'); + await expectEventually(() => page.locator('#submit-code').isEnabled(), true, 'invalid success settles'); + assert.equal(await achievement(page, 'lab'), 'false', `inconsistent response is not proof of success: ${JSON.stringify(overrides)}`); + } + }); + + for (const interaction of ['edit', 'lesson', 'version', 'cancel']) { + await run(browser, `pending-response-${interaction}-cannot-grant-achievement`, async ({ context, page }) => { + const gate = deferred(); + context.once('close', () => gate.resolve()); + const api = await mockApi(context, { respond: async request => { await gate.promise; return { body: executionResult(request, { stdout: 'old-response-proof' }) }; } }); + await openLesson(page, first, 'lab'); + await page.locator('#submit-code').click(); + await expectEventually(() => api.requests.length, 1, 'submission is pending'); + assert.equal(await page.locator('#run-code').isDisabled(), true, 'duplicate execution is blocked'); + assert.equal(await page.locator('#submit-code').isDisabled(), true, 'duplicate submission is blocked'); + const edited = `${firstExercise.template}\n// edited during execution`; + if (interaction === 'edit') await page.locator('#code-editor').fill(edited); + if (interaction === 'lesson') { + await page.locator(`#lesson-list [data-lesson-id="${other.id}"]`).click(); + await stepTo(page, 'lab'); + } + if (interaction === 'version') await page.locator('#version-select').selectOption('11'); + if (interaction === 'cancel') await page.locator('#cancel-execution').click(); + gate.resolve(); + await expectEventually(() => api.settled.length, 1, 'delayed mock response has settled'); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + assert.equal(await achievement(page, 'lab'), 'false', 'obsolete or cancelled response grants no achievement'); + assert.notEqual(await page.locator('#execution-output').getAttribute('data-status'), 'passed'); + if (interaction === 'edit') { + assert.equal(await page.locator('#code-editor').inputValue(), edited); + assert.equal(await page.locator('#execution-output').getAttribute('data-status'), 'outdated'); + } + if (interaction === 'lesson') { + assert.ok((await page.locator('#lesson-title').textContent()).includes(other.title)); + await page.locator(`#lesson-list [data-lesson-id="${first.id}"]`).click(); + assert.equal(await achievement(page, 'lab'), 'false', 'abandoned lesson was not silently passed'); + } + assert.equal(api.requests.length, 1, 'no automatic retry'); + }); + } + + await run(browser, 'corrupt-storage-falls-back-without-completion', async ({ context, page }) => { + await mockApi(context); + await context.addInitScript(({ key }) => localStorage.setItem(key, '{ invalid-json'), { key: storageKey }); + await openLesson(page, first, 'lab'); + assert.equal(await achievement(page, 'quiz'), 'false'); + assert.equal(await achievement(page, 'lab'), 'false'); + assert.equal(await page.locator('#code-editor').inputValue(), firstExercise.template); + await stepTo(page, 'theory'); + await noViewportOverflow(page); + }); + + await run(browser, 'blocked-storage-preserves-in-memory-interaction', async ({ context, page }) => { + await mockApi(context); + await context.addInitScript(() => { + Storage.prototype.getItem = function () { throw new DOMException('QA storage denied', 'SecurityError'); }; + Storage.prototype.setItem = function () { throw new DOMException('QA storage denied', 'SecurityError'); }; + }); + await openLesson(page, first, 'lab'); + const source = `${firstExercise.template}\n// in-memory QA draft`; + await page.locator('#code-editor').fill(source); + await stepTo(page, 'theory'); + await stepTo(page, 'lab'); + assert.equal(await page.locator('#code-editor').inputValue(), source); + const storageLabel = await page.locator('#storage-label').textContent(); + assert.ok(storageLabel.trim().length > 0 && storageLabel !== '이 브라우저에 저장', 'storage limitation is disclosed'); + await completeQuiz(page, first); + assert.equal(await achievement(page, 'quiz'), 'true', 'in-memory quiz remains usable'); + }); + + await run(browser, 'reset-records-cancel-and-confirm', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + const draft = `${firstExercise.template}\n// record-reset QA`; + await page.locator('#code-editor').fill(draft); + await completeQuiz(page, first); + await stepTo(page, 'lab'); + await page.locator('#version-select').selectOption('11'); + const before = await storedProgress(page); + await page.locator('#reset-progress-open').click(); + await page.locator('#reset-dialog').waitFor({ state: 'visible' }); + await page.locator('#reset-dialog [data-close-dialog="reset-dialog"]').click(); + assert.deepEqual(await storedProgress(page), before, 'cancel leaves all records intact'); + assert.equal(await page.locator('#code-editor').inputValue(), draft); + await page.locator('#reset-progress-open').click(); + await page.locator('#confirm-reset-progress').click(); + assert.equal(await achievement(page, 'quiz'), 'false'); + assert.equal(await achievement(page, 'lab'), 'false'); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + assert.equal(await page.locator('#version-select').inputValue(), '11', 'record reset retains selected version'); + await stepTo(page, 'lab'); + assert.equal(await page.locator('#code-editor').inputValue(), firstExercise.template); + await page.reload(); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + assert.equal(await achievement(page, 'quiz'), 'false'); + }); + + await run(browser, 'keyboard-tabs-radio-editor-and-dialog-focus', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first); + for (const selector of ['#version-select', '#lesson-search']) { + assert.equal(await page.locator(selector).evaluate(element => Boolean(element.labels?.length || element.getAttribute('aria-label') || element.getAttribute('aria-labelledby'))), true, `${selector} has an accessible label`); + } + await page.locator('#tab-theory').focus(); + await page.keyboard.press('ArrowRight'); + await expectEventually(() => page.locator('#tab-example').getAttribute('aria-selected'), 'true', 'right arrow changes the selected tab'); + assert.equal(await page.locator('#tab-example').evaluate(element => document.activeElement === element), true); + await page.keyboard.press('End'); + await expectEventually(() => page.locator('#tab-lab').getAttribute('aria-selected'), 'true', 'End selects the last tab'); + await page.keyboard.press('Home'); + await expectEventually(() => page.locator('#tab-theory').getAttribute('aria-selected'), 'true', 'Home selects the first tab'); + await stepTo(page, 'quiz'); + const group = page.locator(`#quiz-form input[name="quiz-${first.quiz[0].id}"]`); + await group.first().focus(); + await page.keyboard.press('Space'); + await page.keyboard.press('ArrowRight'); + assert.equal(await group.nth(1).isChecked(), true, 'native radio keyboard selection works'); + await stepTo(page, 'lab'); + await page.locator('#code-editor').focus(); + await page.keyboard.press('ControlOrMeta+End'); + await page.keyboard.insertText('\n// 키보드 입력 QA'); + assert.ok((await page.locator('#code-editor').inputValue()).endsWith('// 키보드 입력 QA')); + await page.keyboard.press('Tab'); + assert.notEqual(await page.evaluate(() => document.activeElement?.id), 'code-editor', 'plain textarea allows Tab to leave the editor'); + await page.locator('#guide-open').click(); + await page.locator('#guide-dialog').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#guide-dialog').evaluate(element => element.contains(document.activeElement)), true, 'dialog receives focus'); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#guide-dialog').isVisible(), false); + assert.equal(await page.locator('#guide-open').evaluate(element => document.activeElement === element), true, 'closing dialog restores opener focus'); + }); + + await run(browser, 'mobile-drawer-keyboard-and-lesson-navigation', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first); + await page.locator('#mobile-menu-toggle').click(); + assert.equal(await page.locator('#mobile-menu-toggle').getAttribute('aria-expanded'), 'true'); + assert.equal(await page.locator('#curriculum-sidebar').evaluate(element => element.contains(document.activeElement)), true, 'drawer receives focus'); + await screenshot(page, 'mobile-drawer-360', false); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#mobile-menu-toggle').getAttribute('aria-expanded'), 'false'); + assert.equal(await page.locator('#mobile-menu-toggle').evaluate(element => document.activeElement === element), true, 'drawer closure restores focus'); + await page.locator('#mobile-menu-toggle').click(); + await page.locator(`#lesson-list [data-lesson-id="${other.id}"]`).click(); + await expectEventually(() => page.locator('#lesson-title').textContent(), title => title.includes(other.title), 'drawer lesson navigation'); + assert.equal(await page.locator('#mobile-menu-toggle').getAttribute('aria-expanded'), 'false'); + await noViewportOverflow(page); + }, { viewport: { width: 360, height: 800 }, isMobile: true, hasTouch: true }); + + for (const viewport of [ + { width: 320, height: 720 }, + { width: 360, height: 800 }, + { width: 390, height: 844 }, + { width: 768, height: 1024 }, + { width: 1440, height: 1000 }, + { width: 1280, height: 600 }, + { width: 844, height: 390 }, + ]) { + await run(browser, `responsive-${viewport.width}x${viewport.height}`, async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first); + for (const step of ['theory', 'example', 'quiz', 'practice', 'lab']) { + await stepTo(page, step); + await noViewportOverflow(page); + if (viewport.width === 390 && ['quiz', 'practice', 'lab'].includes(step)) await screenshot(page, `mobile-${step === 'lab' ? 'ide' : step}`); + if ([320, 360, 768].includes(viewport.width) && ['theory', 'lab'].includes(step)) await screenshot(page, `viewport-${viewport.width}-${step}`); + } + for (const id of ['run-code', 'submit-code', 'lab-reset']) { + const rectangle = await page.locator(`#${id}`).boundingBox(); + assert.ok(rectangle && rectangle.width > 0 && rectangle.x >= -1 && rectangle.x + rectangle.width <= viewport.width + 1, `${id} stays inside viewport`); + } + if ([320, 360, 768, 1280, 844].includes(viewport.width)) { + await page.locator('#run-code').scrollIntoViewIfNeeded(); + await screenshot(page, `viewport-${viewport.width}x${viewport.height}-lab-controls`, false); + } + await page.locator('#code-editor').fill(`${firstExercise.template}\n// ${'long_identifier_'.repeat(80)}`); + await noViewportOverflow(page); + const internalScroll = await page.locator('#code-editor').evaluate(element => { + element.scrollLeft = 150; + return { width: element.clientWidth, scrollWidth: element.scrollWidth, left: element.scrollLeft }; + }); + assert.ok(internalScroll.scrollWidth > internalScroll.width && internalScroll.left > 0, 'long code scrolls inside editor'); + }, { viewport }); + } + + await run(browser, 'resize-orientation-and-two-hundred-percent-text', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + const draft = `${firstExercise.template}\n// resize persists input`; + await page.locator('#code-editor').fill(draft); + for (const viewport of [{ width: 390, height: 844 }, { width: 844, height: 390 }, { width: 390, height: 844 }, { width: 1440, height: 1000 }]) { + await page.setViewportSize(viewport); + assert.equal(await page.locator('#code-editor').inputValue(), draft); + await noViewportOverflow(page); + } + const before = await page.locator('html').evaluate(element => parseFloat(getComputedStyle(element).fontSize)); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + const after = await page.locator('html').evaluate(element => parseFloat(getComputedStyle(element).fontSize)); + assert.ok(after >= before * 1.99, 'root text size is doubled'); + for (const step of ['theory', 'example', 'quiz', 'practice', 'lab']) { + await stepTo(page, step); + await noViewportOverflow(page); + } + await screenshot(page, 'desktop-text-200-percent'); + await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })); + await screenshot(page, 'desktop-text-200-top', false); + await page.locator('#version-select').scrollIntoViewIfNeeded(); + await screenshot(page, 'desktop-text-200-version', false); + await page.setViewportSize({ width: 390, height: 844 }); + await noViewportOverflow(page); + await screenshot(page, 'mobile-text-200-percent'); + await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })); + await screenshot(page, 'mobile-text-200-top', false); + for (const [selector, label] of [['.lesson-tabs', 'tabs'], ['#code-editor', 'editor'], ['#run-code', 'controls'], ['#execution-output', 'output']]) { + await page.locator(selector).scrollIntoViewIfNeeded(); + await screenshot(page, `mobile-text-200-${label}`, false); + } + await page.locator('#mobile-menu-toggle').click(); + await screenshot(page, 'mobile-text-200-drawer', false); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#code-editor').inputValue(), draft); + }); + } finally { + await browser.close(); + } + console.log(JSON.stringify({ browser: process.env.PLAYWRIGHT_CHANNEL || 'chromium', browserVersion, node: process.version, mode: 'mocked API; browser emulation, not real devices', passed: results.filter(result => result.status === 'PASS').length, failed: results.filter(result => result.status === 'FAIL').length, artifacts: artifactRoot, results }, null, 2)); + if (!results.length || results.some(result => result.status === 'FAIL')) process.exitCode = 1; +} + +main().catch(error => { + console.error(error.stack); + process.exitCode = 1; +}); diff --git a/tests/content.test.mjs b/tests/content.test.mjs new file mode 100644 index 0000000..5b5dfb6 --- /dev/null +++ b/tests/content.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, access } from 'node:fs/promises'; +import { lessons } from '../dist/data/curriculum.js'; +import { exercises } from '../dist/data/exercises.js'; +import { exerciseDefinitions } from '../server/exercises.mjs'; +import { jobEvidence } from '../dist/data/job-evidence.js'; + +const versions = [8, 11, 17, 21]; +const referenceAnswers = JSON.parse(await readFile(new URL('../examples/solutions.json', import.meta.url), 'utf8')); +const exampleManifest = JSON.parse(await readFile(new URL('../examples/expected-output.json', import.meta.url), 'utf8')); + +test('12 published lessons have meaningful five-stage content and two distinct template modes', () => { + assert.equal(lessons.length, 12); + assert.equal(new Set(lessons.map((lesson) => lesson.id)).size, lessons.length); + assert.equal(exercises.length, lessons.length); + assert.equal(exerciseDefinitions.length, lessons.length); + assert.deepEqual(new Set(exercises.map((exercise) => exercise.mode)), new Set(['class', 'method'])); + for (const lesson of lessons) { + assert.ok(lesson.title && lesson.description && lesson.category); + assert.ok(versions.includes(lesson.minVersion), lesson.id); + assert.equal(lesson.preview, false, lesson.id); + assert.ok(['transition', 'advanced', 'both'].includes(lesson.track)); + assert.ok(lesson.goals.length >= 2 && lesson.theory.length >= 2 && lesson.quiz.length >= 2, lesson.id); + for (const section of lesson.theory) assert.ok(section.heading && section.text.length >= 35, lesson.id); + assert.ok(lesson.example.code.includes('class Main') && lesson.example.output && lesson.example.explanation, lesson.id); + assert.ok(lesson.practice.title && lesson.practice.scenario && lesson.practice.tasks.length >= 2 && lesson.practice.takeaway, lesson.id); + assert.ok(lesson.sources.length >= 1, lesson.id); + for (const source of lesson.sources) { + assert.equal(new URL(source.url).protocol, 'https:'); + assert.ok(source.title && source.edition && /^2026-10-12$/.test(source.checkedAt)); + } + for (const question of lesson.quiz) { + assert.ok(question.id && question.question && question.explanation); + assert.equal(question.options.length, 4); + assert.equal(new Set(question.options).size, 4); + assert.ok(Number.isInteger(question.correctIndex) && question.correctIndex >= 0 && question.correctIndex < 4); + } + const exercise = exercises.find((item) => item.id === lesson.exerciseId); + const definition = exerciseDefinitions.find((item) => item.id === lesson.exerciseId); + assert.ok(exercise && definition, lesson.id); + assert.equal(exercise.lessonId, lesson.id); + assert.equal(definition.lessonId, lesson.id); + assert.equal(exercise.minVersion, lesson.minVersion); + assert.equal(definition.minVersion, lesson.minVersion); + assert.equal(exercise.template, definition.template); + assert.ok(exercise.instructions.length >= 2 && exercise.rubric.length >= 1); + assert.ok(typeof referenceAnswers[exercise.id] === 'string', exercise.id); + assert.ok(definition.sampleChecks.length >= 1 && definition.checks.length > definition.sampleChecks.length); + assert.equal(new Set(definition.checks.map((check) => check.name)).size, definition.checks.length); + for (const sample of definition.sampleChecks) assert.ok(definition.checks.some((check) => check.name === sample.name && check.expression === sample.expression)); + } +}); + +test('authored expected examples exist and match the displayed code at every target version', async () => { + assert.equal(Object.keys(exampleManifest).length, lessons.length); + for (const [relativeFile, example] of Object.entries(exampleManifest)) { + assert.ok(versions.includes(example.minVersion)); + assert.equal(example.mainClass, 'Main'); + assert.ok(relativeFile.startsWith(`java${example.minVersion}/`) && !relativeFile.includes('..')); + const code = await readFile(new URL(`../examples/${relativeFile}`, import.meta.url), 'utf8'); + const matches = lessons.filter((lesson) => lesson.example.code.trim() === code.trim()); + assert.equal(matches.length, 1, relativeFile); + assert.equal(matches[0].minVersion, example.minVersion); + assert.equal(matches[0].example.output.trim(), example.stdout.trim()); + } + for (const version of versions) assert.ok(lessons.some((lesson) => lesson.minVersion === version)); +}); + +test('Java 8 examples exclude later standard collection APIs and preview syntax', () => { + for (const lesson of lessons.filter((item) => item.minVersion === 8)) { + assert.doesNotMatch(lesson.example.code, /List\.of\(|(? lesson.id === 'modern-apis'); + assert.equal(modern.minVersion, 11); + assert.equal(lessons.find((lesson) => lesson.id === 'java21').minVersion, 21); +}); + +test('public evidence has real URLs, dated status, and valid lesson associations', () => { + assert.ok(jobEvidence.length >= 5); + assert.equal(new Set(jobEvidence.map((item) => item.id)).size, jobEvidence.length); + const ids = new Set(lessons.map((lesson) => lesson.id)); + for (const item of jobEvidence) { + assert.ok(item.company && item.title && item.summary && item.status); + assert.equal(new URL(item.url).protocol, 'https:'); + assert.equal(item.checkedAt, '2026-10-12'); + assert.ok(['full', 'partial'].includes(item.access)); + assert.ok(item.technologies.length && item.lessonIds.every((id) => ids.has(id))); + } +}); + +test('public static assets do not contain reference solutions or trusted server tests', async () => { + const html = await readFile(new URL('../dist/index.html', import.meta.url), 'utf8'); + assert.doesNotMatch(html, /examples\/solutions\.json|server\/exercises\.mjs/); + for (const file of ['../dist/app.js', '../dist/styles.css']) await access(new URL(file, import.meta.url)); +}); From fe68aefb190bd4ae52262e4d8458e3f964038b95 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:22:23 +0900 Subject: [PATCH 03/29] =?UTF-8?q?feat(web):=20Java=20=EC=8B=A4=EC=8A=B5=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20API=EC=99=80=20Worker=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/verify.yml | 2 + README.md | 3 + docs/architecture.md | 4 +- docs/execution-contract.md | 139 +++++++++++++ docs/milestones.md | 2 +- scripts/build-worker.mjs | 20 ++ scripts/check-worker.mjs | 19 ++ server/executor.mjs | 244 +++++++++++++++++++++++ server/provider.mjs | 209 ++++++++++++++++++++ tests/executor.test.mjs | 367 +++++++++++++++++++++++++++++++++++ worker/index.js | 24 +++ 11 files changed, 1031 insertions(+), 2 deletions(-) create mode 100644 docs/execution-contract.md create mode 100644 scripts/build-worker.mjs create mode 100644 scripts/check-worker.mjs create mode 100644 server/executor.mjs create mode 100644 server/provider.mjs create mode 100644 tests/executor.test.mjs create mode 100644 worker/index.js diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index b522769..4181bfc 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -21,6 +21,8 @@ jobs: cache: npm - run: npm ci - run: npm run check + - run: npm run build + - run: npm run test:worker java: name: Java examples (${{ matrix.java }}) runs-on: ubuntu-latest diff --git a/README.md b/README.md index 5f597ab..6e85a82 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - [교육 로드맵](docs/education-roadmap.md) - [마일스톤](docs/milestones.md) - [실행 아키텍처](docs/architecture.md) +- [실행 API 계약과 제한](docs/execution-contract.md) - [공식 Java 호환 기준](docs/java-version-matrix.md) - [채용 근거](docs/job-evidence.md) - [구현된 코어 과정](docs/curriculum.md) @@ -20,3 +21,5 @@ Node22+로 `npm ci`, `npm run dev`를 실행하면 `http://127.0.0.1:4173`에서 학습실을 사용할 수 있습니다. 진도·코드·메모는 이 브라우저에 저장됩니다. 편집기는 일반 텍스트 편집기이며 자동 완성을 제공하지 않습니다. `npm run check`는 콘텐츠와 실행 API 검사, `npm run test:java`는 설치된 JDK의 예제·기준 답안 검증입니다. `npm run test:browser`는 서버가 실행 중일 때 모의 API로 기능을 검사합니다. 로컬 Edge는 `PLAYWRIGHT_CHANNEL=msedge`를 지정하고 CI는 Chromium을 사용합니다. 검사 정의와 실제 결과를 구분해 기록합니다. + +`npm run build`는 정적 UI와 실행 API를 하나의 Worker로 묶습니다. `npm run test:worker`는 빌드 산출물의 경로·CSP·개인 파일 제외·인증 필수 동작을 검사합니다. 네트워크 실행 테스트는 별도 명시적 모드에서만 호출하며 기본 테스트는 외부로 코드를 보내지 않습니다. diff --git a/docs/architecture.md b/docs/architecture.md index 020f0e8..a0c1e3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,9 @@ 실제 JVM은21(`openjdk-jdk-21+35`). `--release`로8/11/17/21의 언어/API 대상 컴파일을 검사해도 실행 JVM은21이다. 8/11/17의 실제 런타임 검증은 별도 CI와 후속 실행 서버에서 분리한다. 2026-10-12 실제 확인용 코드는 `--release\n8`과 package-private `class Main`으로 컴파일하고 `JAVA_EDU_OK`·`21-internal`을 출력했다. Java8 obsolete 경고와 종료0을 함께 확인했다. 이 결과는 전체 과제 검증을 대신하지 않는다. -주 파일은prog.java이므로 template의 Solution과 Main은 package-private다. 사용자 Solution과 trusted Main harness를 합쳐 실행한다. 호출·반환 타입·과제 개정판·컴파일 대상·샘플/제출 검사를 고정한다. 결과는 실행ID·작성 코드 snapshot에 연결한다. 단순 stdout 문구만으로 정답을 판정하지 않는다. 교육용 행동 검증은 악의적 제출에 대한 완전한 부정행위 방지 시스템을 뜻하지 않는다. +주 파일은prog.java이므로 template의 Solution과 Main은 package-private다. 사용자 Solution과 trusted Main harness를 합쳐 실행한다. 호출·반환 타입·컴파일 대상·샘플/제출 검사는 배포 소스에서 고정한다. 결과는 실행ID·작성 코드 snapshot에 연결한다. 단순 stdout 문구만으로 정답을 판정하지 않는다. 교육용 행동 검증은 악의적 제출에 대한 완전한 부정행위 방지 시스템을 뜻하지 않는다. + +초판의 완료 기록에는 과제 ID와 코드 snapshot을 사용하고 개정판을 별도 연결하지 않는다. 같은 ID의 검사·계약을 바꾸면 이전 성취가 복원될 수 있다. 후속 콘텐츠 개정 전에 revision 또는 계약 fingerprint를 추가하고 과거 성취를 재검증해야 한다. ## 데이터와 접근 diff --git a/docs/execution-contract.md b/docs/execution-contract.md new file mode 100644 index 0000000..deb36ff --- /dev/null +++ b/docs/execution-contract.md @@ -0,0 +1,139 @@ +# Java 실행 API 계약 + +기준일: 2026-10-12. 이 문서는 소유자 전용 Sites 프로토타입의 실행 범위를 설명한다. 공개 GitHub 저장소와 사이트의 접근 범위는 별개다. + +## 실제 실행과 대상 버전 + +- 제공자: Wandbox, 컴파일러 ID `openjdk-jdk-21+35`, 실제 JVM **21**. +- 과제의 `minVersion`에 따라 서버가 `--release 8`, `11`, `17`, `21` 중 하나를 지정한다. 문법·표준 API 대상 확인이며 JDK 8·11·17에서 실행했다는 의미가 아니다. +- 클라이언트가 컴파일러·명령·옵션·파일 경로·외부 의존성 URL을 지정할 수 없다. 현재 실행 과제는 표준 Java 단일 소스이며 Spring·Lombok·DB 프로젝트 실행을 지원한다고 표시하지 않는다. +- 컴파일러 목록의 언어·ID·raw 옵션 지원을 확인한다. 성공 목록은 5분, 실패는 15초 동안 프로세스 메모리에 캐시한다. 목록 확인 성공은 매 실행의 성공이나 SLA를 보장하지 않는다. + +공식 근거: [Wandbox 프로젝트](https://github.com/melpon/wandbox), [현재 API 형식](https://github.com/melpon/wandbox/blob/develop/feline/src/types.rs), [실행과 기록 처리](https://github.com/melpon/wandbox/blob/develop/feline/src/api_compile_json.rs), [Oracle javac 21](https://docs.oracle.com/en/java/javase/21/docs/specs/man/javac.html). + +## 신뢰 경계와 데이터 + +배포는 private Sites 게이트웨이가 사이트 접근 권한을 검사하고 `oai-authenticated-user-id`를 주입·덮어쓰는 환경을 전제로 한다. Worker의 `REQUIRE_IDENTITY` 기본값은 인증 필수다. `TRUSTED_IDENTITY_HEADER`를 설정한다면 위 고정 이름만 허용한다. 이메일은 읽거나 외부로 전송하지 않는다. + +헤더의 존재 확인만으로 일반 인터넷 공개 서비스의 인증을 구현한 것은 아니다. Worker를 게이트웨이 외부에 직접 노출하거나 사이트를 공개할 때에는 독립 인증·권한 검사와 공유 저장소 기반 제한을 먼저 마련해야 한다. 로컬 서버만 명시적으로 `{ REQUIRE_IDENTITY: false }`를 전달하며 루프백 주소에 바인딩한다. + +브라우저의 Origin이 있으면 요청 URL의 Origin과 정확히 일치해야 한다. `null` Origin과 cross-site 요청은 거절하고 CORS 허용 헤더를 제공하지 않는다. 비브라우저 요청의 인증은 Sites 게이트웨이의 책임이다. + +학습자가 실행을 누르면 작성한 Java 코드와 서버 검사 코드가 Wandbox에 전달된다. 쿠키·인증 헤더·사용자 ID·이메일은 전달하지 않는다. 요청은 `save: false`를 지정해 공개 permalink 생성을 요청하지 않는다. **이는 제공자가 소스·실행 정보를 기록하지 않는다는 뜻이 아니다.** 제공자의 현재 구현은 저장 옵션과 별도로 실행 코드를 기록한다. 실행 UI에서 전송 대상을 알리고 개인정보·비밀정보 입력을 피하도록 안내한다. 이 애플리케이션 코드는 사용자 소스나 출력을 콘솔에 기록하지 않는다. + +## 엔드포인트 + +### GET `/api/runtime` + +```json +{ + "provider": "Wandbox", + "available": true, + "runtime": { "javaVersion": 21, "compiler": "openjdk-jdk-21+35" }, + "supportedTargets": [8, 11, 17, 21], + "checkedAt": "2026-10-12T00:00:00.000Z", + "notice": "실제 실행 버전, 대상 컴파일, 외부 코드 전송 안내" +} +``` + +위 시간은 형식 예시다. 사용 불가 시 HTTP 200에 `available: false`, `supportedTargets: []`, `reason`을 반환한다. 인증과 Origin 오류는 별도의 401/403이다. + +### POST `/api/execute` + +Content-Type은 `application/json`이며 본문 전체는 UTF-8 기준 32 KiB 이하다. 본문의 알 수 없는 추가 필드는 실행에 사용하지 않는다. + +```json +{ + "exerciseId": "java-basics-lab", + "source": "class Solution { /* 학습자가 작성한 코드 */ }", + "action": "submit", + "requestId": "b70d96d6-6618-4489-a79e-8a4a2ce40343" +} +``` + +- `exerciseId`: 서버의 `exerciseDefinitions`에 등록된 ID. +- `action`: `run`은 예제 검사, `submit`은 전체 교육 검사. +- `requestId`: 영문·숫자·밑줄·하이픈 1~80자. 같은 사용자·같은 isolate에서 2분 내 중복 ID는 409이며 제공자에 재전송하지 않는다. 영구적인 전역 멱등성은 보장하지 않는다. +- `source`: `class Solution` 템플릿을 완성한 소스. package와 별도의 Main은 사용하지 않는다. 파일명은 제공자의 `prog.java`이므로 `public class Solution`도 사용하지 않는다. import 선언은 사용할 수 있다. + +일반 결과 형식: + +```json +{ + "requestId": "b70d96d6-6618-4489-a79e-8a4a2ce40343", + "status": "passed", + "provider": "Wandbox", + "runtimeVersion": 21, + "targetVersion": 8, + "stdout": "", + "stderr": "", + "compilerOutput": "", + "checks": [{ "name": "내용이 같은 별도 객체", "passed": true }], + "passed": 1, + "total": 1, + "durationMs": 1000 +} +``` + +검사 개수·시간은 형식 예시다. `status`는 `passed`, `failed`, `compile_error`, `runtime_error`, `timeout`, `provider_error` 중 하나다. 컴파일 경고는 실패로 처리하지 않는다. 검사 하나가 예외를 던지면 해당 검사는 실패하고 다음 검사를 수집한다. JVM의 비정상 종료, 누락·중복된 검사 마커는 통과 처리하지 않는다. + +입력 오류는 `{ "requestId": "유효한 ID가 있을 때", "error": { "code": "분류", "message": "안내" } }` 형태다. 제공자 오류에는 일반 결과 필드와 `error`를 함께 반환한다. + +| HTTP | 의미 | +| --- | --- | +| 200 | 실행 결과 또는 환경 조회. 테스트 실패·컴파일 오류도 결과로 반환 | +| 400 / 404 | 잘못된 입력 / 미등록 과제·API | +| 401 / 403 | 인증 누락 / Origin 거절 | +| 405 / 415 | 잘못된 HTTP 방식 / JSON 아닌 요청·압축 본문 | +| 409 / 429 | 중복 요청 ID / 실행 중·호출 한도 | +| 413 | 요청의 UTF-8 바이트 제한 초과 | +| 499 | HTTP 응답 대기 취소 | +| 500 | 서버가 작성한 과제 검사 설정 오류 | +| 502 / 503 | 제공자 응답 오류 / 연결·컴파일러·제공자 한도 문제 | +| 504 | 결과 대기 시간 초과 | + +## 검사와 보안 채점의 차이 + +검사 표현식은 서버의 교육 데이터에서만 선택한다. 요청 본문에 보내는 테스트·예상 결과·옵션은 사용하지 않는다. 서버는 사용자 소스 뒤에 Main을 붙이므로 사용자 소스의 기존 줄 번호를 보존한다. Main이 원본 코드 뒤에 있으므로 일부 오류는 추가된 검사 코드의 줄 번호로 표시될 수 있다. + +실행마다 128-bit 무작위 nonce를 만들고 시작·순서가 있는 검사·종료 마커를 확인한다. 다른 실행의 마커, 단순 `passed` 문자열, JSON 모양의 표준 출력은 정답 판정에 사용하지 않는다. 불완전한 프로토콜은 실패로 처리한다. + +**학습 피드백용 검사이며 부정행위에 강한 시험 채점기가 아니다.** 학습 코드와 검사기가 같은 JVM에서 실행되므로 악의적인 리플렉션·클래스 파일 접근·출력 조작을 완전히 막는다고 주장하지 않는다. 수료·성적·인증의 단독 근거로 사용하지 않는다. 공개 시험용 서비스는 별도 격리된 채점기와 공격 검증이 필요하다. + +## 제한·취소·부분 실패 + +- 요청/소스 32 KiB, 작성된 검사까지 합친 내부 소스 64 KiB. JSON 이스케이프와 메타데이터도 요청 바이트에 포함된다. +- 컴파일 결과 응답 본문 전체를 64 KiB까지 스트리밍 수집하며 초과 시 읽기를 중단한다. Wandbox가 출력 필드를 중복해 보내므로 실제 표시 가능한 표준 출력은 64 KiB보다 작을 수 있다. 제한 초과를 통과로 표시하지 않는다. +- 제공자 목록은 2 MiB 이내, 목록 조회 5초 이내. 목록 확인을 포함한 개별 컴파일 대기는 15초 이내. 요청 본문 읽기에는 별도 5초 제한이 있다. +- 같은 인증 ID는 동시에 하나, isolate 전체는 동시에 두 개, 60초당 여섯 번으로 제한한다. 큐와 자동 재시도는 두지 않는다. 프로토타입의 낮은 동시 사용량과 외부 제공자 부하를 고려한 보수적인 운영 제한이며 성능 벤치마크에서 도출한 값이 아니다. +- 메모리 기반 제한은 isolate 간 공유되지 않고 재시작 시 초기화된다. 전역 호출 제한·사용자별 할당량을 보장하지 않는다. 제공자도 자체 한도를 적용하며 429를 반환할 수 있다. +- 취소·15초 초과는 HTTP 대기를 종료하고 로컬 실행 슬롯을 반환한다. 원격 JVM 종료를 확인할 API가 없으므로 **원격 실행 중단을 보장하지 않는다.** 취소 직후 새 실행이 원격에서 이전 작업과 겹칠 가능성이 남는다. UI는 '응답 대기 취소'로 표시하고 오래된 결과를 반영하지 않아야 한다. +- CPU·메모리·프로세스·네트워크의 샌드박스 강제는 Wandbox가 담당한다. 이 코드가 제공자 내부 제한의 실제 수치나 적용 상태를 보증하지 않는다. 이 경계 때문에 프로토타입을 제한 없이 공개 실행 서비스로 바꾸지 않는다. +- 불명확한 네트워크 실패 뒤 자동 재시도를 하지 않는다. 결과를 받지 못한 경우에도 이미 원격 코드가 실행되었을 수 있다. + +## 검증 + +`node --test tests/executor.test.mjs`는 외부 요청 없이 native fetch mock으로 실행한다. 입력·바이트 제한·Origin·기본 인증·서버 검사 선택·nonce·마커 누락·경고·예외·출력 제한·제공자 오류·타임아웃·취소·중복·호출 제한·컴파일러 목록 TTL을 검사한다. + +실제 제공자 검사는 명시적으로 `JAVA_EDU_LIVE=1`을 지정해야 한다. 교육자가 작성한 공개용 정답과 의도적인 오류만 전송하며 여섯 번 순차 호출하고 재시도하지 않는다. 기본 CI에서 활성화하지 않는다. 제공자 IP 제한을 존중해 짧은 간격으로 반복하지 않는다. + +Windows의 검증 환경은 Node 22.17.0이다. 상속된 `NODE_TLS_REJECT_UNAUTHORIZED=0`이 최초 실행에서 발견되어 해당 결과만을 TLS 검증 근거로 사용하지 않는다. 인증서 검증을 켜고 시스템 CA를 사용한 GET을 재확인했다. 시스템 사설 CA가 필요한 이 환경에서 안전한 명령은 다음과 같다. + +```powershell +$env:NODE_TLS_REJECT_UNAUTHORIZED = '1' +$env:JAVA_EDU_LIVE = '1' +node --use-system-ca --test --test-name-pattern='^live provider:' tests/executor.test.mjs +``` + +2026-10-12 위 명령으로 인증서 검증을 켠 채 실제 여섯 번 순차 호출을 완료했다. + +| 검증 | 실제 결과 | +| --- | --- | +| `java-basics-lab`, 대상 8 | 6/6 검사 통과, 실제 JVM 21 | +| `modern-apis-lab`, 대상 11 | 5/5 검사 통과, 실제 JVM 21 | +| `records-sealed-lab`, 대상 17 | 6/6 검사 통과, 실제 JVM 21 | +| `java21-lab`, 대상 21 | 6/6 검사 통과, 실제 JVM 21 | +| 잘못된 Java 구문 | 실제 javac 오류와 `compile_error` 반환 | +| 항상 false를 반환하는 오답 | 3/6, `failed` 반환 | + +애플리케이션에는 인증서 검증을 해제하는 설정을 추가하지 않는다. 실제 배포 Worker의 네트워크·인증·학습 화면 검증은 통합 담당의 별도 출시 게이트다. 서버 단위·제공자 연결 검증이 배포 환경의 권한·격리 또는 브라우저 사용성 검증을 대신하지 않는다. diff --git a/docs/milestones.md b/docs/milestones.md index 1ba9a0a..91efffb 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -5,7 +5,7 @@ | ID | 범위 | 완료 게이트 | 현재 상태 | | --- | --- | --- | --- | | M1 | 기획·교육 로드맵·버전/실행/협업 계약 | 독립 문서 리뷰·형식/링크/범위 확인·커밋·원격 ref 확인 | 완료: `796b4cf` 원격 반영 | -| M2 | Java 코어12수업·과제·UI·진도·출처·CI | 콘텐츠 정합성·예제/답안·UI mock 검사·독립 리뷰·커밋/푸시 | 구현 중 | +| M2 | Java 코어12수업·과제·UI·진도·출처·CI | 콘텐츠 정합성·예제/답안·UI mock 검사·독립 리뷰·커밋/푸시 | 완료: `731b4a2` 원격 반영·CI 진행 | | M3 | 실제 JVM21 외부 실행·샘플/제출 API·Worker 패키징 | API 단위 검사·실제 코드 실행·오류/버전 계약·독립 리뷰·커밋/푸시 | 구현 중 | | M4 | 프로토타입 통합·배포 | 반응형·키보드·진도·오래된 응답 무효화·합성 화면·CI 확인·출시 커밋/푸시·배포 성공 | 대기 | | M5 | 별도 JVM8/11/17/21·의존성·DB·HTTP 실행 기반 | 배포 자원·격리·정리·호환 조합·통합 검사 | 후속 설계 | diff --git a/scripts/build-worker.mjs b/scripts/build-worker.mjs new file mode 100644 index 0000000..f68e510 --- /dev/null +++ b/scripts/build-worker.mjs @@ -0,0 +1,20 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { build } from 'esbuild'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; +const files = ['index.html', 'styles.css', 'app.js', 'data/curriculum.js', 'data/exercises.js', 'data/job-evidence.js']; +const assets = {}; +for (const file of files) assets[`/${file}`] = { body: await readFile(path.join(root, 'dist', file), 'utf8'), type: types[path.extname(file)] }; +await mkdir(path.join(root, 'worker'), { recursive: true }); +await writeFile(path.join(root, 'worker/assets.generated.mjs'), `export const assets = ${JSON.stringify(assets)};\n`); +await mkdir(path.join(root, 'dist/server'), { recursive: true }); +await build({ entryPoints: [path.join(root, 'worker/index.js')], outfile: path.join(root, 'dist/server/index.js'), bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: false }); +try { + const manifest = await readFile(path.join(root, '.openai/hosting.json'), 'utf8'); + await mkdir(path.join(root, 'dist/.openai'), { recursive: true }); + await writeFile(path.join(root, 'dist/.openai/hosting.json'), manifest); +} catch (error) { if (error.code !== 'ENOENT') throw error; } +console.log(`Worker bundle ready: ${path.join(root, 'dist/server/index.js')}`); diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs new file mode 100644 index 0000000..d6a986d --- /dev/null +++ b/scripts/check-worker.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import worker from '../dist/server/index.js'; + +const origin = 'https://java-edu.example'; +const html = await worker.fetch(new Request(origin), {}); +assert.equal(html.status, 200); +assert.match(html.headers.get('content-type'), /text\/html/); +assert.match(html.headers.get('content-security-policy'), /script-src 'self'/); +assert.ok((await html.text()).includes('javaedu')); +const head = await worker.fetch(new Request(origin, { method: 'HEAD' }), {}); +assert.equal(head.status, 200); +assert.equal(await head.text(), ''); +for (const file of ['/server/exercises.mjs', '/examples/solutions.json', '/.openai/hosting.json', '/missing']) { + assert.equal((await worker.fetch(new Request(origin + file), {})).status, 404); +} +assert.equal((await worker.fetch(new Request(origin, { method: 'POST' }), {})).status, 405); +const unauthenticated = await worker.fetch(new Request(origin + '/api/runtime'), { REQUIRE_IDENTITY: false }); +assert.equal(unauthenticated.status, 401, 'Worker must require gateway identity even if an env value disables it'); +console.log('PASS: built Worker static routing, CSP, private file exclusion and fail-closed identity'); diff --git a/server/executor.mjs b/server/executor.mjs new file mode 100644 index 0000000..2c1db13 --- /dev/null +++ b/server/executor.mjs @@ -0,0 +1,244 @@ +import { exerciseDefinitions } from './exercises.mjs'; +import { + BodyLimitError, ProviderError, PROVIDER_NAME, RUNTIME_VERSION, SUPPORTED_TARGETS, + createProvider, readLimitedText, withDeadline, +} from './provider.mjs'; + +export const MAX_REQUEST_BYTES = 32 * 1024; +export const MAX_SOURCE_BYTES = 32 * 1024; +const MAX_CHECKS = 64; +const MAX_RUNNER_BYTES = 64 * 1024; +const REQUEST_ID = /^[A-Za-z0-9_-]{1,80}$/; +const IDENTITY_HEADER = 'oai-authenticated-user-id'; +const encoder = new TextEncoder(); +const WINDOW_MS = 60_000; +const MAX_WINDOW_EXECUTIONS = 6; +const MAX_CONCURRENT_EXECUTIONS = 2; + +export function createExecutionState() { + return { providers: new WeakMap(), identities: new Map(), timestamps: [], active: 0 }; +} + +const defaultState = createExecutionState(); + +function json(body, status = 200, extraHeaders = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + ...extraHeaders, + }, + }); +} + +function errorResponse(status, code, message, requestId, headers) { + return json({ ...(requestId ? { requestId } : {}), error: { code, message } }, status, headers); +} + +function authorize(request, env) { + const url = new URL(request.url); + const origin = request.headers.get('origin'); + if ((origin !== null && origin !== url.origin) || request.headers.get('sec-fetch-site') === 'cross-site') { + return { response: errorResponse(403, 'origin_not_allowed', '같은 사이트에서 실행 요청을 보내세요.') }; + } + // The local adapter explicitly opts out. Deployment must use the private Sites + // gateway, which enforces access and overwrites this header at ingress. + if (env.REQUIRE_IDENTITY === false || env.REQUIRE_IDENTITY === 'false') return { identity: 'local' }; + if (env.TRUSTED_IDENTITY_HEADER && env.TRUSTED_IDENTITY_HEADER !== IDENTITY_HEADER) { + return { response: errorResponse(503, 'identity_configuration', '인증 환경 설정을 확인해야 합니다.') }; + } + const identity = request.headers.get(IDENTITY_HEADER)?.trim(); + if (!identity || identity.length > 256) return { response: errorResponse(401, 'authentication_required', '로그인한 사이트 소유자만 실행할 수 있습니다.') }; + return { identity }; +} + +function reserve(state, identity, requestId, now) { + state.timestamps = state.timestamps.filter((time) => now - time < WINDOW_MS); + for (const [key, value] of state.identities) { + if (!value.active && now - value.updatedAt >= WINDOW_MS * 2) state.identities.delete(key); + } + let entry = state.identities.get(identity); + if (!entry) { + if (state.identities.size >= 128) return { code: 'execution_busy', message: '실행 요청이 많습니다. 잠시 후 다시 시도하세요.', status: 429 }; + entry = { active: false, requests: new Map(), updatedAt: now }; + state.identities.set(identity, entry); + } + for (const [id, timestamp] of entry.requests) if (now - timestamp >= WINDOW_MS * 2) entry.requests.delete(id); + if (entry.requests.has(requestId)) return { code: 'duplicate_request', message: '이미 처리한 실행 요청입니다. 새 실행 요청으로 시도하세요.', status: 409 }; + if (entry.active || state.active >= MAX_CONCURRENT_EXECUTIONS || state.timestamps.length >= MAX_WINDOW_EXECUTIONS) { + return { code: 'execution_busy', message: '실행 중인 요청이 있거나 분당 실행 한도를 넘었습니다. 잠시 후 다시 시도하세요.', status: 429 }; + } + entry.active = true; + entry.updatedAt = now; + entry.requests.set(requestId, now); + state.active += 1; + state.timestamps.push(now); + return { release() { entry.active = false; state.active -= 1; } }; +} + +function selectedChecks(definition, action) { + const checks = action === 'run' ? definition.sampleChecks : definition.checks; + if (!SUPPORTED_TARGETS.includes(definition.minVersion) || !Array.isArray(checks) || checks.length < 1 || checks.length > MAX_CHECKS + || checks.some((check) => !check || typeof check.name !== 'string' || !check.name || check.name.length > 160 + || typeof check.expression !== 'string' || !check.expression.trim() || check.expression.length > 8_192)) { + throw new Error('Invalid authored exercise definition.'); + } + return checks; +} + +/** Expressions are authored server data. Never accept a test or expression from a request. */ +export function buildRunner(source, checks, nonce) { + if (!/^[a-f0-9]{32}$/.test(nonce)) throw new Error('Invalid internal execution nonce.'); + const marker = `__JAVA_EDU_${nonce}__`; + const lines = [ + source, + '', + 'class Main {', + ' public static void main(String[] args) {', + ' final java.io.PrintStream report = java.lang.System.out;', + ` report.println("\\n${marker}START");`, + ]; + checks.forEach((check, index) => { + lines.push( + ' try {', + ` boolean passed = (${check.expression});`, + ` report.println("\\n${marker}CHECK:${index}:" + passed);`, + ' } catch (java.lang.Throwable failure) {', + ` report.println("\\n${marker}CHECK:${index}:ERROR");`, + ` java.lang.System.err.println("검사 ${index + 1}: " + failure.getClass().getName() + ": " + failure.getMessage());`, + ' }', + ); + }); + lines.push(` report.println("\\n${marker}END");`, ' }', '}'); + const code = lines.join('\n'); + if (encoder.encode(code).byteLength > MAX_RUNNER_BYTES) throw new Error('Authored runner exceeds its limit.'); + return code; +} + +/** Require this invocation's complete, ordered protocol; arbitrary stdout is not a verdict. */ +export function parseCheckOutput(output, checks, nonce) { + const marker = `__JAVA_EDU_${nonce}__`; + const expected = [`${marker}START`, ...checks.map((_, index) => `${marker}CHECK:${index}:`), `${marker}END`]; + const seen = []; + const results = checks.map((check) => ({ name: check.name, passed: false })); + let malformed = false; + for (const line of output.split(/\r?\n/)) { + if (!line.startsWith(marker)) continue; + const position = seen.length; + if (position === 0 || position === expected.length - 1) { + if (line !== expected[position]) malformed = true; + } else { + const suffix = line.slice(expected[position]?.length || 0); + if (!expected[position] || !line.startsWith(expected[position]) || !['true', 'false', 'ERROR'].includes(suffix)) malformed = true; + else if (results[position - 1]) results[position - 1].passed = suffix === 'true'; + } + seen.push(line); + } + const complete = !malformed && seen.length === expected.length; + let stdout = output; + for (const line of seen) { + const escaped = line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // The harness deliberately adds one leading and trailing newline per marker. + // Remove only that framing, preserving the learner's spaces and line breaks. + stdout = stdout.replace(new RegExp(`(?:^|\\r?\\n)${escaped}(?:\\r?\\n|$)`), ''); + } + return { complete, checks: complete ? results : results.map((check) => ({ ...check, passed: false })), stdout }; +} + +function resultFromProvider(raw, checks, nonce) { + const parsed = parseCheckOutput(raw.stdout, checks, nonce); + let status; + if (raw.signal || raw.exitCode !== '0') { + const compilerFailed = !raw.signal && raw.compilerOutput && (/\berror\s*:|\b\d+ errors?\b/i.test(raw.compilerOutput) || (!raw.stdout && !raw.stderr)); + status = compilerFailed ? 'compile_error' : 'runtime_error'; + } else if (!parsed.complete) status = 'runtime_error'; + else status = parsed.checks.every((check) => check.passed) ? 'passed' : 'failed'; + const safeChecks = status === 'passed' || status === 'failed' ? parsed.checks : checks.map((check) => ({ name: check.name, passed: false })); + return { + status, + stdout: parsed.stdout, + stderr: raw.stderr || (status === 'runtime_error' && !parsed.complete ? '검사 실행이 끝나지 않았습니다. main 추가, 강제 종료, 출력 변경 또는 실행 오류를 확인하세요.' : ''), + compilerOutput: raw.compilerOutput, + checks: safeChecks, + passed: safeChecks.filter((check) => check.passed).length, + total: checks.length, + }; +} + +export async function handleApi(request, env = {}, options = {}) { + const url = new URL(request.url); + if (!['/api/runtime', '/api/execute'].includes(url.pathname)) return errorResponse(404, 'not_found', 'API 경로를 찾을 수 없습니다.'); + const expectedMethod = url.pathname === '/api/runtime' ? 'GET' : 'POST'; + if (request.method !== expectedMethod) return errorResponse(405, 'method_not_allowed', '지원하지 않는 요청 방식입니다.', undefined, { Allow: expectedMethod }); + const auth = authorize(request, env); + if (auth.response) return auth.response; + const state = options.state || defaultState; + const now = options.now || Date.now; + const fetchImpl = options.fetchImpl || globalThis.fetch; + let provider = state.providers.get(fetchImpl); + if (!provider) { + provider = createProvider({ fetchImpl, now, timeoutMs: options.providerTimeoutMs }); + state.providers.set(fetchImpl, provider); + } + if (url.pathname === '/api/runtime') return json(await provider.getRuntime()); + + const contentType = request.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); + if (contentType !== 'application/json' || (request.headers.has('content-encoding') && request.headers.get('content-encoding') !== 'identity')) { + return errorResponse(415, 'json_required', 'application/json 요청을 보내세요.'); + } + let input; + try { + input = JSON.parse(await withDeadline((signal) => readLimitedText(request, MAX_REQUEST_BYTES, signal), request.signal, 5_000)); + } catch (error) { + if (error instanceof BodyLimitError) return errorResponse(413, 'request_too_large', '요청은 UTF-8 기준 32 KiB 이하여야 합니다.'); + return errorResponse(400, 'invalid_json', 'JSON 요청을 읽을 수 없습니다.'); + } + if (!input || typeof input !== 'object' || Array.isArray(input)) return errorResponse(400, 'invalid_request', '실행 요청 객체가 필요합니다.'); + const { requestId, exerciseId, source, action } = input; + if (typeof requestId !== 'string' || !REQUEST_ID.test(requestId)) return errorResponse(400, 'invalid_request_id', '유효한 실행 요청 ID가 필요합니다.'); + if (typeof exerciseId !== 'string' || exerciseId.length > 100 || !['run', 'submit'].includes(action) + || typeof source !== 'string' || !source.trim() || source.includes('\u0000')) { + return errorResponse(400, 'invalid_request', '과제 ID, 코드와 실행 종류를 확인하세요.', requestId); + } + if (encoder.encode(source).byteLength > MAX_SOURCE_BYTES) return errorResponse(413, 'source_too_large', '코드는 UTF-8 기준 32 KiB 이하여야 합니다.', requestId); + const definitions = options.exercises || exerciseDefinitions; + const definition = definitions.find((item) => item.id === exerciseId); + if (!definition) return errorResponse(404, 'exercise_not_found', '등록되지 않은 실습입니다.', requestId); + let checks; + let code; + const nonce = crypto.randomUUID().replaceAll('-', ''); + try { + checks = selectedChecks(definition, action); + code = buildRunner(source, checks, nonce); + } catch { + return errorResponse(500, 'exercise_configuration', '실습 검사 설정을 확인해야 합니다.', requestId); + } + if (request.signal.aborted) return errorResponse(499, 'request_cancelled', '응답 대기를 취소했습니다.', requestId); + const reservation = reserve(state, auth.identity, requestId, now()); + if (!reservation.release) return errorResponse(reservation.status, reservation.code, reservation.message, requestId, { 'Retry-After': '60' }); + const started = now(); + const metadata = { + requestId, + provider: PROVIDER_NAME, + runtimeVersion: RUNTIME_VERSION, + targetVersion: definition.minVersion, + }; + try { + const raw = await provider.compile(code, definition.minVersion, request.signal); + return json({ ...metadata, ...resultFromProvider(raw, checks, nonce), durationMs: Math.max(0, now() - started) }); + } catch (error) { + const failure = error instanceof ProviderError ? error : new ProviderError('provider_error', '실행 결과를 처리할 수 없습니다.'); + return json({ + ...metadata, + status: failure.code === 'provider_timeout' ? 'timeout' : 'provider_error', + stdout: '', stderr: failure.message, compilerOutput: '', + checks: checks.map((check) => ({ name: check.name, passed: false })), + passed: 0, total: checks.length, durationMs: Math.max(0, now() - started), + error: { code: failure.code, message: failure.message }, + }, failure.httpStatus); + } finally { + reservation.release(); + } +} diff --git a/server/provider.mjs b/server/provider.mjs new file mode 100644 index 0000000..eca003c --- /dev/null +++ b/server/provider.mjs @@ -0,0 +1,209 @@ +/** The only outbound code-execution provider used by the private prototype. */ +export const PROVIDER_NAME = 'Wandbox'; +export const COMPILER = 'openjdk-jdk-21+35'; +export const RUNTIME_VERSION = 21; +export const SUPPORTED_TARGETS = Object.freeze([8, 11, 17, 21]); +export const MAX_PROVIDER_RESPONSE_BYTES = 64 * 1024; +export const PROVIDER_TIMEOUT_MS = 15_000; +const LIST_URL = 'https://wandbox.org/api/list.json'; +const COMPILE_URL = 'https://wandbox.org/api/compile.json'; +const MAX_LIST_BYTES = 2 * 1024 * 1024; +const encoder = new TextEncoder(); + +export class ProviderError extends Error { + constructor(code, message, httpStatus = 502) { + super(message); + this.name = 'ProviderError'; + this.code = code; + this.httpStatus = httpStatus; + } +} + +export class BodyLimitError extends Error { + constructor() { + super('Response or request body exceeds the byte limit.'); + this.name = 'BodyLimitError'; + } +} + +/** Read streams incrementally: Content-Length alone does not enforce a limit. */ +export async function readLimitedText(message, maxBytes, signal) { + const declaredSize = message.headers.get('content-length'); + if (declaredSize && /^\d+$/.test(declaredSize) && Number(declaredSize) > maxBytes) { + void message.body?.cancel().catch(() => {}); + throw new BodyLimitError(); + } + if (!message.body) return ''; + const reader = message.body.getReader(); + const chunks = []; + let length = 0; + const cancel = () => { void reader.cancel().catch(() => {}); }; + signal?.addEventListener('abort', cancel, { once: true }); + try { + while (true) { + if (signal?.aborted) throw signal.reason; + const { done, value } = await reader.read(); + if (signal?.aborted) throw signal.reason; + if (done) break; + length += value.byteLength; + if (length > maxBytes) { + cancel(); + throw new BodyLimitError(); + } + chunks.push(value); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } finally { + signal?.removeEventListener('abort', cancel); + reader.releaseLock(); + } +} + +/** Aborting the HTTP wait does not promise that the remote JVM was terminated. */ +export async function withDeadline(operation, parentSignal, timeoutMs) { + const controller = new AbortController(); + let rejectAbort; + const aborted = new Promise((_, reject) => { rejectAbort = reject; }); + const fail = (error) => { + if (controller.signal.aborted) return; + controller.abort(error); + rejectAbort(error); + }; + const onCancel = () => fail(new ProviderError('request_cancelled', '응답 대기를 취소했습니다. 외부 실행의 종료는 보장하지 않습니다.', 499)); + const timer = setTimeout(() => fail(new ProviderError('provider_timeout', '실행 결과 대기 시간이 초과되었습니다. 외부 실행의 종료는 보장하지 않습니다.', 504)), timeoutMs); + parentSignal?.addEventListener('abort', onCancel, { once: true }); + if (parentSignal?.aborted) onCancel(); + try { + return await Promise.race([ + Promise.resolve().then(() => { + if (controller.signal.aborted) throw controller.signal.reason; + return operation(controller.signal); + }), + aborted, + ]); + } finally { + clearTimeout(timer); + parentSignal?.removeEventListener('abort', onCancel); + } +} + +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + throw new ProviderError('provider_invalid_response', '실행 제공자의 응답 형식을 확인할 수 없습니다.'); + } +} + +function normalizeResult(value) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || typeof value.status !== 'string' || typeof value.program_output !== 'string') { + throw new ProviderError('provider_invalid_response', '실행 제공자의 결과 형식을 확인할 수 없습니다.'); + } + for (const name of ['signal', 'program_error', 'program_message', 'compiler_output', 'compiler_error', 'compiler_message']) { + if (value[name] !== undefined && typeof value[name] !== 'string') { + throw new ProviderError('provider_invalid_response', '실행 제공자의 결과 형식을 확인할 수 없습니다.'); + } + } + const result = { + exitCode: value.status, + signal: value.signal || '', + stdout: value.program_output, + stderr: value.program_error || '', + compilerOutput: value.compiler_message || [value.compiler_output, value.compiler_error].filter(Boolean).join('\n'), + }; + if (encoder.encode(result.stdout + result.stderr + result.compilerOutput).byteLength > MAX_PROVIDER_RESPONSE_BYTES) { + throw new ProviderError('provider_output_limit', '실행 출력이 64 KiB 제한을 초과했습니다. 출력을 줄여 다시 실행하세요.'); + } + return result; +} + +export function createProvider({ fetchImpl = globalThis.fetch, now = Date.now, timeoutMs = PROVIDER_TIMEOUT_MS } = {}) { + let cachedRuntime; + let expiresAt = 0; + let pendingRuntime; + + async function fetchJson(url, init, limit, signal) { + let response; + try { + // Never forward visitor identity, cookies, arbitrary URLs, or request headers. + response = await fetchImpl(url, { ...init, redirect: 'error', signal }); + } catch (error) { + if (signal.aborted) throw signal.reason; + throw new ProviderError('provider_unreachable', '실행 제공자에 연결할 수 없습니다.', 503); + } + if (!response.ok) { + void response.body?.cancel().catch(() => {}); + if (response.status === 429) throw new ProviderError('provider_rate_limit', '실행 제공자가 요청을 제한했습니다. 잠시 후 다시 실행하세요.', 503); + throw new ProviderError('provider_unavailable', '실행 제공자가 현재 요청을 처리할 수 없습니다.', 503); + } + try { + return parseJson(await readLimitedText(response, limit, signal)); + } catch (error) { + if (error instanceof BodyLimitError) throw new ProviderError('provider_output_limit', '실행 출력이 응답 제한을 초과했습니다. 출력을 줄여 다시 실행하세요.'); + if (error instanceof ProviderError) throw error; + if (signal.aborted) throw signal.reason; + throw new ProviderError('provider_invalid_response', '실행 제공자의 응답을 읽을 수 없습니다.'); + } + } + + async function getRuntime() { + if (cachedRuntime && now() < expiresAt) return cachedRuntime; + if (pendingRuntime) return pendingRuntime; + // Metadata is shared; one cancelled browser request must not cancel other readers. + pendingRuntime = (async () => { + let available = false; + let reason; + try { + const list = await withDeadline((signal) => fetchJson(LIST_URL, { method: 'GET', headers: { Accept: 'application/json' } }, MAX_LIST_BYTES, signal), undefined, Math.min(timeoutMs, 5_000)); + if (!Array.isArray(list)) throw new ProviderError('provider_invalid_response', '컴파일러 목록을 확인할 수 없습니다.'); + available = list.some((item) => item?.name === COMPILER && item.language === 'Java' && item['compiler-option-raw'] === true); + if (!available) reason = 'compiler_unavailable'; + } catch (error) { + reason = error instanceof ProviderError ? error.code : 'provider_unavailable'; + } + cachedRuntime = { + provider: PROVIDER_NAME, + available, + runtime: { javaVersion: RUNTIME_VERSION, compiler: COMPILER }, + supportedTargets: available ? [...SUPPORTED_TARGETS] : [], + checkedAt: new Date(now()).toISOString(), + notice: '실제 실행은 Java 21이며, 대상 버전은 --release로 문법·API를 검사합니다. 코드를 Wandbox로 전송하며 제공자가 코드를 기록할 수 있습니다. 개인정보·비밀정보를 입력하지 마세요.', + ...(reason ? { reason } : {}), + }; + expiresAt = now() + (available ? 5 * 60_000 : 15_000); + return cachedRuntime; + })(); + try { return await pendingRuntime; } finally { pendingRuntime = undefined; } + } + + async function compile(code, targetVersion, parentSignal) { + if (!SUPPORTED_TARGETS.includes(targetVersion)) throw new ProviderError('unsupported_target', '지원하지 않는 Java 대상 버전입니다.', 400); + return withDeadline(async (signal) => { + const runtime = await getRuntime(); + if (signal.aborted) throw signal.reason; + if (!runtime.available) throw new ProviderError('compiler_unavailable', '현재 Java 실행 환경을 사용할 수 없습니다.', 503); + const raw = await fetchJson(COMPILE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + compiler: COMPILER, + code, + 'compiler-option-raw': `--release\n${targetVersion}`, + options: '', + stdin: '', + save: false, + }), + }, MAX_PROVIDER_RESPONSE_BYTES, signal); + return normalizeResult(raw); + }, parentSignal, timeoutMs); + } + + return { getRuntime, compile }; +} diff --git a/tests/executor.test.mjs b/tests/executor.test.mjs new file mode 100644 index 0000000..a9f909d --- /dev/null +++ b/tests/executor.test.mjs @@ -0,0 +1,367 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { buildRunner, createExecutionState, handleApi, MAX_REQUEST_BYTES, parseCheckOutput } from '../server/executor.mjs'; +import { COMPILER, createProvider, MAX_PROVIDER_RESPONSE_BYTES, ProviderError } from '../server/provider.mjs'; +import { exerciseDefinitions } from '../server/exercises.mjs'; + +const localEnv = { REQUIRE_IDENTITY: false }; +const definitions = [{ + id: 'test-lab', lessonId: 'test', minVersion: 8, template: 'class Solution {}', mode: 'method', + sampleChecks: [{ name: 'sample', expression: 'new Solution().value() == 1' }], + checks: [{ name: 'first', expression: 'new Solution().value() == 1' }, { name: 'second', expression: 'new Solution().value() > 0' }], +}]; +const compilerList = [{ name: COMPILER, language: 'Java', 'compiler-option-raw': true }]; +const nonce = 'abcdef0123456789abcdef0123456789'; + +function request(body = {}, init = {}) { + return new Request('https://java-edu.example/api/execute', { + method: 'POST', headers: { 'Content-Type': 'application/json', ...init.headers }, + body: JSON.stringify({ exerciseId: 'test-lab', source: 'class Solution { int value() { return 1; } }', action: 'submit', requestId: crypto.randomUUID(), ...body }), + ...init, ...(init.headers ? { headers: { 'Content-Type': 'application/json', ...init.headers } } : {}), + }); +} + +function rawResult(overrides = {}) { + return { status: '0', signal: '', compiler_output: '', compiler_error: '', compiler_message: '', program_output: '', program_error: '', program_message: '', ...overrides }; +} + +function protocol(payload, verdicts) { + const prefix = payload.code.match(/__JAVA_EDU_[a-f0-9]{32}__/)[0]; + const count = [...payload.code.matchAll(/CHECK:\d+:\"/g)].length; + return `${prefix}START\n${Array.from({ length: count }, (_, index) => `${prefix}CHECK:${index}:${verdicts?.[index] ?? 'true'}`).join('\n')}\n${prefix}END\n`; +} + +function harness(compilation = (payload) => rawResult({ program_output: protocol(payload) })) { + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url, init }); + if (url.endsWith('/list.json')) return Response.json(compilerList); + const payload = JSON.parse(init.body); + const result = await compilation(payload, init); + return result instanceof Response ? result : Response.json(result); + }; + const options = { fetchImpl, state: createExecutionState(), exercises: definitions }; + return { calls, options, call: (input, env = localEnv) => handleApi(input, env, options) }; +} + +test('default authentication fails closed before any upstream call', async () => { + const h = harness(); + assert.equal((await h.call(request(), {})).status, 401); + assert.equal(h.calls.length, 0); + assert.equal((await h.call(request({}, { headers: { 'oai-authenticated-user-id': 'owner-id' } }), { REQUIRE_IDENTITY: true })).status, 200); + assert.equal((await h.call(request(), { REQUIRE_IDENTITY: true, TRUSTED_IDENTITY_HEADER: 'user-supplied-id' })).status, 503); +}); + +test('foreign origins and null origins cannot trigger execution', async () => { + const h = harness(); + for (const origin of ['https://other.example', 'null', 'https://java-edu.example.evil']) { + assert.equal((await h.call(request({}, { headers: { Origin: origin } }))).status, 403); + } + assert.equal((await h.call(request({}, { headers: { 'Sec-Fetch-Site': 'cross-site' } }))).status, 403); + assert.equal(h.calls.length, 0); + assert.equal((await h.call(request({}, { headers: { Origin: 'https://java-edu.example' } }))).status, 200); +}); + +test('invalid JSON, fields, method, content type and unknown exercise reject before provider', async () => { + const h = harness(); + const cases = [ + [new Request('https://java-edu.example/api/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{broken' }), 400], + [request({ requestId: '' }), 400], + [request({ requestId: 123 }), 400], + [request({ exerciseId: 'unknown-lab' }), 404], + [request({ action: 'shell' }), 400], + [request({ source: ' ' }), 400], + [request({ source: '\u0000' }), 400], + [request({}, { headers: { 'Content-Type': 'text/plain' } }), 415], + [request({}, { headers: { 'Content-Encoding': 'gzip' } }), 415], + [new Request('https://java-edu.example/api/execute'), 405], + [new Request('https://java-edu.example/api/unknown'), 404], + ]; + for (const [input, expected] of cases) assert.equal((await h.call(input)).status, expected); + assert.equal(h.calls.length, 0); +}); + +test('UTF-8 request limit holds with missing or forged Content-Length', async () => { + const h = harness(); + for (const headers of [{}, { 'Content-Length': '1' }]) { + const response = await h.call(request({ source: '가'.repeat(12_000) }, { headers })); + assert.equal(response.status, 413); + } + const body = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(MAX_REQUEST_BYTES + 1).fill(32)); controller.close(); } }); + const streamed = new Request('https://java-edu.example/api/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, duplex: 'half' }); + assert.equal((await h.call(streamed)).status, 413); + assert.equal(h.calls.length, 0); +}); + +test('compile options, endpoint, tests and privacy fields are server controlled', async () => { + const h = harness(); + const response = await h.call(request({ + compiler: 'bash', targetVersion: 21, checks: [{ expression: 'true' }], + url: 'http://localhost/admin', options: '--evil', save: true, + }, { headers: { Cookie: 'secret=session', Authorization: 'Bearer private', 'oai-authenticated-user-id': 'private-user' } })); + const result = await response.json(); + assert.equal(result.status, 'passed'); + assert.equal(result.targetVersion, 8); + assert.equal(result.runtimeVersion, 21); + assert.equal(result.total, 2); + const call = h.calls.find((item) => item.init.method === 'POST'); + assert.equal(call.url, 'https://wandbox.org/api/compile.json'); + assert.equal(call.init.redirect, 'error'); + const sent = JSON.parse(call.init.body); + assert.deepEqual(Object.keys(sent).sort(), ['code', 'compiler', 'compiler-option-raw', 'options', 'save', 'stdin'].sort()); + assert.equal(sent.compiler, COMPILER); + assert.equal(sent['compiler-option-raw'], '--release\n8'); + assert.equal(sent.save, false); + assert.match(sent.code, /new Solution\(\)\.value\(\) > 0/); + assert.deepEqual(call.init.headers, { 'Content-Type': 'application/json', Accept: 'application/json' }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.has('access-control-allow-origin'), false); +}); + +test('run uses sample checks, submit uses full checks and nonce changes per request', async () => { + const h = harness(); + const sample = await (await h.call(request({ action: 'run' }))).json(); + const submitted = await (await h.call(request())).json(); + assert.equal(sample.total, 1); + assert.equal(submitted.total, 2); + const nonces = h.calls.filter((call) => call.init.method === 'POST').map((call) => JSON.parse(call.init.body).code.match(/__JAVA_EDU_([a-f0-9]{32})__/)[1]); + assert.notEqual(nonces[0], nonces[1]); +}); + +test('javac warnings do not turn successful execution into a compile error', async () => { + const h = harness((payload) => rawResult({ compiler_error: 'warning: source value 8 is obsolete\n', compiler_message: 'warning: source value 8 is obsolete\n', program_output: protocol(payload) })); + const result = await (await h.call(request())).json(); + assert.equal(result.status, 'passed'); + assert.match(result.compilerOutput, /warning/); +}); + +test('failing checks remain failed even when arbitrary stdout claims success', async () => { + const h = harness((payload) => rawResult({ program_output: `{"passed":999,"total":999}\n${protocol(payload, ['true', 'false'])}ALL PASSED\n` })); + const result = await (await h.call(request())).json(); + assert.equal(result.status, 'failed'); + assert.equal(result.passed, 1); + assert.equal(result.total, 2); + assert.match(result.stdout, /ALL PASSED/); +}); + +test('wrong nonce, truncated protocol, duplicate markers and out-of-order checks cannot pass', () => { + const prefix = `__JAVA_EDU_${nonce}__`; + const correct = `${prefix}START\n${prefix}CHECK:0:true\n${prefix}CHECK:1:true\n${prefix}END\n`; + assert.equal(parseCheckOutput(correct, definitions[0].checks, nonce).complete, true); + const invalid = [ + correct.replaceAll(nonce, '0'.repeat(32)), + correct.replace(`${prefix}END\n`, ''), + `${correct}${prefix}CHECK:0:true\n`, + correct.replace('CHECK:0:true', 'CHECK:1:true'), + correct.replace('CHECK:0:true', 'CHECK:0:true EXTRA'), + correct.replace(`${prefix}START`, `${prefix}END`), + ]; + for (const output of invalid) { + const result = parseCheckOutput(output, definitions[0].checks, nonce); + assert.equal(result.complete, false); + assert.equal(result.checks.some((check) => check.passed), false); + } +}); + +test('missing runner completion is runtime_error, not a false positive', async () => { + const h = harness(() => rawResult({ program_output: 'passed 2/2\n' })); + const result = await (await h.call(request())).json(); + assert.equal(result.status, 'runtime_error'); + assert.equal(result.passed, 0); +}); + +test('protocol removal preserves learner output whitespace exactly', () => { + const prefix = `__JAVA_EDU_${nonce}__`; + const output = `\n${prefix}START\n kept\t\n\n${prefix}CHECK:0:true\n tail \n${prefix}CHECK:1:true\n\n${prefix}END\n`; + const parsed = parseCheckOutput(output, definitions[0].checks, nonce); + assert.equal(parsed.complete, true); + assert.equal(parsed.stdout, ' kept\t\n tail '); +}); + +test('compile error, runtime exception and per-check exception have distinct results', async () => { + const compile = harness(() => rawResult({ status: '1', compiler_error: 'prog.java:2: error: cannot find symbol' })); + assert.equal((await (await compile.call(request())).json()).status, 'compile_error'); + const runtime = harness(() => rawResult({ status: '1', compiler_error: 'warning: old target', program_error: 'Exception in thread main' })); + assert.equal((await (await runtime.call(request())).json()).status, 'runtime_error'); + const partial = harness((payload) => rawResult({ program_output: protocol(payload, ['ERROR', 'true']), program_error: '검사 1: java.lang.IllegalStateException' })); + const result = await (await partial.call(request())).json(); + assert.equal(result.status, 'failed'); + assert.equal(result.passed, 1); +}); + +test('nonzero process exit or signal cannot pass even after all check markers', async () => { + for (const failure of [{ status: '1' }, { signal: 'Killed' }]) { + const h = harness((payload) => rawResult({ ...failure, program_output: protocol(payload) })); + const result = await (await h.call(request())).json(); + assert.equal(result.status, 'runtime_error'); + assert.equal(result.passed, 0); + } +}); + +test('oversized provider output is stopped and never returned as a passed result', async () => { + const h = harness(() => new Response('x'.repeat(MAX_PROVIDER_RESPONSE_BYTES + 1), { headers: { 'Content-Length': '1' } })); + const response = await h.call(request()); + const result = await response.json(); + assert.equal(response.status, 502); + assert.equal(result.status, 'provider_error'); + assert.equal(result.error.code, 'provider_output_limit'); + assert.equal(result.stdout, ''); +}); + +test('provider malformed JSON, invalid output fields, rate limiting and server errors fail closed', async () => { + const upstreamResults = [ + new Response('not json'), + Response.json({ status: 0, program_output: 'passed' }), + Response.json({ status: '0', program_output: [], program_error: '' }), + new Response('rate limited', { status: 429 }), + new Response('private provider stack trace', { status: 500 }), + ]; + for (const upstream of upstreamResults) { + const h = harness(() => upstream); + const response = await h.call(request()); + const result = await response.json(); + assert.ok([502, 503].includes(response.status)); + assert.equal(result.status, 'provider_error'); + assert.equal(result.passed, 0); + assert.doesNotMatch(JSON.stringify(result), /private provider stack trace/); + assert.equal(h.calls.filter((call) => call.init.method === 'POST').length, 1); + } +}); + +test('upstream timeout aborts waiting, does not retry, and releases the local concurrency slot', async () => { + let hang = true; + const h = harness((payload) => hang ? new Promise(() => {}) : rawResult({ program_output: protocol(payload) })); + h.options.providerTimeoutMs = 25; + const response = await h.call(request()); + const result = await response.json(); + assert.equal(response.status, 504); + assert.equal(result.status, 'timeout'); + assert.match(result.stderr, /종료는 보장하지/); + assert.equal(h.calls.filter((call) => call.init.method === 'POST').length, 1); + hang = false; + assert.equal((await (await h.call(request())).json()).status, 'passed'); +}); + +test('client cancellation aborts waiting without promising upstream process cancellation', async () => { + const controller = new AbortController(); + const h = harness(() => { controller.abort(); return new Promise(() => {}); }); + const response = await h.call(request({}, { signal: controller.signal })); + const result = await response.json(); + assert.equal(response.status, 499); + assert.equal(result.error.code, 'request_cancelled'); + assert.equal(h.options.state.active, 0); +}); + +test('in-flight, duplicate and rolling-window requests do not fan out upstream', async () => { + let resolveCompilation; + const h = harness((payload) => new Promise((resolve) => { resolveCompilation = () => resolve(rawResult({ program_output: protocol(payload) })); })); + const first = h.call(request({ requestId: 'same-request' })); + while (!resolveCompilation) await new Promise((resolve) => setImmediate(resolve)); + assert.equal((await h.call(request({ requestId: 'other-request' }))).status, 429); + assert.equal((await h.call(request({ requestId: 'same-request' }))).status, 409); + resolveCompilation(); + assert.equal((await first).status, 200); + assert.equal((await h.call(request({ requestId: 'same-request' }))).status, 409); + assert.equal(h.calls.filter((call) => call.init.method === 'POST').length, 1); + + const rate = harness(); + let now = 100_000; + rate.options.now = () => now; + for (let index = 0; index < 6; index += 1) assert.equal((await rate.call(request({ requestId: `r-${index}` }))).status, 200); + assert.equal((await rate.call(request())).status, 429); + assert.equal(rate.calls.filter((call) => call.init.method === 'POST').length, 6); + now += 60_001; + assert.equal((await rate.call(request())).status, 200); +}); + +test('runtime availability reflects compiler inventory and caches only for its TTL', async () => { + let now = 100_000; + let listed = true; + let calls = 0; + const provider = createProvider({ now: () => now, fetchImpl: async () => { calls += 1; return Response.json(listed ? compilerList : []); } }); + const first = await provider.getRuntime(); + assert.equal(first.available, true); + assert.deepEqual(first.supportedTargets, [8, 11, 17, 21]); + assert.equal(first.runtime.javaVersion, 21); + listed = false; + assert.equal((await provider.getRuntime()).available, true); + assert.equal(calls, 1); + now += 300_001; + const missing = await provider.getRuntime(); + assert.equal(missing.available, false); + assert.deepEqual(missing.supportedTargets, []); + await assert.rejects(provider.compile('class Main {}', 8), (error) => error instanceof ProviderError && error.code === 'compiler_unavailable'); + assert.equal(calls, 2); + listed = true; + now += 15_001; + assert.equal((await provider.getRuntime()).available, true); +}); + +test('compiler without raw release support is unavailable; unsupported targets never fetch', async () => { + let calls = 0; + const provider = createProvider({ fetchImpl: async () => { calls += 1; return Response.json([{ ...compilerList[0], 'compiler-option-raw': false }]); } }); + await assert.rejects(provider.compile('class Main {}', 22), (error) => error.code === 'unsupported_target'); + assert.equal(calls, 0); + assert.equal((await provider.getRuntime()).available, false); +}); + +test('runner preserves learner source line numbers and supports Java 8 checks', () => { + const source = 'class Solution {\n int value() { return 1; }\n}'; + const code = buildRunner(source, definitions[0].checks, nonce); + assert.equal(code.slice(0, source.length), source); + assert.match(code, /class Main \{/); + assert.match(code, /java\.lang\.Throwable/); + assert.doesNotMatch(code, /public class Main/); + assert.throws(() => buildRunner(source, definitions[0].checks, 'invalid')); +}); + +test('every authored exercise has nonempty sample and submission checks with supported targets', () => { + assert.ok(exerciseDefinitions.length >= 1); + const ids = new Set(); + for (const definition of exerciseDefinitions) { + assert.equal(ids.has(definition.id), false); + ids.add(definition.id); + assert.ok([8, 11, 17, 21].includes(definition.minVersion)); + for (const checks of [definition.sampleChecks, definition.checks]) { + assert.ok(checks.length > 0); + assert.ok(buildRunner(definition.template, checks, nonce).length > definition.template.length); + } + } +}); + +test('live provider: actual Java 21 executes four release targets and reports failure cases', { skip: process.env.JAVA_EDU_LIVE !== '1' }, async (t) => { + // Opt-in only: transmits authored, non-sensitive exercise solutions to Wandbox. + // Six sequential POSTs, no retries, no parallelism. Never run in default CI. + const solutions = JSON.parse(await readFile(new URL('../examples/solutions.json', import.meta.url), 'utf8')); + const state = createExecutionState(); + for (const target of [8, 11, 17, 21]) { + await t.test(`--release ${target}, actual JVM 21`, async () => { + const definition = exerciseDefinitions.find((item) => item.minVersion === target); + assert.ok(definition, `Authored exercise for Java ${target} is required.`); + const response = await handleApi(request({ exerciseId: definition.id, source: solutions[definition.id] }), localEnv, { state }); + const result = await response.json(); + assert.equal(response.status, 200, JSON.stringify(result)); + assert.equal(result.status, 'passed', JSON.stringify(result)); + assert.equal(result.passed, definition.checks.length); + assert.equal(result.targetVersion, target); + assert.equal(result.runtimeVersion, 21); + console.info(`LIVE ${definition.id}: ${result.passed}/${result.total}, --release ${target}, JVM ${result.runtimeVersion}, ${result.durationMs} ms`); + }); + } + const definition = exerciseDefinitions.find((item) => item.id === 'java-basics-lab'); + await t.test('compiler diagnostic from actual javac', async () => { + const response = await handleApi(request({ exerciseId: definition.id, source: 'class Solution { THIS_IS_NOT_JAVA }' }), localEnv, { state }); + const result = await response.json(); + assert.equal(result.status, 'compile_error', JSON.stringify(result)); + assert.match(result.compilerOutput, /error/i); + console.info(`LIVE compile_error: ${result.durationMs} ms`); + }); + await t.test('wrong implementation fails actual checks', async () => { + const response = await handleApi(request({ exerciseId: definition.id, source: 'class Solution { public boolean sameCode(String a, String b) { return false; } }' }), localEnv, { state }); + const result = await response.json(); + assert.equal(result.status, 'failed', JSON.stringify(result)); + assert.ok(result.passed < result.total); + console.info(`LIVE failed: ${result.passed}/${result.total}, ${result.durationMs} ms`); + }); +}); diff --git a/worker/index.js b/worker/index.js new file mode 100644 index 0000000..18ee8e5 --- /dev/null +++ b/worker/index.js @@ -0,0 +1,24 @@ +import { handleApi } from '../server/executor.mjs'; +import { assets } from './assets.generated.mjs'; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname.startsWith('/api/')) { + return handleApi(request, { ...env, REQUIRE_IDENTITY: true, TRUSTED_IDENTITY_HEADER: 'oai-authenticated-user-id' }); + } + if (!['GET', 'HEAD'].includes(request.method)) return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } }); + const pathname = url.pathname === '/' ? '/index.html' : url.pathname; + const asset = assets[pathname]; + if (!asset) return new Response('Not found', { status: 404 }); + return new Response(request.method === 'HEAD' ? null : asset.body, { + headers: { + 'Content-Type': asset.type, + 'Cache-Control': 'no-cache', + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'", + }, + }); + }, +}; From 165e113cc77bb56f0bfab78cf28f94de47f022cd Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:31:56 +0900 Subject: [PATCH 04/29] =?UTF-8?q?fix(web):=20=ED=99=95=EB=8C=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EA=B3=BC=20=ED=99=9C=EC=84=B1=20=ED=83=AD=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=9B=84=20=ED=94=84=EB=A1=9C=ED=86=A0?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + dist/styles.css | 4 +- docs/milestones.md | 6 +-- docs/qa-report.md | 116 +++++++++++++++++++++++++++++++++++++++++++ docs/verification.md | 38 ++++++++++++++ tests/browser.mjs | 56 +++++++++++++++++++++ 6 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 docs/qa-report.md create mode 100644 docs/verification.md diff --git a/README.md b/README.md index 6e85a82..f39add5 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ - [채용 근거](docs/job-evidence.md) - [구현된 코어 과정](docs/curriculum.md) - [협업 규칙](CONTRIBUTING.md) +- [실제 검증과 미확인 범위](docs/verification.md) +- [독립 브라우저 QA](docs/qa-report.md) 수업은 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩의 5단계입니다. 객관식 성취·코드 행동 검증·설계 평가를 구분합니다. diff --git a/dist/styles.css b/dist/styles.css index d60f186..cd15acb 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -153,7 +153,7 @@ ul, ol { padding-left: 1.35rem; } .lesson-panel { min-height: 330px; padding: 1.9rem 2rem 2rem; overflow-wrap: anywhere; } .lesson-panel h3 { font-size: 1.0625rem; letter-spacing: -.025em; line-height: 1.6; } .section-heading { display: flex; align-items: center; gap: .65rem; margin-bottom: .9rem; } -.section-heading .section-number { font: .6875rem/1.5 var(--mono); color: #8da078; } +.section-heading .section-number { flex-shrink: 0; white-space: nowrap; font: .6875rem/1.5 var(--mono); color: #8da078; } .section-caption { font-size: .875rem; color: var(--muted); margin: .45rem 0 1.25rem; line-height: 1.85; } .learning-goals { background: #f1f4ed; border: 1px solid #e0e6d6; border-radius: 10px; padding: 1.1rem 1.15rem; margin-bottom: 1.8rem; } .learning-goals .small-label { color: #617455; margin-bottom: .55rem; font-size: .6875rem; } @@ -406,7 +406,7 @@ ul, ol { padding-left: 1.35rem; } .lesson-header h2 { font-size: 1.5rem; } .lesson-description { font-size: .875rem; } .lesson-meta { margin-top: .9rem; } - .lesson-tabs { padding: 0 .8rem; gap: .15rem; } + .lesson-tabs { flex-wrap: wrap; padding: 0 .8rem; gap: .15rem; overflow-x: visible; } .lesson-tabs button { padding: .9rem .5rem; font-size: .75rem; } .tab-number { display: none; } .lesson-panel { padding: 1.35rem 1.25rem 1.5rem; } diff --git a/docs/milestones.md b/docs/milestones.md index 91efffb..b4b5afb 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -5,9 +5,9 @@ | ID | 범위 | 완료 게이트 | 현재 상태 | | --- | --- | --- | --- | | M1 | 기획·교육 로드맵·버전/실행/협업 계약 | 독립 문서 리뷰·형식/링크/범위 확인·커밋·원격 ref 확인 | 완료: `796b4cf` 원격 반영 | -| M2 | Java 코어12수업·과제·UI·진도·출처·CI | 콘텐츠 정합성·예제/답안·UI mock 검사·독립 리뷰·커밋/푸시 | 완료: `731b4a2` 원격 반영·CI 진행 | -| M3 | 실제 JVM21 외부 실행·샘플/제출 API·Worker 패키징 | API 단위 검사·실제 코드 실행·오류/버전 계약·독립 리뷰·커밋/푸시 | 구현 중 | -| M4 | 프로토타입 통합·배포 | 반응형·키보드·진도·오래된 응답 무효화·합성 화면·CI 확인·출시 커밋/푸시·배포 성공 | 대기 | +| M2 | Java 코어12수업·과제·UI·진도·출처·CI | 콘텐츠 정합성·예제/답안·UI mock 검사·독립 리뷰·커밋/푸시 | 완료: `731b4a2` 원격 반영·CI PASS | +| M3 | 실제 JVM21 외부 실행·샘플/제출 API·Worker 패키징 | API 단위 검사·실제 코드 실행·오류/버전 계약·독립 리뷰·커밋/푸시 | 완료: `fe68aef` 원격 반영·CI PASS | +| M4 | 프로토타입 통합·배포 | 반응형·키보드·진도·오래된 응답 무효화·합성 화면·CI 확인·출시 커밋/푸시·배포 성공 | 최종 검수·배포 준비 | | M5 | 별도 JVM8/11/17/21·의존성·DB·HTTP 실행 기반 | 배포 자원·격리·정리·호환 조합·통합 검사 | 후속 설계 | | M6 | Spring/REST/Lombok/MyBatis/테스트 대표 수업 | 실제 의존성·다중 파일·DB·HTTP 코딩 실습 | M5 의존 | | M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | M5/6 의존 | diff --git a/docs/qa-report.md b/docs/qa-report.md new file mode 100644 index 0000000..0fe5df3 --- /dev/null +++ b/docs/qa-report.md @@ -0,0 +1,116 @@ +# 독립 QA 기록 + +기준일: 2026-10-12 (Asia/Seoul) + +**판정: PASS — 아래에 명시한 로컬 브라우저 QA 범위.** 최종 테스트 파일의 35개 시나리오는 전체 검사와 변경 영향에 따른 선택 재검사를 합쳐 모두 실행·통과했다. 최종 35개를 한 번의 명령으로 실행했다는 뜻은 아니다. 독립 시각 검수 담당자도 최종 합성 화면을 직접 확인한 뒤 검토 범위에 대해 PASS를 전달했다. + +QA 담당자는 `tests/browser.mjs`, 이 문서와 지정된 화면 증거만 작성했다. 앱·교육 데이터·API 구현은 수정하지 않았다. 대상은 다른 언어 경험자의 Java 전환 학습과 현직자 심화 학습이다. + +## 환경과 경계 + +- Windows, Node.js `v22.17.0`, Playwright `1.62.1`, 시스템 Edge `155.0.4283.45`, headless 실행. +- 부모 담당자가 소유한 개발 서버 `http://127.0.0.1:4173`. QA는 서버를 시작·변경하지 않았다. +- 각 시나리오는 별도 브라우저 컨텍스트를 사용했다. 실제 사용자 기록은 사용하거나 변경하지 않았다. +- `/api/runtime`과 `/api/execute`를 Playwright route로 대체했다. QA의 실제 외부 코드 실행 요청은 **0건**이다. 화면의 성공·오류 응답은 UI 검사용 데이터이며 실제 Java 채점 결과를 증명하지 않는다. +- 클립보드 성공·거부도 API 모의 구현으로 검사했다. 사용자 OS 클립보드는 변경하지 않았다. +- 320·360·390·768·1440 CSS px, 1280×600 및 844×390을 사용했다. 실제 휴대전화·태블릿 검사가 아니다. +- 글자 확대는 루트 글꼴 크기 200% 변경이다. 브라우저 메뉴 확대나 OS 디스플레이 배율을 검사한 것은 아니다. + +## 명령과 실제 결과 + +작업 디렉터리: `outputs/java-learning-lab`. + +```powershell +$env:PLAYWRIGHT_CHANNEL = 'msedge' +node tests/browser.mjs +``` + +기본 브라우저는 Chromium이다. `QA_FILTER`가 있으면 이름이 정규식과 일치하는 시나리오만 실행하며, 선택된 시나리오가 0개이면 실패한다. `QA_TRACE_SCREENSHOTS=1`은 캡처 시 초점·스크롤·활성 탭 경계를 출력한다. + +| 실행 | 실제 결과 | +| --- | --- | +| `node --check tests/browser.mjs` | 종료 코드 0 | +| 초기 서버 준비 확인 | sandbox에서는 EACCES, 상승 실행에서는 ECONNREFUSED. 브라우저 검사 시작 전 중단. 부모 담당자가 서버 실행 경계를 바로잡은 뒤 연결됨. | +| 전체 33개 시나리오 | 32 PASS, 1 FAIL. 빈 코드에 validation_error를 요구한 테스트 기대값 오류였다. 실제 계약인 입력 안내 toast·편집기 초점·요청 차단은 정상 동작했다. | +| `QA_FILTER='empty-source-and-utf8-payload-limit'` | 기대값을 계약에 맞춘 뒤 1 PASS, 0 FAIL, 종료 코드 0. 빈 코드와 UTF-8 32 KiB 초과 요청 모두 서버 전송 전 차단. | +| 첫 CSS 수정 후 선택 검사 | 11 PASS, 0 FAIL, 종료 코드 0 | +| 최종 탭·번호 수정 후 선택 검사 | 15 PASS, 0 FAIL, 종료 코드 0. 새 활성 탭 경계·모바일 긴 오류문 검사 2개 포함. | + +최종 선택 재검사 명령: + +```powershell +$env:PLAYWRIGHT_CHANNEL = 'msedge' +$env:QA_FILTER = 'lesson-steps|run-submit-and-two|execution-error-states|keyboard-tabs|mobile-drawer|responsive-|resize-orientation|initial-text|mobile-long' +$env:QA_TRACE_SCREENSHOTS = '1' +node tests/browser.mjs +``` + +## 검증 매트릭스 + +| 범위 | 실제 확인한 조건 | 결과 | +| --- | --- | --- | +| 수업·근거 | 12개 수업의 5개 단계, 실습 초기 코드, 공식 링크와 공고 표본 한계 안내 | PASS | +| 버전·트랙·검색 | Java 8/11/17/21 수업 집합, 공통 수업을 포함한 두 트랙, 제목 검색, 검색 결과 없음·복구, HTML 형태 검색어 | PASS | +| 링크·이동 | 잘못된 수업/단계/버전, 상위 버전 수업 직접 접근 차단, 뒤로·앞으로, 검색 실패 후 이어 학습하기 | PASS | +| 퀴즈 | 미응답·오답 미완료, 재시도, 탭 이동 중 선택 유지, 모두 정답인 경우 성취와 reload 후 유지 | PASS | +| 기록·초안 | 메모 작성만으로 성취를 만들지 않음, 수업별 코드 분리, 탭·수업 이동·즉시 reload 시 보존 | PASS | +| 초기화 | 코드 초기화와 undo, 전체 기록 초기화 취소·확인, 선택 버전 보존, 초기화 후 reload | PASS | +| 성취 정합성 | 예시 실행·일부 제출 실패는 미완료, 퀴즈와 제출 모두 통과해야 완료, 코드 변경 시 이전 통과와 현재 상태 분리 | PASS | +| 재제출 회귀 | 같은 코드의 통과 이후 failed/compile_error/runtime_error/timeout 제출 시 성취·완료 해제와 reload 후 유지 | PASS | +| 입력·응답 오류 | 빈 코드·UTF-8 크기 제한, 컴파일/런타임/타임아웃/제공자 오류, HTTP 400/413/429/502/503, 네트워크 실패, 비 JSON 응답, 실행 환경 복구 | PASS | +| 거짓 성공 방지 | 검사 0개, 통과 수치와 실패 항목의 모순, 다른 requestId 응답이 완료를 만들지 않음 | PASS | +| 비동기 경계 | 대기 중 편집·수업 이동·버전 변경·취소 시 낡은 응답 무효화, 중복 버튼 비활성화, 자동 재시도 없음 | PASS | +| 출력·복사 | HTML 형태 출력이 문자로 표시됨, 복사 성공 및 거부 시 직접 복사를 위한 선택·초점 | PASS | +| 저장소 제한 | 손상 JSON 및 읽기/쓰기 거부 시 수업·임시 코드·퀴즈 사용 가능, 저장 제한 안내 | PASS | +| 키보드·초점 | 입력 레이블, 탭 Arrow/Home/End, 라디오, 편집기 입력·Tab 이탈, dialog·모바일 목차 Escape와 초점 복귀 | PASS | +| 화면 조합 | 7개 크기에서 5개 단계, 페이지 가로 넘침 없음, 버튼 경계, 긴 코드·컴파일 진단의 박스 내부 스크롤 | PASS | +| 확대·리사이즈 | 입력 보존, 가로·세로 전환, 200% 글자, 확대 전/후 선택 모두 활성 탭의 상하좌우 경계 보장 | PASS | + +## 재현 문제와 수정 확인 + +| 문제·심각도 | 재현·실제 결과 | 기대·조치와 확인 | +| --- | --- | --- | +| 같은 코드의 새 제출 실패 이후 과거 성취 유지 — 코드 리뷰에서 제기 | 코드 리뷰 담당자의 요청으로 회귀 추가. QA는 수정 후 상태를 검사했다. | 최신 유효 제출 실패 시 성취·완료 해제. 4개 실패 상태 및 reload PASS. | +| 200% 버전 라벨 분절 — 시각 P2 | 고정 사이드바 안에서 select와 라벨이 경쟁하여 한 글자씩 분절됨. 독립 시각 검수에서 재현. | 버전 선택기를 단일 열로 수정. 최종 이미지에서 해결 확인. | +| 200% 로고 돌출 — 시각 P2 | 글자는 확대되나 배경은 고정 크기여서 밖으로 돌출됨. | 비례 크기와 헤더 줄바꿈 적용. 최종 이미지에서 해결 확인. | +| 실행 안내 가독성 — 시각 개선 항목 | 실행 환경·실행 전 결과 안내의 낮은 대비를 독립 시각 검수에서 지적. | 텍스트 색상·크기 수정. 최종 이미지에서 해결 확인. | +| 390px·200% 활성 탭 가림 — 시각 P2 | 코드 실습 선택 상태에서 확대·리사이즈하면 글자·밑줄이 내부 스크롤 영역 밖으로 가려짐. 페이지 가로 넘침 검사만으로 검출되지 않음. | 좁은 화면 탭 줄바꿈, 활성 탭 경계 검사 추가. 확대 전/후 선택 모두 PASS. 최종 탭 영역 x=36.19~353.81, 활성 코드 실습 x=169.17~328.22. | +| 320px 이론 번호 분절 — 시각 P3 | 절 번호 03이 분절됨. | 번호 축소·줄바꿈 방지. 최종 이미지에서 해결 확인. | + +기능·입력·화면 경계에서 남은 차단 결함은 없다. 시각 검수 담당자는 수정된 확대 탭 두 상태, 320px 번호, 모의 성공·오류 결과를 직접 재확인한 뒤 검토한 로컬 합성 화면에 PASS를 전달했다. + +## 화면 증거 + +로컬 작업 공간의 `work/java-edu-qa/`에 PNG를 저장했다. 이 경로는 개발 작업 공간 산출물이며 외부 호스팅 URL이 아니다. + +| 화면 | 주요 파일 | +| --- | --- | +| 데스크톱 학습 | desktop-theory.png, desktop-example.png, desktop-ide.png, desktop-practice-long-content.png | +| 모바일 학습 | mobile-quiz.png, mobile-practice.png, mobile-ide.png, mobile-drawer-360.png | +| 모바일·태블릿 경계 | viewport-320-theory.png, viewport-320-lab.png, viewport-360-theory.png, viewport-360-lab.png, viewport-768-theory.png, viewport-768-lab.png | +| 낮은 높이·조작부 | viewport-320x720-lab-controls.png, viewport-360x800-lab-controls.png, viewport-768x1024-lab-controls.png, viewport-1280x600-lab-controls.png, viewport-844x390-lab-controls.png | +| 데스크톱 확대 | desktop-text-200-percent.png, desktop-text-200-top.png, desktop-text-200-version.png | +| 모바일 확대 | mobile-text-200-percent.png, mobile-text-200-top.png, mobile-text-200-tabs.png, mobile-text-200-tabs-selected-before-enlargement.png, mobile-text-200-editor.png, mobile-text-200-controls.png, mobile-text-200-output.png, mobile-text-200-drawer.png | +| 모의 제출 결과 | desktop-submit-passed.png, desktop-submit-passed-result.png, mobile-compile-error.png, mobile-compile-error-result.png | + +초기 fullPage 캡처에서 화면 밖 고정 skip-link가 문서 중간에 포함되는 현상을 확인했다. 최종 fullPage는 스크롤을 맨 위로 이동해 촬영하고 위치를 복원한다. 실제 viewport와 DOM 측정에서 skip-link는 초점이 없었으며 100% y=-96~-48.125, 200% y=-192~-96.21875로 화면 밖이었다. 항상 노출되는 링크로 오인하지 않는다. + +## 검증 소스 식별 + +최종 화면 회귀 당시 SHA-256: + +| 파일 | SHA-256 | +| --- | --- | +| dist/app.js | C176883B44692D1EF89ECB1719B4040558348C9EEF5196A5E5FD422AD286566C | +| dist/styles.css | C2D433075AC2B66F60403AB4208E7C0AE46DC22320A2BE5E6E05CDFD0F6CC9B9 | +| dist/data/curriculum.js | CDB6607660F282C523A68771B3C9A86EAB9ABDB83909197BD2084E77EB98E73F | +| dist/data/exercises.js | 8F859F0641A3D8E59540DD0BD0EBF306ECE9A583B65437A2864D7E084551ACDE | +| tests/browser.mjs | A68CDD638669B8E6CB82953B9EBE5178128D48698E82BFCAD5D02376AC3EB166 | + +## 미검증·해석 제한 + +- 실제 Java 컴파일·실행, JDK별 언어/API 정합성, 외부 제공자 연결·제한·보안은 이 모의 API UI 검사 결과에 포함하지 않는다. 담당자의 별도 결과를 확인해야 한다. +- 실제 기기, iOS Safari·Firefox·CI Chromium, 스크린리더, 모바일 길게 누르기·IME 조합 입력은 실행하지 않았다. +- OS 클립보드 연동, 브라우저 메뉴 확대, 네트워크 단절 뒤 서비스 워커 기반 오프라인 재접속은 검사하지 않았다. +- 공식 문서·공고 링크의 화면 표시는 검사했지만 원문 사실 검증이나 현재 채용 상태 재조사는 별도 조사 결과의 영역이다. +- 테스트 완료는 학습자의 교육 효과 실측이나 설계 과제에 대한 전문가 승인이 아니다. \ No newline at end of file diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..9031f9d --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,38 @@ +# 프로토타입 검증 기록 + +기준일: 2026-10-12. Java 코어 첫 12수업을 대상으로 한다. 전체 교육 로드맵 수료 가능성이나 실제 학습 효과는 아직 검증하지 않았다. + +## 소스와 마일스톤 + +| 마일스톤 | GitHub 커밋 | 근거 | +| --- | --- | --- | +| M1 기획·계약 | `796b4cf48504d18822fa77c0cd2e9b5437e851f9` | 독립 문서 리뷰 PASS, 원격 ref 일치 | +| M2 12수업·학습실 | `731b4a2cb44f00d107ad29cda7918739114e9428` | 독립 교육·코드 리뷰, 기능 QA, [CI 성공](https://github.com/jbsim999/java-edu/actions/runs/38199412067) | +| M3 실행 API·Worker | `fe68aefb190bd4ae52262e4d8458e3f964038b95` | API 단위 검사, 실제 제공자 실행, 독립 코드 리뷰, [CI 성공](https://github.com/jbsim999/java-edu/actions/runs/38199510214) | + +작업 브랜치는 `feat/java-learning-mvp`, 검토 PR은 [#1](https://github.com/jbsim999/java-edu/pull/1)이다. GitHub 공개 저장소와 Sites 개인 전용 호스팅을 구분한다. Sites에는 동일 GitHub 소스를 별도 체크아웃으로 전송하며 GitHub origin을 교체하지 않는다. 배포 소스 SHA와 배포 ID는 성공 응답 이후 배포 기록에 남긴다. + +## 실제 실행한 검사 + +- `npm run check`: 콘텐츠 연결·출력 일치 등 5개와 실행 API 22개, 총 27 PASS. 네트워크 live 모드 1개는 기본 실행에서 SKIP. +- 로컬 JDK 8u265: 독립 예제 9개 + 기준 답안 9개, 행동 검사 56개 PASS. 이후 버전 예제·답안 6개는 로컬에서 SKIP. +- CI의 실제 Temurin JDK 8/11/17/21: 각각 18/20/22/24개 예제·답안 PASS, 상위 버전 6/4/2/0개 SKIP. JDK 21에서는 12예제와 12기준 답안, 전체 행동 검사 73개를 실행했다. +- 외부 Wandbox 실제 JVM 21: `--release` 8·11·17·21 대표 과제 각각 통과, 컴파일 오류와 오답을 구분. TLS 인증서 검증을 켠 `node --use-system-ca` 환경에서 확인했다. 전체 12과제를 외부 제공자로 실행한 검사는 아니다. +- 브라우저 기능·반응형·글자 확대는 [독립 QA 기록](qa-report.md)을 따른다. API 응답은 모의 처리하며 실제 휴대전화 테스트가 아니다. +- `npm run build` 및 `npm run test:worker`: 빌드된 Worker 경로·HEAD·CSP·비공개 파일 제외·인증 필수 동작 PASS. 배포 환경에서 `REQUIRE_IDENTITY=false`가 전달돼도 실행 API 인증을 해제하지 않는다. + +독립 리뷰에서 경계값 0/30001 채점 누락, 제네릭 저장소 공유 전제, 같은 코드 재제출 실패 후 성취 유지 문제를 발견하고 수정했다. 경계값 생략 결함 3종의 거절을 Java 8에서 확인했고 재제출 실패 회귀는 브라우저 QA로 확인했다. 확대·대비·활성 탭 시각 검수 결과는 QA 기록에 남긴다. + +## 제한과 미확인 + +- 실제 웹 IDE JVM은 21이다. 네 개 JVM의 선택 실행은 M5 이후 범위이며 CI의 네 JDK 실행과 다르다. +- 실제 외부 제공자 실행과 브라우저 모의 통합은 각각 검증했다. 자동 승인 검토가 기준 답안을 외부 제공자로 전송하는 추가 로컬 HTTP 통합 검사를 거절해 해당 호출은 실행하지 않았다. 배포된 인증 세션에서의 종단 간 Java 제출도 별도 미확인 범위다. +- 제공자 쿼터·중단·로그 보존은 제3자 운영 정책에 의존한다. 취소는 원격 JVM 종료를 보장하지 않는다. 한 isolate의 호출 제한은 다중 isolate 전체의 분산 쿼터가 아니다. +- 교육용 자동 행동 검사는 설계 품질이나 완전한 부정행위 방지 판정을 대신하지 않는다. 설계 메모와 루브릭을 함께 사용한다. +- 현재 성취 기록은 과제 개정판과 연결하지 않는다. 계약을 변경하기 전에 revision/fingerprint와 재검증을 도입해야 한다. +- 실제 모바일 기기·스크린리더·장기 부하·서비스 장애 훈련·기기 간 진도 동기화는 미검증이다. +- Spring·Lombok·MyBatis·OAuth2/OIDC·JPA·Redis·Kafka·DB/HTTP 실행은 후속 M5–M7 범위다. + +로컬 UI 시각 판정: 독립 시각 검수 PASS. 320/360/390/768/1440px, 1280×600·844×390, 100/200% 글자 확대의 합성 화면에서 로고·버전 라벨·안내 대비·활성 탭·절 번호 개선을 재검증했다. 성공/오류 화면은 모의 API 상태다. + +출시 판정: 정확한 최종 소스의 CI와 private Sites 배포 성공 응답을 확인한 뒤 기록한다. diff --git a/tests/browser.mjs b/tests/browser.mjs index 8571b5f..53a84b8 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -50,6 +50,18 @@ async function noViewportOverflow(page) { assert.ok(dimensions.scroll <= dimensions.client + 1, `viewport overflow: ${JSON.stringify(dimensions)}`); } +async function activeTabVisible(page) { + const geometry = await page.evaluate(() => { + const strip = document.querySelector('.lesson-tabs'); + const tab = strip.querySelector('[aria-selected="true"]'); + const container = strip.getBoundingClientRect(); + const selected = tab.getBoundingClientRect(); + return { viewport: innerWidth, scrollLeft: strip.scrollLeft, clientWidth: strip.clientWidth, scrollWidth: strip.scrollWidth, containerLeft: container.left, containerRight: container.right, containerTop: container.top, containerBottom: container.bottom, tabLeft: selected.left, tabRight: selected.right, tabTop: selected.top, tabBottom: selected.bottom }; + }); + if (process.env.QA_TRACE_SCREENSHOTS === '1') console.log('ACTIVE_TAB', JSON.stringify(geometry)); + assert.ok(geometry.tabLeft >= geometry.containerLeft - 1 && geometry.tabRight <= geometry.containerRight + 1 && geometry.tabTop >= geometry.containerTop - 1 && geometry.tabBottom <= geometry.containerBottom + 1, `selected tab is clipped: ${JSON.stringify(geometry)}`); +} + async function screenshot(page, name, fullPage = true) { const position = await page.evaluate(() => ({ x: scrollX, y: scrollY })); if (fullPage) await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })); @@ -431,6 +443,9 @@ async function main() { await expectEventually(() => achievement(page, 'lab'), 'true', 'all submit checks grant code achievement'); assert.equal(await page.locator('#overall-completed').textContent(), '1'); assert.equal(await page.locator(`#lesson-list [data-lesson-id="${first.id}"]`).getAttribute('data-complete'), 'true'); + await screenshot(page, 'desktop-submit-passed'); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await screenshot(page, 'desktop-submit-passed-result', false); await page.reload(); await page.locator('#code-editor').waitFor({ state: 'visible' }); assert.equal(await achievement(page, 'quiz'), 'true'); @@ -488,6 +503,28 @@ async function main() { assert.equal(await achievement(page, 'lab'), 'false', 'revocation survives reload'); }); + await run(browser, 'mobile-long-diagnostic-output-stays-internal', async ({ context, page }) => { + const diagnostic = `Solution.java:3: error: cannot find symbol\n${'long_identifier_'.repeat(120)}\n location: class Solution\n1 error`; + await mockApi(context, { respond: request => ({ body: executionResult(request, { status: 'compile_error', stdout: '', stderr: '', compilerOutput: diagnostic, checks: [], passed: 0, total: 0 }) }) }); + await openLesson(page, first, 'lab'); + await page.locator('#submit-code').click(); + await waitForOutput(page, 'compile_error'); + await noViewportOverflow(page); + const output = page.locator('#execution-output pre'); + const scroll = await output.evaluate(element => { + const width = element.clientWidth; + element.scrollLeft = 120; + const result = { width, scrollWidth: element.scrollWidth, left: element.scrollLeft }; + element.scrollLeft = 0; + return result; + }); + assert.ok(scroll.scrollWidth > scroll.width && scroll.left > 0, 'long compiler diagnostics have their own horizontal scroll'); + assert.equal(await achievement(page, 'lab'), 'false'); + await screenshot(page, 'mobile-compile-error'); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await screenshot(page, 'mobile-compile-error-result', false); + }, { viewport: { width: 390, height: 844 } }); + await run(browser, 'empty-source-and-utf8-payload-limit', async ({ context, page }) => { const api = await mockApi(context); await openLesson(page, first, 'lab'); @@ -775,6 +812,7 @@ async function main() { await page.setViewportSize(viewport); assert.equal(await page.locator('#code-editor').inputValue(), draft); await noViewportOverflow(page); + await activeTabVisible(page); } const before = await page.locator('html').evaluate(element => parseFloat(getComputedStyle(element).fontSize)); await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); @@ -791,6 +829,7 @@ async function main() { await screenshot(page, 'desktop-text-200-version', false); await page.setViewportSize({ width: 390, height: 844 }); await noViewportOverflow(page); + await activeTabVisible(page); await screenshot(page, 'mobile-text-200-percent'); await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })); await screenshot(page, 'mobile-text-200-top', false); @@ -803,6 +842,23 @@ async function main() { await page.keyboard.press('Escape'); assert.equal(await page.locator('#code-editor').inputValue(), draft); }); + + await run(browser, 'initial-text-enlargement-keeps-mobile-tabs-visible', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + for (const step of ['theory', 'example', 'quiz', 'practice', 'lab']) { + await stepTo(page, step); + await activeTabVisible(page); + await noViewportOverflow(page); + } + await page.locator('html').evaluate(element => { element.style.fontSize = '100%'; }); + await activeTabVisible(page); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + await activeTabVisible(page); + await page.locator('.lesson-tabs').scrollIntoViewIfNeeded(); + await screenshot(page, 'mobile-text-200-tabs-selected-before-enlargement', false); + }, { viewport: { width: 390, height: 844 } }); } finally { await browser.close(); } From 794e2ba2dc3e5b38a42945cafc7e4a00d5d11c6d Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:38:15 +0900 Subject: [PATCH 05/29] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=ED=86=A0?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=B0=B0=ED=8F=AC=EC=99=80=20=EC=B5=9C?= =?UTF-8?q?=EC=A2=85=20=EA=B2=80=EC=A6=9D=20=EA=B7=BC=EA=B1=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++- docs/deployment.md | 22 ++++++++++++++++++++++ docs/milestones.md | 2 +- docs/verification.md | 5 ++++- 4 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 docs/deployment.md diff --git a/README.md b/README.md index f39add5..1517791 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 공식 Java 문서 기반으로 다른 언어 경험자의 Java 전환 학습과 현직자의 심화를 연결하는 한국어 교육 서비스입니다. OOP·타입·동시성을 깊게 다루고, 수업 끝에서 Java 코드를 직접 작성·실행·검증합니다. -현재 상태: **프로토타입 검증 중**. Java 코어 12수업·24문항·12코딩 과제와 5단계 학습실을 구현했습니다. Spring·Lombok·MyBatis·OAuth2/OIDC·JPA·Redis·Kafka 및 운영 과정은 교육 로드맵에 포함하며 후속 실습 기반에서 확장합니다. 전체 교육 과정 완료를 뜻하지 않습니다. +현재 상태: **프로토타입 배포 완료**. [Java Learning Lab](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 계정만 접근할 수 있습니다. Java 코어 12수업·24문항·12코딩 과제와 5단계 학습실을 구현했습니다. Spring·Lombok·MyBatis·OAuth2/OIDC·JPA·Redis·Kafka 및 운영 과정은 교육 로드맵에 포함하며 후속 실습 기반에서 확장합니다. 전체 교육 과정 완료를 뜻하지 않습니다. - [기획안](docs/product-plan.md) - [교육 로드맵](docs/education-roadmap.md) @@ -14,6 +14,7 @@ - [구현된 코어 과정](docs/curriculum.md) - [협업 규칙](CONTRIBUTING.md) - [실제 검증과 미확인 범위](docs/verification.md) +- [배포 소스·버전 기록](docs/deployment.md) - [독립 브라우저 QA](docs/qa-report.md) 수업은 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩의 5단계입니다. 객관식 성취·코드 행동 검증·설계 평가를 구분합니다. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..4fc3688 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,22 @@ +# 코어 프로토타입 배포 기록 + +2026-10-12 03:36:36 UTC(한국시간 12:36:36)에 Sites의 개인 전용 배포가 성공했다. 운영 URL은 [Java Learning Lab](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)이다. 공개 GitHub 저장소와 별개로 사이트는 소유자 계정만 접근한다. + +| 항목 | 확인 값 | +| --- | --- | +| GitHub 출시 소스 | `165e113cc77bb56f0bfab78cf28f94de47f022cd` | +| 브랜치 | `feat/java-learning-mvp` | +| 출시 CI | [38200134013: 모든 6개 작업 성공](https://github.com/jbsim999/java-edu/actions/runs/38200134013) | +| Sites 프로젝트 | `appgprj_6acc4fec8bb48191a296197d2d55c1e3` | +| Sites source SHA | `e5c276479bf60c606884c61fb5b90a472e246d2c` | +| 저장된 버전 | `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_ff8ebe8af5d88191a2d038ddb0302484` | +| 배포 ID | `appgdep_6acc55b5a94c81918d004500c476c6c4` | +| 실제 응답 상태 | `succeeded`, `failure_message: null` | + +별도 체크아웃을 통해 Sites source helper로 commit/push 및 패키징했다. GitHub origin은 변경하지 않았다. 모든 출시 파일의 Git blob을 비교해 `.gitignore`의 호스팅 metadata 제외 규칙 외 동일함을 확인했다. Sites에만 `.openai/hosting.json`이 추가되며 인증 토큰은 세션 메모리와 숨겨진 stdin에만 사용했다. + +정적 UI·데이터와 서버 API를 ESM Worker로 묶었다. Cloudflare Worker 안에서 JVM을 실행하는 방식이 아니라 고정된 외부 Wandbox API를 호출한다. 네 대상 버전은 `--release`로 컴파일하며 실제 실행 JVM은 21이다. Spring/의존성/DB/HTTP 앱 실행은 후속 마일스톤이다. + +기능·반응형·키보드·저장소·경쟁 응답은 모의 API 브라우저 QA로, 제공자 실행은 별도 실제 호출로 확인했다. 배포된 인증 세션의 Java 제출·실제 기기·장기 부하 검증은 수행하지 않았다. 상세 범위는 [검증 기록](verification.md)을 따른다. 배포 성공 응답은 런타임 종단 간 검증을 대신하지 않는다. + +다음 배포는 현재 프로젝트 ID·접근 범위를 재사용하고 새 source SHA의 CI·독립 검수를 확인한 뒤 저장된 버전으로 배포한다. 이전 버전 재배포 시에도 저장된 정확한 버전 ID와 당시 계약을 확인한다. 공개 접근 전환이나 별도 실행 자원은 추가 결정 대상이다. diff --git a/docs/milestones.md b/docs/milestones.md index b4b5afb..e629d46 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -7,7 +7,7 @@ | M1 | 기획·교육 로드맵·버전/실행/협업 계약 | 독립 문서 리뷰·형식/링크/범위 확인·커밋·원격 ref 확인 | 완료: `796b4cf` 원격 반영 | | M2 | Java 코어12수업·과제·UI·진도·출처·CI | 콘텐츠 정합성·예제/답안·UI mock 검사·독립 리뷰·커밋/푸시 | 완료: `731b4a2` 원격 반영·CI PASS | | M3 | 실제 JVM21 외부 실행·샘플/제출 API·Worker 패키징 | API 단위 검사·실제 코드 실행·오류/버전 계약·독립 리뷰·커밋/푸시 | 완료: `fe68aef` 원격 반영·CI PASS | -| M4 | 프로토타입 통합·배포 | 반응형·키보드·진도·오래된 응답 무효화·합성 화면·CI 확인·출시 커밋/푸시·배포 성공 | 최종 검수·배포 준비 | +| M4 | 프로토타입 통합·배포 | 반응형·키보드·진도·오래된 응답 무효화·합성 화면·CI 확인·출시 커밋/푸시·배포 성공 | 완료: `165e113` 원격 반영·CI PASS·private 배포 성공 | | M5 | 별도 JVM8/11/17/21·의존성·DB·HTTP 실행 기반 | 배포 자원·격리·정리·호환 조합·통합 검사 | 후속 설계 | | M6 | Spring/REST/Lombok/MyBatis/테스트 대표 수업 | 실제 의존성·다중 파일·DB·HTTP 코딩 실습 | M5 의존 | | M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | M5/6 의존 | diff --git a/docs/verification.md b/docs/verification.md index 9031f9d..61a13d3 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -9,6 +9,7 @@ | M1 기획·계약 | `796b4cf48504d18822fa77c0cd2e9b5437e851f9` | 독립 문서 리뷰 PASS, 원격 ref 일치 | | M2 12수업·학습실 | `731b4a2cb44f00d107ad29cda7918739114e9428` | 독립 교육·코드 리뷰, 기능 QA, [CI 성공](https://github.com/jbsim999/java-edu/actions/runs/38199412067) | | M3 실행 API·Worker | `fe68aefb190bd4ae52262e4d8458e3f964038b95` | API 단위 검사, 실제 제공자 실행, 독립 코드 리뷰, [CI 성공](https://github.com/jbsim999/java-edu/actions/runs/38199510214) | +| M4 출시 프로토타입 | `165e113cc77bb56f0bfab78cf28f94de47f022cd` | 최종 독립 코드·QA·시각 PASS, [CI 성공](https://github.com/jbsim999/java-edu/actions/runs/38200134013), private 배포 성공 | 작업 브랜치는 `feat/java-learning-mvp`, 검토 PR은 [#1](https://github.com/jbsim999/java-edu/pull/1)이다. GitHub 공개 저장소와 Sites 개인 전용 호스팅을 구분한다. Sites에는 동일 GitHub 소스를 별도 체크아웃으로 전송하며 GitHub origin을 교체하지 않는다. 배포 소스 SHA와 배포 ID는 성공 응답 이후 배포 기록에 남긴다. @@ -35,4 +36,6 @@ 로컬 UI 시각 판정: 독립 시각 검수 PASS. 320/360/390/768/1440px, 1280×600·844×390, 100/200% 글자 확대의 합성 화면에서 로고·버전 라벨·안내 대비·활성 탭·절 번호 개선을 재검증했다. 성공/오류 화면은 모의 API 상태다. -출시 판정: 정확한 최종 소스의 CI와 private Sites 배포 성공 응답을 확인한 뒤 기록한다. +최종 출시 CI는 Content integrity, Java examples (8/11/17/21), Browser QA 모두 성공했다. Chromium 브라우저 35개 시나리오를 한 번의 전체 실행에서 35 PASS/0 FAIL로 확인했다. 로컬 Edge QA 기록의 선택 재검사와 구분한다. + +출시 판정: **PASS — 코어 프로토타입 범위**. private Sites 배포가 `succeeded`와 운영 URL을 반환했다. 모든 GitHub 출시 파일과 Sites source tree의 Git blob을 비교해 호스팅 manifest/ignore 설정 외 동일함을 확인했다. [배포 기록](deployment.md)에 소스·버전·배포 ID를 기록했다. 배포된 인증 세션에서의 Java 제출은 미검증이며 위 제한은 유지한다. From a4d277e10f6c9e03b6a14f4fb6a11a2672987b54 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:33:19 +0900 Subject: [PATCH 06/29] =?UTF-8?q?feat(editor):=20Java=20=EB=AC=B8=EB=B2=95?= =?UTF-8?q?=20=EA=B0=95=EC=A1=B0=EC=99=80=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?= =?UTF-8?q?=ED=85=8C=EB=A7=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + dist/app.js | 185 +- dist/editor.js | 122 ++ dist/index.html | 2 +- dist/styles.css | 139 +- docs/editor-qa-report.md | 118 ++ docs/milestones.md | 7 +- package-lock.json | 3790 +++++++++++++++++++++++++++++++++++++- package.json | 19 +- scripts/build-editor.mjs | 50 + scripts/build-worker.mjs | 3 +- scripts/check-worker.mjs | 6 + tests/browser.mjs | 522 +++++- tests/editor.test.mjs | 51 + 14 files changed, 4845 insertions(+), 171 deletions(-) create mode 100644 dist/editor.js create mode 100644 docs/editor-qa-report.md create mode 100644 scripts/build-editor.mjs create mode 100644 tests/editor.test.mjs diff --git a/.gitignore b/.gitignore index 857017e..35fb58b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ test-results/ worker/assets.generated.mjs dist/server/ dist/.openai/ +dist/assets/editor.bundle.js +desktop-release/ diff --git a/dist/app.js b/dist/app.js index bd8e699..d53a86b 100644 --- a/dist/app.js +++ b/dist/app.js @@ -1,4 +1,6 @@ const STORAGE_KEY = 'java-edu:progress:v1'; +const EDITOR_PREFERENCES_KEY = 'java-edu:editor-preferences:v1'; +const EDITOR_THEMES = ['dark', 'light', 'contrast']; const VERSIONS = [8, 11, 17, 21]; const STEPS = ['theory', 'example', 'quiz', 'practice', 'lab']; const STEP_LABELS = { theory: '이론', example: '예제', quiz: '문제', practice: '실무', lab: '코드 실습' }; @@ -14,6 +16,8 @@ let jobEvidence = []; let saveTimer; let toastTimer; let runtimeSequence = 0; +let editorLibrary = null; +let activeEditor = null; const state = { version: 21, track: 'all', search: '', lessonId: '', step: 'theory', @@ -21,6 +25,7 @@ const state = { quizAnswers: new Map(), quizResults: new Map(), labResults: new Map(), undo: new Map(), storage: 'ready', runtime: null, runtimeStatus: 'checking', runtimeMessage: '', pending: null, menuOpen: false, + editorTheme: 'dark', editorWrap: false, }; const dom = Object.fromEntries([ @@ -151,6 +156,65 @@ function restoreStorage() { } } +function restoreEditorPreferences() { + try { + const preferences = JSON.parse(window.localStorage.getItem(EDITOR_PREFERENCES_KEY)); + if (!preferences || typeof preferences !== 'object') return; + if (EDITOR_THEMES.includes(preferences.theme)) state.editorTheme = preferences.theme; + if (typeof preferences.wrap === 'boolean') state.editorWrap = preferences.wrap; + } catch { /* Preferences are optional; the default theme also works without storage. */ } +} + +function saveEditorPreferences() { + try { + window.localStorage.setItem(EDITOR_PREFERENCES_KEY, JSON.stringify({ theme: state.editorTheme, wrap: state.editorWrap })); + } catch { + state.storage = 'unavailable'; + updateStorageIndicator(); + toast('코드 테마는 이번 방문 동안 적용됩니다. 브라우저 저장을 사용할 수 없습니다.'); + } +} + +function setEditorTheme(theme) { + if (!EDITOR_THEMES.includes(theme)) return; + state.editorTheme = theme; + document.querySelectorAll('[data-editor-theme]').forEach((node) => { node.dataset.editorTheme = theme; }); + document.querySelectorAll('.editor-theme-select').forEach((select) => { select.value = theme; }); + activeEditor?.setTheme(theme); + saveEditorPreferences(); + announce(`코드 테마를 ${theme === 'dark' ? '다크' : theme === 'light' ? '라이트' : '고대비'}로 변경했습니다.`); +} + +function codePreferences() { + const settings = el('div', 'code-settings'); + const label = el('label', 'code-theme-label', '코드 테마'); + label.htmlFor = 'editor-theme-select'; + const select = el('select', 'editor-theme-select'); + select.id = 'editor-theme-select'; + select.dataset.testid = 'editor-theme-select'; + for (const [value, name] of [['dark', '다크'], ['light', '라이트'], ['contrast', '고대비']]) { + const option = el('option', '', name); option.value = value; select.append(option); + } + select.value = state.editorTheme; + select.addEventListener('change', () => setEditorTheme(select.value)); + settings.append(label, select); + return settings; +} + +function syntaxLegend() { + const legend = el('div', 'syntax-legend'); + legend.setAttribute('aria-label', '코드 색상 안내'); + for (const [category, label] of [['keyword', '키워드'], ['class', '클래스'], ['type', '타입'], ['variable', '변수'], ['method', '메서드'], ['string', '문자열'], ['comment', '주석'], ['number', '숫자']]) { + legend.append(el('span', `syntax-legend-item tok-${category}`, label)); + } + return legend; +} + +function destroyCodeEditor() { + activeEditor?.destroy(); + activeEditor = null; +} + function updateStorageIndicator() { const indicator = document.getElementById('storage-indicator'); indicator.classList.toggle('is-unavailable', state.storage !== 'ready'); @@ -318,6 +382,7 @@ function renderAchievements(lesson = currentLesson()) { function renderLesson() { const lesson = currentLesson(); const tabs = document.querySelector('.lesson-tabs'); + destroyCodeEditor(); dom['lesson-panel'].replaceChildren(); dom['lesson-meta'].replaceChildren(); dom['lesson-footer'].replaceChildren(); @@ -465,7 +530,9 @@ async function copyText(text, selectableNode) { await navigator.clipboard.writeText(text); toast('코드를 복사했습니다.'); } catch { - if (selectableNode instanceof HTMLTextAreaElement) { + if (typeof selectableNode?.selectAll === 'function') { + selectableNode.selectAll(); + } else if (selectableNode instanceof HTMLTextAreaElement) { selectableNode.focus(); selectableNode.select(); } else if (selectableNode) { @@ -484,19 +551,23 @@ function renderExample(lesson) { if (!example) return; dom['lesson-panel'].append(sectionTitle(example.title || '코드로 개념 살펴보기'), el('p', 'section-caption', '아래는 학습용 예제와 예상 출력입니다. 실제 실행은 코드 실습에서 확인하세요.')); const shell = el('div', 'code-shell'); + shell.dataset.editorTheme = state.editorTheme; const heading = el('div', 'code-heading'); const filename = el('span'); filename.append(icon('code'), document.createTextNode('Main.java')); const pre = el('pre', 'code-block'); pre.tabIndex = 0; pre.setAttribute('aria-label', '학습 예제 소스 코드'); - pre.append(el('code', '', example.code)); + const code = el('code'); + if (editorLibrary) editorLibrary.highlightJavaCode(code, example.code); + else code.textContent = example.code; + pre.append(code); const copy = actionButton('코드 복사', 'copy-button', () => copyText(example.code, pre), 'copy-example'); copy.prepend(icon('copy')); heading.append(filename, copy); - shell.append(heading, pre); + shell.append(heading, codePreferences(), syntaxLegend(), pre); const output = el('div', 'expected-output'); output.append(el('p', 'small-label', '예상 출력 · 실행 결과가 아닙니다'), el('pre', '', example.output || '(표준 출력 없음)')); - dom['lesson-panel'].append(shell, output, el('p', 'explanation', example.explanation), nextStepButton('quiz', '문제로 이해 확인하기')); + dom['lesson-panel'].append(shell, el('p', 'inline-note syntax-note', editorLibrary ? '색상은 코드의 구문을 구분하며, 컴파일 결과를 대신하지 않습니다.' : '문법 강조를 불러오지 못해 원문으로 표시합니다.'), output, el('p', 'explanation', example.explanation), nextStepButton('quiz', '문제로 이해 확인하기')); } function renderQuiz(lesson) { @@ -659,43 +730,34 @@ function renderLab(lesson) { dom['lesson-panel'].append(rubric); } const editorShell = el('div', 'editor-shell'); + editorShell.dataset.editorTheme = state.editorTheme; const toolbar = el('div', 'editor-toolbar'); const filename = el('span', 'editor-filename'); filename.append(icon('code'), document.createTextNode('Solution · Java')); const editorTools = el('div', 'editor-tools'); - const editor = el('textarea', 'code-editor'); + const editor = el('div', 'code-editor'); editor.id = 'code-editor'; editor.dataset.testid = 'code-editor'; - editor.value = sourceFor(exercise); - editor.spellcheck = false; - editor.autocomplete = 'off'; - editor.setAttribute('autocapitalize', 'off'); - editor.setAttribute('autocorrect', 'off'); - editor.setAttribute('wrap', 'off'); - editor.setAttribute('aria-label', `${exercise.title} Java 코드 편집기`); - editor.setAttribute('aria-describedby', 'editor-hint editor-save-state'); - editor.maxLength = SOURCE_LIMIT; - editor.addEventListener('input', () => { - state.drafts.set(exercise.id, editor.value); - state.undo.delete(exercise.id); - renderUndo(exercise); - refreshDraftDependentUi(exercise); - scheduleSave(); - }); - editor.addEventListener('blur', saveStorage); + const wrapToggle = actionButton('줄바꿈', '', () => { + state.editorWrap = !state.editorWrap; + activeEditor?.setWrap(state.editorWrap); + wrapToggle.setAttribute('aria-pressed', String(state.editorWrap)); + saveEditorPreferences(); + }, 'code-wrap-toggle'); + wrapToggle.setAttribute('aria-pressed', String(state.editorWrap)); + wrapToggle.setAttribute('aria-label', '긴 코드 줄 자동 줄바꿈'); editorTools.append( actionButton('들여쓰기', '', () => { - editor.setRangeText(' ', editor.selectionStart, editor.selectionEnd, 'end'); - editor.dispatchEvent(new Event('input', { bubbles: true })); - editor.focus(); + activeEditor?.indent(); }, 'indent-code'), - actionButton('복사', '', () => copyText(editor.value, editor), 'copy-code'), + wrapToggle, + actionButton('복사', '', () => copyText(activeEditor?.getValue() ?? sourceFor(exercise), activeEditor), 'copy-code'), actionButton('초기 코드', '', () => { - const previous = sourceFor(exercise); + const previous = activeEditor?.getValue() ?? sourceFor(exercise); if (previous === exercise.template) { toast('이미 초기 코드입니다.'); return; } state.undo.set(exercise.id, previous); state.drafts.set(exercise.id, exercise.template); - editor.value = exercise.template; + activeEditor?.setValue(exercise.template); refreshDraftDependentUi(exercise); renderUndo(exercise); saveStorage(); @@ -707,8 +769,8 @@ function renderLab(lesson) { const lines = el('span'); lines.id = 'editor-line-count'; const save = el('span'); save.id = 'editor-save-state'; editorStatus.append(lines, save); - editorShell.append(toolbar, editor, editorStatus); - const hint = el('p', 'editor-hint', '일반 텍스트 편집기 · 자동 완성 없음 · Tab은 다음 항목으로 이동합니다. 들여쓰기는 공백 4칸입니다. class Solution 골격을 유지하고 최상위 public class·package·Main 선언은 추가하지 마세요. 실행 요청은 UTF-8 32 KiB까지 전송할 수 있습니다.'); + editorShell.append(toolbar, codePreferences(), syntaxLegend(), editor, editorStatus); + const hint = el('p', 'editor-hint', '문법 강조 · 줄 번호 · 괄호 짝 표시 · Tab은 다음 항목으로 이동합니다. 들여쓰기 버튼은 선택한 줄에 공백 4칸을 넣습니다. 색상은 구문을 구분하며 컴파일 결과를 대신하지 않습니다. class Solution 골격을 유지하고 최상위 public class·package·Main 선언은 추가하지 마세요. 실행 요청은 UTF-8 32 KiB까지 전송할 수 있습니다.'); hint.id = 'editor-hint'; const undo = el('div', 'reset-undo'); undo.id = 'lab-reset-undo'; undo.hidden = true; const disclosure = el('p', 'execution-disclosure', '실행·제출을 누르면 작성 코드가 외부 서비스 Wandbox로 전송됩니다. 운영 로그에 코드가 남을 수 있으니 비밀정보를 입력하지 마세요. '); @@ -732,6 +794,8 @@ function renderLab(lesson) { output.setAttribute('aria-busy', 'false'); output.tabIndex = -1; dom['lesson-panel'].append(editorShell, hint, undo, disclosure, actions, output); + mountCodeEditor(editor, exercise); + if (!editorLibrary) hint.prepend(document.createTextNode('편집기 확장을 불러오지 못해 기본 텍스트 편집기를 사용합니다. ')); renderRuntime(); renderUndo(exercise); refreshEditorCounter(exercise); @@ -739,6 +803,55 @@ function renderLab(lesson) { updateLabControls(); } +function mountCodeEditor(parent, exercise) { + const onChange = (value) => { + state.drafts.set(exercise.id, value); + state.undo.delete(exercise.id); + renderUndo(exercise); + refreshDraftDependentUi(exercise); + scheduleSave(); + }; + const options = { + parent, source: sourceFor(exercise), theme: state.editorTheme, wrap: state.editorWrap, + label: `${exercise.title} Java 코드 편집기`, descriptionId: 'editor-hint editor-save-state', + maxLength: SOURCE_LIMIT, onChange, onBlur: saveStorage, + onLimit: () => toast(`코드는 ${SOURCE_LIMIT.toLocaleString('ko-KR')}자까지 작성할 수 있습니다.`), + }; + if (editorLibrary) { + activeEditor = editorLibrary.createJavaEditor(options); + return; + } + const textarea = el('textarea', 'code-editor-fallback'); + textarea.value = options.source; + textarea.maxLength = SOURCE_LIMIT; + textarea.spellcheck = false; + textarea.setAttribute('aria-label', options.label); + textarea.setAttribute('aria-describedby', options.descriptionId); + textarea.dataset.testid = 'code-editor-input'; + textarea.setAttribute('autocapitalize', 'off'); + textarea.setAttribute('autocorrect', 'off'); + textarea.setAttribute('autocomplete', 'off'); + textarea.setAttribute('wrap', state.editorWrap ? 'soft' : 'off'); + textarea.addEventListener('input', () => onChange(textarea.value)); + textarea.addEventListener('blur', saveStorage); + parent.dataset.editorEngine = 'textarea-fallback'; + parent.append(textarea); + activeEditor = { + getValue: () => textarea.value, + setValue: (value) => { textarea.value = value; }, + focus: () => textarea.focus(), + selectAll: () => { textarea.focus(); textarea.select(); }, + indent: () => { + textarea.setRangeText(' ', textarea.selectionStart, textarea.selectionEnd, 'end'); + onChange(textarea.value); + textarea.focus(); + }, + setTheme: () => {}, + setWrap: (wrap) => textarea.setAttribute('wrap', wrap ? 'soft' : 'off'), + destroy: () => {}, + }; +} + function refreshEditorCounter(exercise) { const count = document.getElementById('editor-line-count'); if (count) count.textContent = `${sourceFor(exercise).split('\n').length} lines · ${sourceFor(exercise).length.toLocaleString('ko-KR')} / ${SOURCE_LIMIT.toLocaleString('ko-KR')} chars`; @@ -755,12 +868,11 @@ function renderUndo(exercise) { const source = state.undo.get(exercise.id); state.undo.delete(exercise.id); state.drafts.set(exercise.id, source); - const editor = document.getElementById('code-editor'); - if (editor) editor.value = source; + activeEditor?.setValue(source); refreshDraftDependentUi(exercise); renderUndo(exercise); saveStorage(); - editor?.focus(); + activeEditor?.focus(); announce('이전 작성 코드를 복구했습니다.'); }, 'lab-undo')); } @@ -944,7 +1056,7 @@ async function executeCode(exercise, action) { const source = sourceFor(exercise); if (!source.trim()) { toast('실행할 Java 코드를 입력해 주세요.'); - document.getElementById('code-editor')?.focus(); + activeEditor?.focus(); return; } saveStorage(); @@ -1170,14 +1282,17 @@ function bindEvents() { async function boot() { try { - const [curriculumModule, exerciseModule, evidenceModule] = await Promise.all([ + const [curriculumModule, exerciseModule, evidenceModule, editorModule] = await Promise.all([ import('./data/curriculum.js'), import('./data/exercises.js'), import('./data/job-evidence.js'), + import('./assets/editor.bundle.js').catch(() => null), ]); lessons = curriculumModule.lessons; exercises = exerciseModule.exercises; jobEvidence = evidenceModule.jobEvidence; + editorLibrary = editorModule; if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(exercises) || !Array.isArray(jobEvidence)) throw new Error('Content data unavailable'); restoreStorage(); + restoreEditorPreferences(); readHash(); ensureSelection(); bindEvents(); diff --git a/dist/editor.js b/dist/editor.js new file mode 100644 index 0000000..7137623 --- /dev/null +++ b/dist/editor.js @@ -0,0 +1,122 @@ +import { Compartment, EditorSelection, EditorState, Transaction } from '@codemirror/state'; +import { EditorView, drawSelection, dropCursor, highlightActiveLine, highlightActiveLineGutter, highlightSpecialChars, keymap, lineNumbers } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap, indentMore } from '@codemirror/commands'; +import { LanguageSupport, bracketMatching, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language'; +import { javaLanguage } from '@codemirror/lang-java'; +import { highlightTree, styleTags, tagHighlighter, tags } from '@lezer/highlight'; + +// These are syntactic categories. No symbol resolver or Java compiler runs in the editor. +const learningJava = javaLanguage.configure({ + props: [styleTags({ + Definition: tags.definition(tags.variableName), + Identifier: tags.variableName, + 'ClassDeclaration/Definition InterfaceDeclaration/Definition EnumDeclaration/Definition RecordDeclaration/Definition': tags.className, + 'AnnotationTypeDeclaration/Identifier': tags.className, + 'TypeParameter/Definition': tags.typeName, + 'MethodDeclaration/Definition ConstructorDeclaration/Definition': tags.function(tags.variableName), + 'MethodName/Identifier': tags.function(tags.variableName), + 'record sealed permits': tags.definitionKeyword, + })], +}); + +const javaHighlighter = tagHighlighter([ + { tag: tags.keyword, class: 'tok-keyword' }, + { tag: tags.modifier, class: 'tok-keyword' }, + { tag: tags.className, class: 'tok-class' }, + { tag: tags.typeName, class: 'tok-type' }, + { tag: tags.variableName, class: 'tok-variable' }, + { tag: tags.function(tags.variableName), class: 'tok-method' }, + { tag: [tags.string, tags.character], class: 'tok-string' }, + { tag: tags.comment, class: 'tok-comment' }, + { tag: [tags.number, tags.bool, tags.null], class: 'tok-number' }, + { tag: tags.operator, class: 'tok-operator' }, + { tag: tags.punctuation, class: 'tok-punctuation' }, + { tag: tags.meta, class: 'tok-annotation' }, +]); + +const THEMES = new Set(['dark', 'light', 'contrast']); +function normalizeTheme(theme) { return THEMES.has(theme) ? theme : 'dark'; } +function colorMode(theme) { return EditorView.theme({}, { dark: normalizeTheme(theme) !== 'light' }); } + +export function javaHighlightSpans(source) { + const spans = []; + highlightTree(learningJava.parser.parse(source), javaHighlighter, (from, to, classes) => spans.push({ from, to, classes })); + return spans; +} + +export function highlightJavaCode(target, source) { + const fragment = target.ownerDocument.createDocumentFragment(); + let position = 0; + for (const { from, to, classes } of javaHighlightSpans(source)) { + if (from > position) fragment.append(target.ownerDocument.createTextNode(source.slice(position, from))); + const span = target.ownerDocument.createElement('span'); + span.className = classes; + span.textContent = source.slice(from, to); + fragment.append(span); + position = to; + } + if (position < source.length) fragment.append(target.ownerDocument.createTextNode(source.slice(position))); + target.replaceChildren(fragment); +} + +export function createJavaEditor({ parent, source, theme = 'dark', wrap = false, label, descriptionId, maxLength = 50000, onChange, onBlur, onLimit }) { + const mode = new Compartment(); + const wrapping = new Compartment(); + let suppressChange = false; + let destroyed = false; + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: source, + extensions: [ + new LanguageSupport(learningJava), + syntaxHighlighting(javaHighlighter), + lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), highlightSpecialChars(), + drawSelection(), dropCursor(), history(), bracketMatching(), indentOnInput(), + EditorState.tabSize.of(4), indentUnit.of(' '), + // Deliberately omit indentWithTab: Tab remains standard keyboard navigation. + keymap.of([...defaultKeymap, ...historyKeymap]), + mode.of(colorMode(theme)), + wrapping.of(wrap ? EditorView.lineWrapping : []), + EditorView.contentAttributes.of({ + role: 'textbox', 'aria-label': label, 'aria-multiline': 'true', + 'aria-describedby': descriptionId, 'data-testid': 'code-editor-input', + spellcheck: 'false', autocorrect: 'off', autocapitalize: 'off', autocomplete: 'off', + }), + EditorState.transactionFilter.of((transaction) => { + if (!transaction.docChanged || transaction.newDoc.length <= maxLength) return transaction; + queueMicrotask(() => { if (!destroyed) onLimit?.(); }); + return []; + }), + EditorView.updateListener.of((update) => { + if (update.docChanged && !suppressChange) onChange?.(update.state.doc.toString()); + }), + EditorView.domEventHandlers({ blur: () => { onBlur?.(); return false; } }), + ], + }), + }); + parent.dataset.editorEngine = 'codemirror'; + return { + getValue: () => view.state.doc.toString(), + focus: () => view.focus(), + selectAll: () => { + view.dispatch({ selection: EditorSelection.single(0, view.state.doc.length) }); + view.focus(); + }, + indent: () => { indentMore(view); view.focus(); }, + setValue: (value) => { + if (value === view.state.doc.toString()) return; + suppressChange = true; + try { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: value }, + selection: EditorSelection.cursor(0), + annotations: Transaction.addToHistory.of(false), + }); + } finally { suppressChange = false; } + }, + setTheme: (nextTheme) => view.dispatch({ effects: mode.reconfigure(colorMode(nextTheme)) }), + setWrap: (nextWrap) => view.dispatch({ effects: wrapping.reconfigure(nextWrap ? EditorView.lineWrapping : []) }), + destroy: () => { destroyed = true; view.destroy(); }, + }; +} diff --git a/dist/index.html b/dist/index.html index 969617f..e90e717 100644 --- a/dist/index.html +++ b/dist/index.html @@ -70,7 +70,7 @@
-

LEARNING GUIDE

이 학습실을 사용하는 방법

다른 언어 경험자의 Java 전환과 현직 개발자의 심화 학습을 함께 지원합니다. 이론 → 예제 → 문제 → 실무 → 코드 실습 순서로 진행해 보세요.

  • 학습 기준: Java 8·11·17·21. 선택 버전에 맞는 수업을 표시합니다.
  • 객관식 성취: 한 수업의 모든 문제에 정답을 제출하면 기록됩니다.
  • 실습 통과: 현재 작성 코드로 제출한 테스트가 모두 통과하면 기록됩니다. 두 조건을 모두 충족하면 수업 완료입니다.
  • 설계 기록: 나의 판단을 정리하는 메모입니다. 자동 평가나 정답 판정이 아닙니다.
  • 실행 환경: 실제 JVM은 Java 21이며, 실습별 최소 버전을 컴파일 대상으로 사용합니다. 다른 JVM 버전에서 실행한 결과는 아닙니다.
  • 저장: 코드, 메모, 진도는 이 브라우저에 저장됩니다. 기기 간 동기화·계정 백업을 제공하지 않습니다.

초기 프로토타입의 편집기는 일반 텍스트 입력 방식입니다. 자동 완성이나 실시간 문법 진단은 제공하지 않습니다.

+

LEARNING GUIDE

이 학습실을 사용하는 방법

다른 언어 경험자의 Java 전환과 현직 개발자의 심화 학습을 함께 지원합니다. 이론 → 예제 → 문제 → 실무 → 코드 실습 순서로 진행해 보세요.

  • 학습 기준: Java 8·11·17·21. 선택 버전에 맞는 수업을 표시합니다.
  • 객관식 성취: 한 수업의 모든 문제에 정답을 제출하면 기록됩니다.
  • 실습 통과: 현재 작성 코드로 제출한 테스트가 모두 통과하면 기록됩니다. 두 조건을 모두 충족하면 수업 완료입니다.
  • 설계 기록: 나의 판단을 정리하는 메모입니다. 자동 평가나 정답 판정이 아닙니다.
  • 실행 환경: 실제 JVM은 Java 21이며, 실습별 최소 버전을 컴파일 대상으로 사용합니다. 다른 JVM 버전에서 실행한 결과는 아닙니다.
  • 저장: 코드, 메모, 진도는 이 브라우저에 저장됩니다. 기기 간 동기화·계정 백업을 제공하지 않습니다.

코드 예제와 실습에서 Java 구문 강조와 3가지 테마를 사용할 수 있습니다. 실습은 줄 번호·자동 들여쓰기·괄호 짝 표시를 지원합니다. 색상은 코드의 구문을 구분하며 컴파일 결과를 대신하지 않습니다.

BROWSER RECORDS

학습 기록을 초기화할까요?

이 브라우저의 진도, 작성 코드와 설계 메모를 모두 지웁니다. 현재 학습 기준 버전은 유지됩니다.

diff --git a/dist/styles.css b/dist/styles.css index cd15acb..e49ec25 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -173,14 +173,97 @@ ul, ol { padding-left: 1.35rem; } .button-quiet:hover:not(:disabled) { background: #f0f4e9; } .button-danger { background: #a34227; color: #fff; } .button-small { font-size: .625rem; padding: .4rem .7rem; min-height: 34px; } -.code-shell { min-width: 0; overflow: hidden; border: 1px solid #263f34; border-radius: 10px; background: #19372f; margin: 1rem 0; } -.code-heading { display: flex; align-items: center; justify-content: space-between; gap: .7rem; padding: .6rem .95rem; background: #143127; color: #b6c8af; border-bottom: 1px solid #2c4938; font: .625rem/1.6 var(--mono); } +.code-shell, .editor-shell { + color-scheme: dark; + --code-bg: #172c2b; + --code-toolbar: #112321; + --code-fg: #e2e9df; + --code-muted: #b2c4b7; + --code-border: #405c52; + --code-gutter: #132724; + --code-gutter-text: #9bb4a7; + --code-active-line: #233b34; + --code-selection: #304751; + --code-focus: #b9d69b; + --code-keyword: #cfa7ef; + --code-class: #edcb78; + --code-type: #76d7c3; + --code-variable: #d1e4fb; + --code-method: #90beff; + --code-string: #bddf91; + --code-comment: #b1c0b2; + --code-number: #f4b98c; + --code-operator: #c8d5cf; +} +[data-editor-theme="light"] { + color-scheme: light; + --code-bg: #fcfbf6; + --code-toolbar: #f0f2e8; + --code-fg: #283e33; + --code-muted: #53674e; + --code-border: #b9c6af; + --code-gutter: #f1f3e9; + --code-gutter-text: #697b5c; + --code-active-line: #edf1e5; + --code-selection: #d2e0db; + --code-focus: #49723d; + --code-keyword: #79439c; + --code-class: #855206; + --code-type: #076054; + --code-variable: #254d80; + --code-method: #3e4caa; + --code-string: #3b6022; + --code-comment: #4e5e46; + --code-number: #943d16; + --code-operator: #475d53; +} +[data-editor-theme="contrast"] { + color-scheme: dark; + --code-bg: #081110; + --code-toolbar: #030a08; + --code-fg: #f4fff8; + --code-muted: #d6e7d8; + --code-border: #a0b9a6; + --code-gutter: #081110; + --code-gutter-text: #c3d7c7; + --code-active-line: #152920; + --code-selection: #304650; + --code-focus: #e2ff92; + --code-keyword: #e0b5ff; + --code-class: #ffe57f; + --code-type: #59efdc; + --code-variable: #e2f0ff; + --code-method: #9bcaff; + --code-string: #cefa9b; + --code-comment: #b6cbb9; + --code-number: #ffd0a8; + --code-operator: #e0ebe3; +} +.code-shell { min-width: 0; overflow: hidden; border: 1px solid var(--code-border); border-radius: 10px; background: var(--code-bg); margin: 1rem 0; } +.code-heading { display: flex; align-items: center; justify-content: space-between; gap: .7rem; padding: .6rem .95rem; background: var(--code-toolbar); color: var(--code-muted); border-bottom: 1px solid var(--code-border); font: .75rem/1.6 var(--mono); } .code-heading > span { display: flex; align-items: center; gap: .4rem; min-width: 0; } .code-heading .icon { width: 13px; height: 13px; flex-basis: 13px; } -.copy-button { display: inline-flex; align-items: center; gap: .35rem; color: #c1d2b9; padding: .3rem .4rem; border-radius: 4px; font: .5625rem/1.5 var(--font); flex-shrink: 0; } -.copy-button:hover { background: #294736; } -.code-block { padding: 1.2rem; font: .75rem/1.85 var(--mono); color: #e1edd6; white-space: pre; overflow-x: auto; tab-size: 4; scrollbar-width: thin; scrollbar-color: #6f8767 #19372f; } +.copy-button { display: inline-flex; align-items: center; gap: .35rem; color: var(--code-muted); padding: .3rem .4rem; border-radius: 4px; font: .75rem/1.5 var(--font); flex-shrink: 0; min-height: 36px; } +.copy-button:hover { background: var(--code-active-line); } +.code-block { padding: 1.2rem; font: .875rem/1.85 var(--mono); color: var(--code-fg); white-space: pre; overflow-x: auto; tab-size: 4; scrollbar-width: thin; scrollbar-color: var(--code-border) var(--code-bg); } .code-block code { font: inherit; } +.code-settings { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem .8rem; padding: .65rem .95rem; background: var(--code-toolbar); color: var(--code-muted); } +.code-theme-label { font-size: .75rem; } +.editor-theme-select { min-height: 36px; min-width: 6.5rem; max-width: 100%; background: var(--code-bg); color: var(--code-fg); border: 1px solid var(--code-border); border-radius: 6px; padding: .3rem .55rem; font-size: .75rem; } +.syntax-legend { display: flex; flex-wrap: wrap; gap: .35rem .8rem; padding: .55rem .95rem .7rem; border-bottom: 1px solid var(--code-border); background: var(--code-toolbar); } +.syntax-legend-item { display: inline-flex; align-items: center; gap: .3rem; font: .6875rem/1.6 var(--font); } +.syntax-legend-item::before { content: ""; display: inline-block; width: .35rem; height: .35rem; border-radius: 50%; background: currentColor; flex-shrink: 0; } +.tok-keyword { color: var(--code-keyword); font-weight: 600; } +.tok-class { color: var(--code-class); font-weight: 650; } +.tok-type { color: var(--code-type); } +.tok-variable { color: var(--code-variable); } +.tok-method { color: var(--code-method); } +.tok-string { color: var(--code-string); } +.tok-comment { color: var(--code-comment); font-style: italic; } +.tok-number { color: var(--code-number); } +.tok-operator, .tok-punctuation { color: var(--code-operator); } +.tok-annotation { color: var(--code-class); } +.syntax-note { margin-top: -.3rem; } .expected-output { margin: 1.2rem 0; padding: 1rem 1.1rem; border: 1px dashed #cbd6c1; border-radius: 8px; background: #f6f8f1; } .expected-output .small-label { color: #5d7850; margin-bottom: .6rem; font-size: .5625rem; } .expected-output pre { font: .75rem/1.75 var(--mono); color: #385731; white-space: pre; overflow-x: auto; } @@ -231,16 +314,31 @@ ul, ol { padding-left: 1.35rem; } .lab-rubric { margin: 1rem 0; border: 1px solid #dfe5d8; border-radius: 8px; padding: .7rem .9rem; } .lab-rubric summary { cursor: pointer; font-size: .6875rem; font-weight: 600; color: #5c714e; } .lab-rubric ul { margin-top: .6rem; font-size: .6875rem; line-height: 1.85; color: #687762; } -.editor-shell { border: 1px solid #284635; border-radius: 10px; overflow: hidden; margin-top: 1.2rem; background: #17342b; } -.editor-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .3rem .7rem; background: #123026; border-bottom: 1px solid #2a4737; padding: .55rem .85rem; color: #b3c9aa; } -.editor-filename { display: inline-flex; align-items: center; gap: .4rem; font: .625rem/1.6 var(--mono); } +.editor-shell { border: 1px solid var(--code-border); border-radius: 10px; overflow: hidden; margin-top: 1.2rem; background: var(--code-bg); } +.editor-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .3rem .7rem; background: var(--code-toolbar); border-bottom: 1px solid var(--code-border); padding: .55rem .85rem; color: var(--code-muted); } +.editor-filename { display: inline-flex; align-items: center; gap: .4rem; font: .75rem/1.6 var(--mono); } .editor-filename .icon { width: 13px; height: 13px; flex-basis: 13px; } .editor-tools { display: flex; flex-wrap: wrap; gap: .35rem; } -.editor-tools button { padding: .3rem .45rem; font-size: .5625rem; color: #bed0b4; border-radius: 4px; min-height: 32px; } -.editor-tools button:hover { background: #2b4b37; } -.code-editor { display: block; width: 100%; min-height: 305px; height: 365px; max-height: 70vh; resize: vertical; padding: 1.15rem 1.2rem; border: 0; border-radius: 0; background: #17342b; color: #e6eedc; font: .75rem/1.85 var(--mono); letter-spacing: 0; tab-size: 4; white-space: pre; overflow: auto; caret-color: #e2eaaa; scrollbar-width: thin; scrollbar-color: #6b835e #17342b; } -.code-editor:focus-visible { outline-offset: -4px; outline-color: #cbd985; } -.editor-status { display: flex; justify-content: space-between; flex-wrap: wrap; gap: .4rem; padding: .45rem .85rem; color: #acc09e; border-top: 1px solid #2a4737; font: .5625rem/1.6 var(--mono); } +.editor-tools button { padding: .3rem .45rem; font-size: .75rem; color: var(--code-muted); border-radius: 4px; min-height: 36px; } +.editor-tools button:hover, .editor-tools button[aria-pressed="true"] { background: var(--code-active-line); } +.editor-tools button[aria-pressed="true"] { box-shadow: inset 0 -2px var(--code-focus); } +.code-editor { display: block; width: 100%; min-width: 0; color: var(--code-fg); background: var(--code-bg); } +.code-editor .cm-editor, .code-editor-fallback { min-height: 12rem; height: 23rem; max-height: 70vh; font: .875rem/1.8 var(--mono); color: var(--code-fg); background: var(--code-bg); } +.code-editor .cm-editor.cm-focused { outline: 2px solid var(--code-focus); outline-offset: -3px; } +.code-editor .cm-scroller { overflow: auto; font: inherit; scrollbar-width: thin; scrollbar-color: var(--code-border) var(--code-bg); } +.code-editor .cm-content { padding: 1rem 0; caret-color: var(--code-focus); } +.code-editor .cm-content:focus-visible { outline: none; } +.code-editor .cm-line { padding-right: 1.2rem; padding-left: 1rem; } +.code-editor .cm-gutters { background: var(--code-gutter); color: var(--code-gutter-text); border-right: 1px solid var(--code-border); } +.code-editor .cm-lineNumbers .cm-gutterElement { min-width: 2.5rem; padding-right: .65rem; padding-left: .65rem; font-size: .75rem; } +.code-editor .cm-activeLine, .code-editor .cm-activeLineGutter { background-color: var(--code-active-line); } +.code-editor .cm-cursor { border-left-color: var(--code-focus); } +.code-editor .cm-selectionBackground, .code-editor .cm-focused .cm-selectionBackground, .code-editor .cm-content ::selection, .code-block ::selection, .code-editor-fallback::selection { background-color: var(--code-selection) !important; } +.code-editor .cm-matchingBracket { background: var(--code-active-line); outline: 1px solid var(--code-focus); color: var(--code-fg); } +.code-editor .cm-nonmatchingBracket { outline: 1px dashed #ed8a7b; } +.code-editor-fallback { display: block; width: 100%; resize: vertical; padding: 1rem; border: 0; border-radius: 0; letter-spacing: 0; tab-size: 4; overflow: auto; caret-color: var(--code-focus); } +.code-editor-fallback:focus-visible { outline-offset: -4px; outline-color: var(--code-focus); } +.editor-status { display: flex; justify-content: space-between; flex-wrap: wrap; gap: .4rem; padding: .5rem .85rem; color: var(--code-muted); border-top: 1px solid var(--code-border); font: .6875rem/1.6 var(--mono); } .editor-hint { font-size: .6875rem; color: var(--muted); margin-top: .45rem; line-height: 1.8; } .reset-undo { padding: .55rem .75rem; margin-top: .6rem; background: #f8efdf; color: #875c38; border-radius: 6px; font-size: .625rem; } .reset-undo button { font-size: inherit; font-weight: 700; text-decoration: underline; margin-left: .5rem; color: #815334; } @@ -414,7 +512,7 @@ ul, ol { padding-left: 1.35rem; } .learning-goals { padding: .9rem; margin-bottom: 1.4rem; } .learning-goals li { font-size: .875rem; } .theory-section p { font-size: .9375rem; } - .code-block { padding: 1rem; font-size: .6875rem; } + .code-block { padding: 1rem; font-size: .875rem; } .lesson-footer { padding: .75rem 1.25rem; } .reference-column { display: flex; } .reference-card { padding: 1.2rem 1.25rem; } @@ -434,11 +532,13 @@ ul, ol { padding-left: 1.35rem; } .lab-mode { font-size: .625rem; } .runtime-line { font-size: .6875rem; } .runtime-card p { font-size: .75rem; } - .code-editor { padding: 1rem; height: 330px; font-size: .75rem; } + .code-editor .cm-editor, .code-editor-fallback { height: 21rem; font-size: .875rem; } .editor-toolbar { padding: .45rem .65rem; } - .editor-tools button { font-size: .625rem; } - .editor-filename { font-size: .6875rem; } - .editor-status { font-size: .5625rem; padding: .45rem .65rem; } + .editor-tools button { font-size: .75rem; } + .editor-filename { font-size: .75rem; } + .editor-status { font-size: .6875rem; padding: .45rem .65rem; } + .code-settings, .syntax-legend { padding-right: .75rem; padding-left: .75rem; } + .code-editor .cm-line { padding-left: .65rem; } .execution-disclosure { font-size: .625rem; } .lab-actions .button { flex: 1 1 auto; padding: .65rem .7rem; font-size: .6875rem; } .lab-actions .execution-state { margin-left: 0; flex-basis: 100%; font-size: .625rem; } @@ -467,7 +567,7 @@ ul, ol { padding-left: 1.35rem; } } @media (max-height: 600px) { .curriculum-sidebar { top: .75rem; } - .code-editor { min-height: 180px; max-height: 70vh; } + .code-editor .cm-editor, .code-editor-fallback { min-height: 9rem; max-height: 70vh; } .app-dialog { max-height: calc(100dvh - 1rem); } } @media (max-width: 899px) and (max-height: 600px) { @@ -485,4 +585,5 @@ ul, ol { padding-left: 1.35rem; } .lesson-card { border: 0; box-shadow: none; } .reference-column { display: block; margin-top: 1rem; } .code-block, .code-editor { white-space: pre-wrap; color: black; background: white; } + .code-editor .cm-editor { height: auto; max-height: none; } } diff --git a/docs/editor-qa-report.md b/docs/editor-qa-report.md new file mode 100644 index 0000000..6cc4c88 --- /dev/null +++ b/docs/editor-qa-report.md @@ -0,0 +1,118 @@ +# M4.1 편집기·문법 강조 독립 QA + +- 기준일: 2026-10-12 +- 담당: 독립 QA. 앱·교재·서버 코드는 수정하지 않았다. +- 최종 판정: **PASS**. 기능 QA와 독립 시각 재검수가 통과했다. 선택 상태 글자 대비 P2는 수정 후 실제 계산색 검사와 원본 화면 검수로 해결 확인했다. +- 범위: CodeMirror 도입, Java 구문별 색, 다크·라이트·고대비 테마, 편집·저장·실행 연동, 기본 편집기 fallback과 기존 학습 흐름 회귀. + +## 환경과 실행 방법 + +Windows, Node v22.17.0, Playwright 1.62.1, Microsoft Edge 155.0.4283.45의 headless 독립 context를 사용했다. 부모가 실행한 `http://127.0.0.1:4173` 서버에 연결했으며 서버 시작·재시작·의존성 설치는 수행하지 않았다. 로컬 sandbox 경계 때문에 브라우저 명령은 승인된 `require_escalated` 실행을 사용했다. + +모든 `/api/*`는 Playwright route로 모의 처리했다. 이번 QA에서 실제 Wandbox 실행 요청은 보내지 않았다. Clipboard API도 context 내부에 모의 구현하여 사용자 운영체제 클립보드를 덮어쓰지 않았다. 브라우저 viewport 검증은 **에뮬레이션이며 실제 기기 테스트가 아니다**. + +```powershell +node --test tests/editor.test.mjs +node --check tests/browser.mjs + +$env:PLAYWRIGHT_CHANNEL='msedge' +$env:QA_ARTIFACT_DIR='C:/Users/USER/Documents/Codex/2026-10-12/new-chat/work/java-edu-editor-qa' +Remove-Item -LiteralPath Env:QA_FILTER -ErrorAction SilentlyContinue +node tests/browser.mjs +``` + +`QA_FILTER`는 선택 실행용 정규식이다. 지정하지 않으면 전체 브라우저 시나리오를 실행한다. CI는 `PLAYWRIGHT_CHANNEL`을 생략하여 설치된 Chromium을 사용할 수 있다. CI 실행 결과는 부모의 별도 검증 기록을 따른다. + +## 실제 실행 기록 + +| 실행 | 실제 결과 | 해석 | +| --- | --- | --- | +| 문법 분류 Node 단위검사 | 4 PASS / 0 FAIL | 선언·호출 구분, 문자열·주석, 최신 구문 표기, 빈값·미완성·Unicode 경계 | +| 신규 편집기 브라우저 1차 | 10 PASS / 2 FAIL | 아래의 테스트 기대 정의 두 건을 보정했다. 제품 결함으로 집계하지 않는다. | +| 기존 35개 + 신규 12개 전체 회귀 | 47 PASS / 0 FAIL | 실제 전체 실행 결과 | +| 긴 한글 확대 캡처 재실행 + 50,000자 경계 추가 | 2 PASS / 0 FAIL | 앞의 47개와 중복 1개, 신규 1개 | +| 세 테마 선택·caret 상태 추가 | 1 PASS / 0 FAIL | 범위 선택·caret 표시 자동검사는 통과했으나 이미지 검수에서 대비 문제를 발견했다. | +| 선택 대비 수정 후 테마·선택·모바일 관련 재실행 | 3 PASS / 0 FAIL | 실제 계산색 기준 8개 토큰 종류 모두 4.5:1 이상. 정상·선택·caret·모바일 캡처 갱신 | + +브라우저 고유 시나리오 49개와 문법 단위검사 4개를 실제 검증했다. 최종 파일의 모든 조건을 한 차례에 49개 실행했다고 주장하지 않는다. 전체 47개 실행 이후 추가 경계와 시각 조건, 수정된 CSS 관련 조건을 위 표처럼 선별 재실행했다. + +1차의 테스트 정의 보정: + +1. 검색 결과가 0개가 되면 선택 수업이 해제되고 복구 후 이론 단계로 돌아간다. 테마 보존 검사에서 코드 실습으로 다시 진입한 뒤 원문을 대조하도록 변경했다. +2. CodeMirror의 Windows redo는 `Ctrl+Y`다. 잘못 사용한 `Ctrl+Shift+Z`를 플랫폼에 맞는 키로 바꾸고 실제 undo/redo를 다시 검증했다. + +## 수용 기준과 확인 결과 + +| 영역 | 실제 확인 내용 | 결과 | +| --- | --- | --- | +| 문법 강조 | 예제 원문 불변, keyword/class/type/variable의 서로 다른 계산색, 예제·IDE 팔레트 일치, method/string/comment/number 토큰, HTML처럼 보이는 코드의 텍스트 처리 | PASS | +| 편집기 계약 | 실제 contenteditable textbox, 접근 가능한 이름·multiline, 정상 모드에 숨겨진 textarea proxy 없음 | PASS | +| 테마·줄바꿈 | 3테마 전환, 저장 키 `java-edu:editor-preferences:v1`, 수업·탭·버전·검색·트랙 전환 및 reload 후 코드와 설정 유지 | PASS | +| 선택·이력 | 같은 편집 DOM 유지, 선택 범위 보존, theme 변경 후 Ctrl+Z/Y, 범위 교체, 초기 코드와 되돌리기, 선택 상태 8종 토큰 대비 | PASS | +| 키보드 | Enter Java 블록 공백 4칸, 들여쓰기 도구 선택 줄 +4칸과 undo, Tab의 다음 컨트롤 이동 | PASS | +| 선택·복사 | 실제 마우스 드래그, 선택 원문, 합성 ClipboardEvent의 선택 복사, 공개 전체복사 동작, 복사 거부 후 직접 복사 선택 fallback | PASS | +| 한글 조합 | 합성 compositionstart/update/end와 실제 insertText 연계, 빈 조합 종료, 중복·누락 없는 원문·요청·저장 비교 | PASS, OS IME는 미검증 | +| 빈값·원문 경계 | 빈 코드 전송 금지와 focus, 끝 빈 줄 3개 유지, 전체 원문을 복사·요청 body·저장·reload에서 비교 | PASS | +| 긴 코드 | 100줄 한글 코드의 전체 원문, DOM 가상화와 무관한 전체복사, 가로·세로 내부 스크롤, 줄바꿈, 320px·가로 회전·200% 글자 확대 후 보존 | PASS | +| 최대 길이 | 정확히 50,000자 허용, 초과 1자 입력의 원자적 거절과 기존 원문 보존, 독립 UTF-8 32 KiB 전송 제한, 경계 크기 reset undo | PASS | +| 실행 snapshot | 테마·줄바꿈 변경은 같은 코드의 채점 유지, 입력·수업·버전 변경·취소 후 오래된 응답은 완료에 미반영 | PASS, API mock | +| 저장 실패 | 손상된 테마 JSON은 기본값, 저장 거부 시 안내와 방문 중 테마·편집·실행 유지 | PASS | +| 확장 로드 실패 | editor bundle 차단 시 예제 원문과 노출된 실제 textarea 사용, 안내·테마·wrap·편집·실행·reset undo·320px 흐름 유지 | PASS | +| 반응형 | 320/360/390/768/1440px, 1280×600, 844×390, 회전·root 글자 크기 200%, 활성 탭·버튼 경계·코드 내부 overflow | PASS | + +기존 35개 회귀에는 12수업×5단계, Java 8/11/17/21 제한과 잘못된 hash, 검색 0건, 객관식 미응답·오답·재시도·완료·새로고침, 설계 메모, 초안 격리, run과 submit 구분, 객관식+현재 코드의 이중 완료, 같은 코드 재제출 실패의 완료 철회, HTTP·네트워크·JSON 오류, 불일치 응답, 저장 거부·손상, 초기화 취소·확인, 키보드·모바일 드로어를 포함한다. 기존 상세 정의는 [M4 QA 기록](qa-report.md)을 참조하되, 이번 표의 PASS는 교체된 CodeMirror와 함께 실제 재실행한 결과다. + +## 발견한 제품 결함 + +### 해결: P2 — 선택한 코드의 일부 토큰 대비 부족 + +재현: 코드 실습에 키워드·클래스·타입·변수·메서드·문자열·주석·숫자가 있는 Java 코드 입력 → 다크/라이트/고대비 각각에서 편집기를 focus → Ctrl+A 전체선택. + +기대: 일반 크기 코드 텍스트가 선택 중에도 4.5:1 이상의 대비를 유지한다. + +실제: 불투명 `.cm-selectionBackground` 배경에 대해 다크 주석 3.25:1·키워드 3.75:1·메서드 3.96:1, 라이트 주석 3.64:1·문자열 4.17:1, 고대비 주석·키워드 4.25:1 등을 확인했다. QA와 독립 시각 검수자가 원본 이미지 및 sRGB 계산을 각각 확인했다. 선택 해제 상태의 텍스트와 별개로 선택 중 가독성 문제다. + +구현 담당자는 `dist/styles.css`에서 선택 배경을 다크 `#304751`, 라이트 `#d2e0db`, 고대비 `#304650`으로 바꾸고 일부 토큰 색을 조정했다. QA는 앱 코드를 직접 수정하지 않았다. 수정 후 같은 선택 시나리오에서 `getComputedStyle`로 읽은 실제 배경·전경의 대비를 자동 계산했다. + +| 테마 | 선택한 8종 토큰의 최저 대비 | 결과 | +| --- | --- | --- | +| 다크 | 4.859:1 | PASS | +| 라이트 | 4.810:1 | PASS | +| 고대비 | 5.772:1 | PASS | + +`editor-{dark,light,contrast}-selection.png`는 수정 후 화면으로 갱신했다. 선택 해제 일반 상태의 예제·IDE, caret, 모바일 테마 화면도 함께 갱신하여 독립 시각 검수자에게 전달했다. + +## 화면 증거 + +로컬 증거 루트: `C:/Users/USER/Documents/Codex/2026-10-12/new-chat/work/java-edu-editor-qa/`. + +- `editor-{dark,light,contrast}-{example,ide}.png`: 세 테마 예제·IDE. +- `editor-{dark,light,contrast}-{selection,caret}.png`: 실제 전체선택과 선택 해제 후 caret. 복사 toast 없이 촬영했다. +- `editor-mobile-{dark,light,contrast}.png`: 390px의 세 테마. +- `editor-mobile-200-theme.png`, `editor-mobile-200-execution-controls.png`: 390px·200% 글자 확대. +- `editor-mobile-200-long-korean.png`, `editor-320-200-controls.png`: 긴 한글 코드·좁은 화면. 복사 toast가 사라진 뒤 재촬영했다. +- `editor-fallback-320.png`: 확장 로드 실패 상태의 실제 기본 편집기. +- 이전 35개 회귀로 생성한 `desktop-*`, `mobile-*`, `viewport-*`도 동일 루트에 보관했다. + +`failure-editor-theme-wrap-persist-across-navigation-and-reload.png`와 `failure-editor-theme-preserves-selection-and-keyboard-undo.png`는 1차 테스트 정의 오류 시점의 진단 자료다. 최종 성공·시각 승인 근거로 사용하지 않는다. + +## 변경 파일과 한계 + +QA 변경: `tests/browser.mjs`, `tests/editor.test.mjs`, 이 보고서. 스크린샷은 전용 `work/java-edu-editor-qa/`에만 기록했다. 다른 담당자의 앱·서버·문서 파일을 수정하지 않았다. + +최종 선별 재검증 시점 SHA-256: + +| 파일 | SHA-256 | +| --- | --- | +| `dist/editor.js` | `284A9BE4E2CE098C7F05A9B0697D94FD46BA28D28646E703A22D7D4657591E13` | +| `dist/app.js` | `9827054696499FE8D38C35A9DE6E76656613E271EEA3602C956F711904E0CA22` | +| `dist/styles.css` | `442AF25EE89BCE84A266F58DE346AD089F3462609D7E2D1AF7C3201C6831FD46` | +| `dist/assets/editor.bundle.js` | `BC95D48A58E7C20F2A14481CED42D11B28B8CE5E1442B71AD795F6A5DDFD4DF9` | +| `tests/browser.mjs` | `CB4225C9FB8FCE00AAE6CBDC438F11FC5051532F9998974F02307BD13F8D339E` | +| `tests/editor.test.mjs` | `BAF867586F6C539C1D747B2B64B5A8BE699F6E5CE1255ADCC060E1CB20434209` | + +전체 47개 실행 뒤 번들에 의존성 LICENSE 주석이 추가되었고, 편집 동작 JavaScript는 변경되지 않았다. 이 주석 변경만으로 전체 회귀를 반복하지 않았다. 선택 대비 CSS 수정은 영향을 받는 테마·선택·모바일 3개를 실제 재실행했다. + +미확인: 실제 Windows/macOS/모바일 IME 및 후보 창, 물리 키보드, 실제 OS 클립보드 권한·복사, 모바일 길게 누르기·선택 핸들·가상 키보드, 실제 기기, Safari·Firefox·스크린리더, 브라우저 자체 page zoom, 배포 환경, 실제 Java provider 실행. root font-size 200%는 글자 확대 검사이며 브라우저 전체 확대와 동일하다고 주장하지 않는다. + +독립 시각 담당자는 수정 후 세 테마 선택 합성 화면과 라이트·다크 기본 IDE를 직접 재확인하여 M4.1 시각 PASS를 전달했다. 선택 경계 식별성과 글자 대비 개선, 독립 계산 최솟값 일치를 확인했다. 기능 QA에서 미해결 중요 결함은 없다. 이 판정은 위에 명시한 로컬 브라우저·모의 API 범위에 한정된다. diff --git a/docs/milestones.md b/docs/milestones.md index e629d46..8d6441d 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -8,8 +8,11 @@ | M2 | Java 코어12수업·과제·UI·진도·출처·CI | 콘텐츠 정합성·예제/답안·UI mock 검사·독립 리뷰·커밋/푸시 | 완료: `731b4a2` 원격 반영·CI PASS | | M3 | 실제 JVM21 외부 실행·샘플/제출 API·Worker 패키징 | API 단위 검사·실제 코드 실행·오류/버전 계약·독립 리뷰·커밋/푸시 | 완료: `fe68aef` 원격 반영·CI PASS | | M4 | 프로토타입 통합·배포 | 반응형·키보드·진도·오래된 응답 무효화·합성 화면·CI 확인·출시 커밋/푸시·배포 성공 | 완료: `165e113` 원격 반영·CI PASS·private 배포 성공 | -| M5 | 별도 JVM8/11/17/21·의존성·DB·HTTP 실행 기반 | 배포 자원·격리·정리·호환 조합·통합 검사 | 후속 설계 | +| M4.1 | Java 문법 색상·3테마·편집 기능 | 구문·브라우저 회귀·선택 대비·새 체크아웃 빌드·독립 코드/시각 검수 | 로컬 PASS, 원격·배포 진행 | +| M5 | Windows 설치형·로컬 JDK8/11/17/21·Maven 실행 | 세션·도구 등록·프로세스 정리·앱 재시작 진도·실제 로컬 실행·설치 파일 | 구현·검증 중 | | M6 | Spring/REST/Lombok/MyBatis/테스트 대표 수업 | 실제 의존성·다중 파일·DB·HTTP 코딩 실습 | M5 의존 | | M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | M5/6 의존 | -M4는 전체 교육 과정 완료를 뜻하지 않는다. 교재4버전과 실제 실행 JVM21의 차이, 외부 제공자 제한, 후속 자원 필요를 배포 결과와 함께 명시한다. 마일스톤마다 관련 파일만 stage하고 강제 푸시하지 않는다. 배포는 검증한 커밋의 동일 소스를 사용한다. +M4는 전체 교육 과정 완료를 뜻하지 않는다. 2026-10-12 사용자는 **Windows 설치형 + 웹 교재**를 선택했다. 학습자 로컬 PC에서 Java/Spring/H2를 실행하며, 호스팅 컨테이너 서버는 기본 과정에 필요하지 않다. Redis/Kafka 실제 연동은 별도 선택 환경이다. 웹의 기존 코어 실행은 Wandbox 실제 JVM21을 사용한다. 교재 버전·컴파일 대상·실제 JVM을 구분한다. 마일스톤마다 관련 파일만 stage하고 강제 푸시하지 않는다. 배포는 검증한 커밋의 동일 소스를 사용한다. + +M4.1 검증: 브라우저 고유49개를 전체47개와 추가/수정 영향 검사로 나눠 확인, 토큰 단위4개 PASS, 전체 Node31 PASS/실제 제공자1 SKIP. 선택 상태 최저 대비4.859/4.810/5.772:1. 생성 번들이 없는 작업 복사본에서 npm ci→build→Worker 검사→Node 검사 PASS. npm audit은 scoped global-agent 보완 후0건이며 실제 설치 파일 빌드는 M5에서 검사한다. 상세 범위·미확인은 docs/editor-qa-report.md를 따른다. diff --git a/package-lock.json b/package-lock.json index 76bc232..a3dd308 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,14 +7,393 @@ "": { "name": "java-edu", "version": "0.1.0", + "dependencies": { + "@codemirror/commands": "6.11.1", + "@codemirror/lang-java": "6.0.2", + "@codemirror/language": "6.13.1", + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.14", + "@lezer/highlight": "1.2.5" + }, "devDependencies": { + "electron": "44.7.0", + "electron-builder": "26.15.3", "esbuild": "0.28.2", + "fflate": "0.8.3", "playwright": "1.62.1" }, "engines": { "node": ">=22" } }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-java": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", + "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/java": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz", + "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/streamparser": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/streamparser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz", + "integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==", + "license": "MIT" + }, + "node_modules/@codemirror/view": { + "version": "6.43.14", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz", + "integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.1.tgz", + "integrity": "sha512-IS4pG3BysRrCLJ8xTAfvg+IQDbiUoAzdJy2SfjcYKnLDy5DpCdjnPdhS5Zf5UMfrkB24xu0KC+TqcOxg+eG+6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -457,93 +836,3372 @@ "node": ">=18" } }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" + "node": ">=18.0.0" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", "license": "MIT", - "optional": true, - "os": [ - "darwin" + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.5.tgz", + "integrity": "sha512-JppTd1iKHpeNlLYw/Zl8JX7zSY5HqAzK4zZ05lpn8l8Sk7Fo5KAKiDiMiezKoT5lfeC2xZf8HeKp1tSctj4G6Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz", + "integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 12.13.0" } }, - "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "playwright-core": "1.62.1" + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" }, - "bin": { - "playwright": "cli.js" + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=20" + "node": ">=10" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" }, - "optionalDependencies": { - "fsevents": "2.3.2" + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "node_modules/@peculiar/asn1-schema": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz", + "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" }, "engines": { - "node": ">=20" + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "44.7.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.7.0.tgz", + "integrity": "sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", + "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "globalthis": "^1.0.2", + "matcher": "^4.0.0", + "semver": "^7.3.5", + "serialize-error": "^8.1.0" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", + "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.37.0.tgz", + "integrity": "sha512-O9XXMXLeRHQtvh43NCah3y9N1K69tecOmu8Y5p2ay4iXvtpbQr0VaroJF9EmRVp3vkdoj3qn4IFfX+ZYtC94fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkijs": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", + "integrity": "sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "website" + ], + "dependencies": { + "@noble/hashes": "1.8.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-error": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } } } diff --git a/package.json b/package.json index c35cc45..7998ee2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "description": "Java 공식 문서 기반 전환 학습과 버전별 실무 심화", "scripts": { - "dev": "node scripts/serve.mjs", + "dev": "node scripts/build-editor.mjs && node scripts/serve.mjs", + "build:editor": "node scripts/build-editor.mjs", "test": "node --test tests/*.test.mjs", "build": "node scripts/build-worker.mjs", "test:worker": "node scripts/check-worker.mjs", @@ -16,8 +17,24 @@ "engines": { "node": ">=22" }, + "overrides": { + "@electron/get@3.1.0": { + "global-agent": "4.1.3" + } + }, "devDependencies": { + "electron": "44.7.0", + "electron-builder": "26.15.3", "esbuild": "0.28.2", + "fflate": "0.8.3", "playwright": "1.62.1" + }, + "dependencies": { + "@codemirror/commands": "6.11.1", + "@codemirror/lang-java": "6.0.2", + "@codemirror/language": "6.13.1", + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.14", + "@lezer/highlight": "1.2.5" } } diff --git a/scripts/build-editor.mjs b/scripts/build-editor.mjs new file mode 100644 index 0000000..e909ce9 --- /dev/null +++ b/scripts/build-editor.mjs @@ -0,0 +1,50 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const source = fileURLToPath(new URL('../dist/editor.js', import.meta.url)); +const assets = fileURLToPath(new URL('../dist/assets/', import.meta.url)); +const outfile = fileURLToPath(new URL('../dist/assets/editor.bundle.js', import.meta.url)); +await mkdir(assets, { recursive: true }); +const result = await build({ + absWorkingDir: root, + entryPoints: [source], outfile, + bundle: true, format: 'esm', platform: 'browser', target: 'es2022', + minify: true, sourcemap: false, legalComments: 'inline', charset: 'utf8', + write: false, metafile: true, +}); + +// Keep complete notices for exactly the dependencies included in this bundle. +const packageRoots = new Set(); +for (const input of Object.keys(result.metafile.inputs)) { + const absoluteInput = resolve(root, input).replaceAll('\\', '/'); + const marker = '/node_modules/'; + const dependencyStart = absoluteInput.lastIndexOf(marker); + if (dependencyStart === -1) continue; + const packageParts = absoluteInput.slice(dependencyStart + marker.length).split('/'); + const packageName = packageParts.slice(0, packageParts[0].startsWith('@') ? 2 : 1).join('/'); + packageRoots.add(`${absoluteInput.slice(0, dependencyStart + marker.length)}${packageName}`); +} +const notices = await Promise.all([...packageRoots].map(async (packageRoot) => { + const metadata = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8')); + let license; + for (const filename of ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENCE', 'LICENCE.md', 'LICENCE.txt']) { + try { + license = await readFile(resolve(packageRoot, filename), 'utf8'); + break; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + if (!license?.trim()) throw new Error(`Missing license notice for ${metadata.name}@${metadata.version}`); + return { name: `${metadata.name}@${metadata.version}`, license }; +})); +notices.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); +const noticeText = notices.map(({ name, license }) => `${name}\n${license}`).join('\n\n'); +const banner = `/*! Third-party license notices\n\n${noticeText.replaceAll('*/', '* /')}\n*/\n`; +const bundle = result.outputFiles.find((file) => resolve(file.path) === resolve(outfile)); +if (!bundle) throw new Error('The editor build did not produce its expected JavaScript bundle.'); +await writeFile(outfile, banner + bundle.text, 'utf8'); +console.log(`Java editor bundle ready: dist/assets/editor.bundle.js (${notices.length} license notices)`); diff --git a/scripts/build-worker.mjs b/scripts/build-worker.mjs index f68e510..51a8d68 100644 --- a/scripts/build-worker.mjs +++ b/scripts/build-worker.mjs @@ -4,8 +4,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('../', import.meta.url)); +await import('./build-editor.mjs'); const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; -const files = ['index.html', 'styles.css', 'app.js', 'data/curriculum.js', 'data/exercises.js', 'data/job-evidence.js']; +const files = ['index.html', 'styles.css', 'app.js', 'assets/editor.bundle.js', 'data/curriculum.js', 'data/exercises.js', 'data/job-evidence.js']; const assets = {}; for (const file of files) assets[`/${file}`] = { body: await readFile(path.join(root, 'dist', file), 'utf8'), type: types[path.extname(file)] }; await mkdir(path.join(root, 'worker'), { recursive: true }); diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs index d6a986d..0d5609b 100644 --- a/scripts/check-worker.mjs +++ b/scripts/check-worker.mjs @@ -10,6 +10,12 @@ assert.ok((await html.text()).includes('javaedu')); const head = await worker.fetch(new Request(origin, { method: 'HEAD' }), {}); assert.equal(head.status, 200); assert.equal(await head.text(), ''); +const editor = await worker.fetch(new Request(origin + '/assets/editor.bundle.js'), {}); +assert.equal(editor.status, 200); +assert.match(editor.headers.get('content-type'), /javascript/); +const editorSource = await editor.text(); +assert.match(editorSource, /Copyright/); +assert.ok(editorSource.length > 100000, 'self-hosted editor bundle must be included'); for (const file of ['/server/exercises.mjs', '/examples/solutions.json', '/.openai/hosting.json', '/missing']) { assert.equal((await worker.fetch(new Request(origin + file), {})).status, 404); } diff --git a/tests/browser.mjs b/tests/browser.mjs index 53a84b8..5dc35d0 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -10,6 +10,7 @@ const projectRoot = fileURLToPath(new URL('../', import.meta.url)); const baseUrl = process.env.QA_BASE_URL || 'http://127.0.0.1:4173'; const artifactRoot = process.env.QA_ARTIFACT_DIR || path.resolve(projectRoot, '../../work/java-edu-qa'); const storageKey = 'java-edu:progress:v1'; +const editorPreferencesKey = 'java-edu:editor-preferences:v1'; const filter = process.env.QA_FILTER ? new RegExp(process.env.QA_FILTER) : null; const results = []; @@ -62,7 +63,7 @@ async function activeTabVisible(page) { assert.ok(geometry.tabLeft >= geometry.containerLeft - 1 && geometry.tabRight <= geometry.containerRight + 1 && geometry.tabTop >= geometry.containerTop - 1 && geometry.tabBottom <= geometry.containerBottom + 1, `selected tab is clipped: ${JSON.stringify(geometry)}`); } -async function screenshot(page, name, fullPage = true) { +async function screenshot(page, name, fullPage = true, options = {}) { const position = await page.evaluate(() => ({ x: scrollX, y: scrollY })); if (fullPage) await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })); if (process.env.QA_TRACE_SCREENSHOTS === '1') { @@ -73,7 +74,7 @@ async function screenshot(page, name, fullPage = true) { }))); } try { - await page.screenshot({ path: path.join(artifactRoot, `${name}.png`), fullPage }); + await page.screenshot({ path: path.join(artifactRoot, `${name}.png`), fullPage, ...options }); } finally { if (fullPage) await page.evaluate(({ x, y }) => window.scrollTo({ top: y, left: x, behavior: 'instant' }), position); } @@ -178,6 +179,59 @@ async function visibleLessonIds(page) { return page.locator('#lesson-list [data-lesson-id]').evaluateAll(nodes => nodes.map(node => node.dataset.lessonId)); } +function editorInput(page) { + return page.locator('#code-editor [role="textbox"]'); +} + +function editorScroller(page) { + return page.locator('#code-editor .cm-scroller'); +} + +async function readEditorSource(page) { + // A virtualized editor's DOM text is not the complete document. Read through + // the public full-source copy action, using an isolated clipboard mock. + const activeElement = await page.evaluateHandle(() => document.activeElement); + const denied = await page.evaluate(() => window.qaClipboardDenied); + await page.evaluate(() => { window.qaClipboardDenied = false; window.qaClipboardText = undefined; }); + try { + await page.locator('#copy-code').click(); + return await expectEventually(() => page.evaluate(() => window.qaClipboardText), value => typeof value === 'string', 'copy exposes the complete editor document'); + } finally { + await page.evaluate(value => { window.qaClipboardDenied = value; }, denied); + await activeElement.evaluate(element => element?.isConnected && element.focus({ preventScroll: true })).catch(() => {}); + await activeElement.dispose(); + } +} + +async function assertDraft(page, exerciseId, expected) { + await expectEventually(async () => (await storedProgress(page))?.drafts.find(entry => entry.exerciseId === exerciseId)?.source, expected, 'stored draft retains the exact source'); +} + +async function tokenPalette(page, scope) { + return page.locator(scope).evaluate(element => { + const colors = {}; + for (const category of ['keyword', 'class', 'type', 'variable', 'method', 'string', 'comment', 'number']) { + const token = element.querySelector(`.tok-${category}`); + if (token) colors[category] = getComputedStyle(token).color; + } + return colors; + }); +} + +async function chooseTheme(page, theme) { + await page.locator('#editor-theme-select').selectOption(theme); + await expectEventually(() => page.locator('[data-editor-theme]').first().getAttribute('data-editor-theme'), theme, 'requested theme reaches the rendered code'); +} + +function contrastRatio(foreground, background) { + const luminance = rgb => rgb.match(/[\d.]+/g).slice(0, 3).map(value => Number(value) / 255) + .map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4) + .reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0); + const foregroundLuminance = luminance(foreground); + const backgroundLuminance = luminance(background); + return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) / (Math.min(foregroundLuminance, backgroundLuminance) + 0.05); +} + async function achievement(page, kind) { return page.locator(`#lesson-achievements [data-achievement="${kind}"]`).getAttribute('data-complete'); } @@ -203,6 +257,14 @@ async function waitForOutput(page, expected) { async function run(browser, name, test, options = {}) { if (filter && !filter.test(name)) return; const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ...options }); + await context.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { + writeText: async text => { + if (window.qaClipboardDenied) throw new DOMException('QA denied', 'NotAllowedError'); + window.qaClipboardText = text; + }, + } }); + }); const page = await context.newPage(); page.setDefaultTimeout(5_000); const pageErrors = []; @@ -253,7 +315,7 @@ async function main() { await noViewportOverflow(page); if (step === 'example') await screenshot(page, 'desktop-example'); if (step === 'lab') { - assert.equal(await page.locator('#code-editor').inputValue(), firstExercise.template); + assert.equal(await readEditorSource(page), firstExercise.template); const labText = await page.locator('#lesson-panel').innerText(); assert.match(labText, /Wandbox/); assert.match(labText, /전송/); @@ -275,7 +337,7 @@ async function main() { } const exercise = exercises.find(item => item.lessonId === lesson.id); assert.ok(exercise, `${lesson.id} has a coding task`); - assert.equal(await page.locator('#code-editor').inputValue(), exercise.template); + assert.equal(await readEditorSource(page), exercise.template); } assert.equal(api.requests.length, 0); }); @@ -392,28 +454,28 @@ async function main() { const api = await mockApi(context); await openLesson(page, first, 'lab'); const draft = `${firstExercise.template}\n// QA draft: 한글과 보존`; - await page.locator('#code-editor').fill(draft); + await editorInput(page).fill(draft); await stepTo(page, 'practice'); await stepTo(page, 'lab'); - assert.equal(await page.locator('#code-editor').inputValue(), draft); + assert.equal(await readEditorSource(page), draft); await page.locator(`#lesson-list [data-lesson-id="${other.id}"]`).click(); await stepTo(page, 'lab'); - assert.equal(await page.locator('#code-editor').inputValue(), exercises.find(item => item.lessonId === other.id).template, 'another lesson keeps its own source'); + assert.equal(await readEditorSource(page), exercises.find(item => item.lessonId === other.id).template, 'another lesson keeps its own source'); await page.locator(`#lesson-list [data-lesson-id="${first.id}"]`).click(); await stepTo(page, 'lab'); - assert.equal(await page.locator('#code-editor').inputValue(), draft); + assert.equal(await readEditorSource(page), draft); await page.reload(); - await page.locator('#code-editor').waitFor({ state: 'visible' }); - assert.equal(await page.locator('#code-editor').inputValue(), draft, 'source survives reload'); + await editorInput(page).waitFor({ state: 'visible' }); + assert.equal(await readEditorSource(page), draft, 'source survives reload'); await page.locator('#lab-reset').click(); - assert.equal(await page.locator('#code-editor').inputValue(), firstExercise.template); + assert.equal(await readEditorSource(page), firstExercise.template); await page.locator('#lab-undo').click(); - assert.equal(await page.locator('#code-editor').inputValue(), draft, 'undo restores the exact source'); + assert.equal(await readEditorSource(page), draft, 'undo restores the exact source'); const immediateDraft = `${draft}\n// immediate reload`; - await page.locator('#code-editor').fill(immediateDraft); + await editorInput(page).fill(immediateDraft); await page.reload(); - await page.locator('#code-editor').waitFor({ state: 'visible' }); - assert.equal(await page.locator('#code-editor').inputValue(), immediateDraft, 'immediate reload flushes the latest edit'); + await editorInput(page).waitFor({ state: 'visible' }); + assert.equal(await readEditorSource(page), immediateDraft, 'immediate reload flushes the latest edit'); assert.equal(api.requests.length, 0, 'editing or reset never sends source'); }); @@ -423,7 +485,7 @@ async function main() { await completeQuiz(page, first); await stepTo(page, 'lab'); const submitted = `${firstExercise.template}\n// QA source snapshot`; - await page.locator('#code-editor').fill(submitted); + await editorInput(page).fill(submitted); await page.locator('#run-code').click(); await waitForOutput(page, 'passed'); assert.equal(api.requests.at(-1).action, 'run'); @@ -447,11 +509,11 @@ async function main() { await page.locator('#execution-output').scrollIntoViewIfNeeded(); await screenshot(page, 'desktop-submit-passed-result', false); await page.reload(); - await page.locator('#code-editor').waitFor({ state: 'visible' }); + await editorInput(page).waitFor({ state: 'visible' }); assert.equal(await achievement(page, 'quiz'), 'true'); assert.equal(await achievement(page, 'lab'), 'true'); assert.equal(await page.locator('#overall-completed').textContent(), '1'); - await page.locator('#code-editor').fill(`${submitted}\n// edited after passing`); + await editorInput(page).fill(`${submitted}\n// edited after passing`); await expectEventually(() => achievement(page, 'lab'), 'false', 'a changed draft is not the previously passed source'); assert.equal(await achievement(page, 'quiz'), 'true'); assert.equal(await page.locator('#overall-completed').textContent(), '0'); @@ -483,7 +545,7 @@ async function main() { await openLesson(page, first); await completeQuiz(page, first); await stepTo(page, 'lab'); - const source = await page.locator('#code-editor').inputValue(); + const source = await readEditorSource(page); for (const status of ['failed', 'compile_error', 'runtime_error', 'timeout']) { api.respond = request => ({ body: executionResult(request) }); await page.locator('#submit-code').click(); @@ -493,13 +555,13 @@ async function main() { api.respond = request => ({ body: executionResult(request, { status, checks: [{ name: '재검증 실패', passed: false }], passed: 0, total: 1 }) }); await page.locator('#submit-code').click(); await waitForOutput(page, status); - assert.equal(await page.locator('#code-editor').inputValue(), source, 'same source is re-evaluated'); + assert.equal(await readEditorSource(page), source, 'same source is re-evaluated'); assert.equal(await achievement(page, 'lab'), 'false', `${status} revokes current code achievement`); assert.equal(await achievement(page, 'quiz'), 'true'); assert.equal(await page.locator('#overall-completed').textContent(), '0'); } await page.reload(); - await page.locator('#code-editor').waitFor({ state: 'visible' }); + await editorInput(page).waitFor({ state: 'visible' }); assert.equal(await achievement(page, 'lab'), 'false', 'revocation survives reload'); }); @@ -528,12 +590,12 @@ async function main() { await run(browser, 'empty-source-and-utf8-payload-limit', async ({ context, page }) => { const api = await mockApi(context); await openLesson(page, first, 'lab'); - await page.locator('#code-editor').fill(''); + await editorInput(page).fill(''); await page.locator('#run-code').click(); assert.match(await page.locator('#toast').textContent(), /코드를 입력/); - assert.equal(await page.locator('#code-editor').evaluate(element => document.activeElement === element), true, 'empty source returns focus to editor'); + assert.equal(await editorInput(page).evaluate(element => document.activeElement === element), true, 'empty source returns focus to editor'); assert.equal(api.requests.length, 0, 'empty source is rejected before network submission'); - await page.locator('#code-editor').fill(`class Solution { /* ${'한'.repeat(12_000)} */ }`); + await editorInput(page).fill(`class Solution { /* ${'한'.repeat(12_000)} */ }`); await page.locator('#run-code').click(); await waitForOutput(page, 'validation_error'); assert.equal(api.requests.length, 0, 'oversized UTF-8 source is rejected before network submission'); @@ -569,14 +631,14 @@ async function main() { assert.equal(await page.locator('#run-code').isDisabled(), true); assert.equal(await page.locator('#submit-code').isDisabled(), true); const draft = `${firstExercise.template}\n// offline draft`; - await page.locator('#code-editor').fill(draft); + await editorInput(page).fill(draft); await stepTo(page, 'theory'); await stepTo(page, 'lab'); - assert.equal(await page.locator('#code-editor').inputValue(), draft); + assert.equal(await readEditorSource(page), draft); api.runtime = {}; await page.locator('#runtime-retry').click(); await expectEventually(() => page.locator('#run-code').isEnabled(), true, 'runtime retry restores execution capability'); - assert.equal(await page.locator('#code-editor').inputValue(), draft); + assert.equal(await readEditorSource(page), draft); assert.equal(api.requests.length, 0, 'availability checks do not send the source'); }); @@ -599,9 +661,8 @@ async function main() { assert.equal(await page.evaluate(() => window.getSelection().toString()), first.example.code, 'copy denial selects example text for manual copying'); await stepTo(page, 'lab'); await page.locator('#copy-code').click(); - const selection = await page.locator('#code-editor').evaluate(element => ({ start: element.selectionStart, end: element.selectionEnd, value: element.value, focused: document.activeElement === element })); - assert.equal(selection.start, 0); - assert.equal(selection.end, selection.value.length); + const selection = await editorInput(page).evaluate(element => ({ text: window.getSelection().toString(), focused: document.activeElement === element })); + assert.equal(selection.text, firstExercise.template, 'copy denial selects the current document for manual copying'); assert.equal(selection.focused, true); assert.match(await page.locator('#toast').textContent(), /직접 복사/); }); @@ -634,7 +695,7 @@ async function main() { assert.equal(await page.locator('#run-code').isDisabled(), true, 'duplicate execution is blocked'); assert.equal(await page.locator('#submit-code').isDisabled(), true, 'duplicate submission is blocked'); const edited = `${firstExercise.template}\n// edited during execution`; - if (interaction === 'edit') await page.locator('#code-editor').fill(edited); + if (interaction === 'edit') await editorInput(page).fill(edited); if (interaction === 'lesson') { await page.locator(`#lesson-list [data-lesson-id="${other.id}"]`).click(); await stepTo(page, 'lab'); @@ -647,7 +708,7 @@ async function main() { assert.equal(await achievement(page, 'lab'), 'false', 'obsolete or cancelled response grants no achievement'); assert.notEqual(await page.locator('#execution-output').getAttribute('data-status'), 'passed'); if (interaction === 'edit') { - assert.equal(await page.locator('#code-editor').inputValue(), edited); + assert.equal(await readEditorSource(page), edited); assert.equal(await page.locator('#execution-output').getAttribute('data-status'), 'outdated'); } if (interaction === 'lesson') { @@ -665,7 +726,7 @@ async function main() { await openLesson(page, first, 'lab'); assert.equal(await achievement(page, 'quiz'), 'false'); assert.equal(await achievement(page, 'lab'), 'false'); - assert.equal(await page.locator('#code-editor').inputValue(), firstExercise.template); + assert.equal(await readEditorSource(page), firstExercise.template); await stepTo(page, 'theory'); await noViewportOverflow(page); }); @@ -678,10 +739,10 @@ async function main() { }); await openLesson(page, first, 'lab'); const source = `${firstExercise.template}\n// in-memory QA draft`; - await page.locator('#code-editor').fill(source); + await editorInput(page).fill(source); await stepTo(page, 'theory'); await stepTo(page, 'lab'); - assert.equal(await page.locator('#code-editor').inputValue(), source); + assert.equal(await readEditorSource(page), source); const storageLabel = await page.locator('#storage-label').textContent(); assert.ok(storageLabel.trim().length > 0 && storageLabel !== '이 브라우저에 저장', 'storage limitation is disclosed'); await completeQuiz(page, first); @@ -692,7 +753,7 @@ async function main() { await mockApi(context); await openLesson(page, first, 'lab'); const draft = `${firstExercise.template}\n// record-reset QA`; - await page.locator('#code-editor').fill(draft); + await editorInput(page).fill(draft); await completeQuiz(page, first); await stepTo(page, 'lab'); await page.locator('#version-select').selectOption('11'); @@ -701,7 +762,7 @@ async function main() { await page.locator('#reset-dialog').waitFor({ state: 'visible' }); await page.locator('#reset-dialog [data-close-dialog="reset-dialog"]').click(); assert.deepEqual(await storedProgress(page), before, 'cancel leaves all records intact'); - assert.equal(await page.locator('#code-editor').inputValue(), draft); + assert.equal(await readEditorSource(page), draft); await page.locator('#reset-progress-open').click(); await page.locator('#confirm-reset-progress').click(); assert.equal(await achievement(page, 'quiz'), 'false'); @@ -709,7 +770,7 @@ async function main() { assert.equal(await page.locator('#overall-completed').textContent(), '0'); assert.equal(await page.locator('#version-select').inputValue(), '11', 'record reset retains selected version'); await stepTo(page, 'lab'); - assert.equal(await page.locator('#code-editor').inputValue(), firstExercise.template); + assert.equal(await readEditorSource(page), firstExercise.template); await page.reload(); await page.locator('#workspace-grid').waitFor({ state: 'visible' }); assert.equal(await achievement(page, 'quiz'), 'false'); @@ -736,12 +797,12 @@ async function main() { await page.keyboard.press('ArrowRight'); assert.equal(await group.nth(1).isChecked(), true, 'native radio keyboard selection works'); await stepTo(page, 'lab'); - await page.locator('#code-editor').focus(); + await editorInput(page).focus(); await page.keyboard.press('ControlOrMeta+End'); await page.keyboard.insertText('\n// 키보드 입력 QA'); - assert.ok((await page.locator('#code-editor').inputValue()).endsWith('// 키보드 입력 QA')); + assert.ok((await readEditorSource(page)).endsWith('// 키보드 입력 QA')); await page.keyboard.press('Tab'); - assert.notEqual(await page.evaluate(() => document.activeElement?.id), 'code-editor', 'plain textarea allows Tab to leave the editor'); + assert.equal(await editorInput(page).evaluate(element => element.contains(document.activeElement)), false, 'Tab leaves the code editor instead of trapping keyboard users'); await page.locator('#guide-open').click(); await page.locator('#guide-dialog').waitFor({ state: 'visible' }); assert.equal(await page.locator('#guide-dialog').evaluate(element => element.contains(document.activeElement)), true, 'dialog receives focus'); @@ -793,9 +854,9 @@ async function main() { await page.locator('#run-code').scrollIntoViewIfNeeded(); await screenshot(page, `viewport-${viewport.width}x${viewport.height}-lab-controls`, false); } - await page.locator('#code-editor').fill(`${firstExercise.template}\n// ${'long_identifier_'.repeat(80)}`); + await editorInput(page).fill(`${firstExercise.template}\n// ${'long_identifier_'.repeat(80)}`); await noViewportOverflow(page); - const internalScroll = await page.locator('#code-editor').evaluate(element => { + const internalScroll = await editorScroller(page).evaluate(element => { element.scrollLeft = 150; return { width: element.clientWidth, scrollWidth: element.scrollWidth, left: element.scrollLeft }; }); @@ -807,10 +868,10 @@ async function main() { await mockApi(context); await openLesson(page, first, 'lab'); const draft = `${firstExercise.template}\n// resize persists input`; - await page.locator('#code-editor').fill(draft); + await editorInput(page).fill(draft); for (const viewport of [{ width: 390, height: 844 }, { width: 844, height: 390 }, { width: 390, height: 844 }, { width: 1440, height: 1000 }]) { await page.setViewportSize(viewport); - assert.equal(await page.locator('#code-editor').inputValue(), draft); + assert.equal(await readEditorSource(page), draft); await noViewportOverflow(page); await activeTabVisible(page); } @@ -840,7 +901,7 @@ async function main() { await page.locator('#mobile-menu-toggle').click(); await screenshot(page, 'mobile-text-200-drawer', false); await page.keyboard.press('Escape'); - assert.equal(await page.locator('#code-editor').inputValue(), draft); + assert.equal(await readEditorSource(page), draft); }); await run(browser, 'initial-text-enlargement-keeps-mobile-tabs-visible', async ({ context, page }) => { @@ -859,6 +920,375 @@ async function main() { await page.locator('.lesson-tabs').scrollIntoViewIfNeeded(); await screenshot(page, 'mobile-text-200-tabs-selected-before-enlargement', false); }, { viewport: { width: 390, height: 844 } }); + await run(browser, 'editor-three-themes-and-syntax-colors', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'example'); + const palettes = {}; + for (const theme of ['dark', 'light', 'contrast']) { + await chooseTheme(page, theme); + assert.equal(await page.locator('.code-block code').textContent(), first.example.code, 'syntax highlighting preserves the exact example'); + const colors = await tokenPalette(page, '.code-block'); + for (const category of ['keyword', 'class', 'type', 'variable', 'method', 'string', 'number']) assert.ok(colors[category], `${theme} example has ${category} tokens`); + assert.equal(new Set(['keyword', 'class', 'type', 'variable'].map(category => colors[category])).size, 4, 'keyword, class, type, and variable colors are distinct'); + palettes[theme] = colors; + await page.locator('.code-shell').scrollIntoViewIfNeeded(); + await screenshot(page, `editor-${theme}-example`, false); + await noViewportOverflow(page); + } + const source = 'class Palette {\n String label = "한글";\n int count = 7;\n // \n void run() { count++; }\n}\n'; + await stepTo(page, 'lab'); + assert.equal(await page.locator('#code-editor').getAttribute('data-editor-engine'), 'codemirror'); + assert.equal(await page.locator('#code-editor textarea').count(), 0, 'normal editor has no hidden textarea proxy'); + assert.equal(await editorInput(page).getAttribute('contenteditable'), 'true'); + assert.equal(await editorInput(page).getAttribute('aria-multiline'), 'true'); + await editorInput(page).fill(source); + for (const theme of ['dark', 'light', 'contrast']) { + await chooseTheme(page, theme); + const colors = await tokenPalette(page, '#code-editor'); + for (const category of ['keyword', 'class', 'type', 'variable', 'method', 'string', 'comment', 'number']) assert.ok(colors[category], `${theme} editor has ${category} tokens`); + for (const category of ['keyword', 'class', 'type', 'variable']) assert.equal(colors[category], palettes[theme][category], 'example and editor use the same category palette'); + await page.locator('.editor-shell').scrollIntoViewIfNeeded(); + await screenshot(page, `editor-${theme}-ide`, false); + } + assert.notEqual(palettes.dark.class, palettes.light.class, 'theme switching changes the computed colors'); + assert.notEqual(palettes.dark.class, palettes.contrast.class); + assert.equal(await page.locator('#code-editor img').count(), 0, 'code-looking HTML is rendered as text'); + assert.equal(await page.evaluate(() => window.qaCodeExecuted), undefined); + assert.equal(await readEditorSource(page), source); + assert.equal(api.requests.length, 0, 'color changes do not submit source'); + }); + + await run(browser, 'editor-theme-wrap-persist-across-navigation-and-reload', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + const source = `${firstExercise.template}\n// 테마와 이동 후에도 보존\n\n`; + await editorInput(page).fill(source); + await chooseTheme(page, 'light'); + await page.locator('#code-wrap-toggle').click(); + assert.equal(await page.locator('#code-wrap-toggle').getAttribute('aria-pressed'), 'true'); + assert.deepEqual(await page.evaluate(key => JSON.parse(localStorage.getItem(key)), editorPreferencesKey), { theme: 'light', wrap: true }); + await page.locator('#lesson-search').fill('no-result-theme-regression'); + await page.locator('#lesson-search').fill(''); + await page.locator('[data-track="transition"]').click(); + await page.locator('[data-track="all"]').click(); + await page.locator('#version-select').selectOption('11'); + await stepTo(page, 'lab'); + assert.equal(await readEditorSource(page), source); + await stepTo(page, 'example'); + assert.equal(await page.locator('.code-shell').getAttribute('data-editor-theme'), 'light'); + await page.locator(`#lesson-list [data-lesson-id="${other.id}"]`).click(); + await stepTo(page, 'lab'); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'light'); + assert.equal(await page.locator('#code-wrap-toggle').getAttribute('aria-pressed'), 'true'); + await page.locator(`#lesson-list [data-lesson-id="${first.id}"]`).click(); + await stepTo(page, 'lab'); + assert.equal(await readEditorSource(page), source); + await page.reload(); + await editorInput(page).waitFor({ state: 'visible' }); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'light'); + assert.equal(await page.locator('#code-wrap-toggle').getAttribute('aria-pressed'), 'true'); + assert.equal(await readEditorSource(page), source); + assert.equal(api.requests.length, 0); + }); + + await run(browser, 'editor-theme-preserves-selection-and-keyboard-undo', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + const source = 'class Palette { int count = 7; }'; + await editorInput(page).fill(source); + const editorNode = await editorInput(page).elementHandle(); + await editorInput(page).focus(); + await page.keyboard.press('ControlOrMeta+Home'); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowRight'); + assert.equal(await page.evaluate(() => getSelection().toString()), 'class'); + await chooseTheme(page, 'light'); + await chooseTheme(page, 'contrast'); + assert.equal(await editorNode.evaluate(element => element.isConnected), true, 'theme reconfiguration keeps the input DOM instance'); + await editorInput(page).focus(); + assert.equal(await page.evaluate(() => getSelection().toString()), 'class', 'theme changes preserve the selected source range'); + await page.keyboard.insertText('interface'); + const replaced = source.replace('class', 'interface'); + assert.equal(await readEditorSource(page), replaced); + await chooseTheme(page, 'dark'); + await editorInput(page).focus(); + await page.keyboard.press('ControlOrMeta+z'); + assert.equal(await readEditorSource(page), source, 'undo survives theme changes'); + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+Shift+z' : 'Control+y'); + assert.equal(await readEditorSource(page), replaced, 'redo restores the typed replacement'); + await editorNode.dispose(); + }); + + await run(browser, 'editor-enter-indent-tab-and-reset-undo', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + await editorInput(page).fill('class Demo {'); + await page.keyboard.press('ControlOrMeta+End'); + await page.keyboard.press('Enter'); + await page.keyboard.insertText('int number = 1;'); + const indented = 'class Demo {\n int number = 1;'; + assert.equal(await readEditorSource(page), indented, 'Enter applies a four-space Java block indentation'); + await page.keyboard.press('Tab'); + assert.equal(await editorInput(page).evaluate(element => element.contains(document.activeElement)), false, 'Tab is navigation'); + await editorInput(page).focus(); + await page.keyboard.press('ControlOrMeta+End'); + await page.locator('#indent-code').click(); + const moreIndented = 'class Demo {\n int number = 1;'; + assert.equal(await readEditorSource(page), moreIndented, 'indent toolbar adds four spaces to the selected line'); + await page.keyboard.press('ControlOrMeta+z'); + assert.equal(await readEditorSource(page), indented, 'indent action is undoable'); + await chooseTheme(page, 'light'); + await page.locator('#lab-reset').click(); + assert.equal(await readEditorSource(page), firstExercise.template); + await page.locator('#lab-undo').click(); + assert.equal(await readEditorSource(page), indented, 'public reset undo restores the entire previous source'); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'light'); + }); + + await run(browser, 'editor-mouse-selection-and-simulated-copy-event', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + const source = 'class MouseSelection { int count = 7; }'; + await editorInput(page).fill(source); + const token = page.locator('#code-editor .tok-class').filter({ hasText: 'MouseSelection' }); + await token.scrollIntoViewIfNeeded(); + const bounds = await token.boundingBox(); + assert.ok(bounds && bounds.width > 0); + await page.mouse.move(bounds.x + 0.2, bounds.y + bounds.height / 2); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width + 0.2, bounds.y + bounds.height / 2, { steps: 14 }); + await page.mouse.up(); + assert.equal(await page.evaluate(() => getSelection().toString()), 'MouseSelection', 'pointer drag selects source text'); + const copied = await editorInput(page).evaluate(element => { + const clipboardData = new DataTransfer(); + const event = new ClipboardEvent('copy', { clipboardData, bubbles: true, cancelable: true }); + element.dispatchEvent(event); + return { text: clipboardData.getData('text/plain'), prevented: event.defaultPrevented }; + }); + assert.equal(copied.text, 'MouseSelection', 'editor copy handler writes only the selection to the simulated clipboard'); + assert.equal(copied.prevented, true); + assert.equal(await readEditorSource(page), source, 'toolbar copy remains a whole-document action'); + }); + + await run(browser, 'editor-simulated-korean-composition-retains-source', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + const before = 'class 한글 { String value = ""; }\n// '; + await editorInput(page).fill(before); + await page.keyboard.press('ControlOrMeta+End'); + await editorInput(page).dispatchEvent('compositionstart', { data: '' }); + await editorInput(page).dispatchEvent('compositionupdate', { data: '한글 입력' }); + await page.keyboard.insertText('한글 입력'); + await editorInput(page).dispatchEvent('compositionend', { data: '한글 입력' }); + const expected = `${before}한글 입력`; + assert.equal(await readEditorSource(page), expected, 'synthetic composition sequence does not duplicate committed Korean text'); + await editorInput(page).dispatchEvent('compositionstart', { data: '' }); + await editorInput(page).dispatchEvent('compositionend', { data: '' }); + await page.locator('#run-code').click(); + await expectEventually(() => api.requests.length, 1, 'composition result reaches the mock API'); + assert.equal(api.requests[0].source, expected); + await waitForOutput(page, 'passed'); + await assertDraft(page, firstExercise.id, expected); + // This is a synthetic browser event test, not validation of an OS IME, + // candidate window, physical keyboard, or mobile composition behavior. + }); + + await run(browser, 'editor-empty-trailing-newline-and-request-roundtrip', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + await editorInput(page).fill(''); + assert.equal(await readEditorSource(page), ''); + await page.locator('#run-code').click(); + assert.equal(api.requests.length, 0, 'empty input is not submitted'); + assert.equal(await editorInput(page).evaluate(element => document.activeElement === element), true); + const source = 'class Solution {\n // 마지막 빈 줄도 원문의 일부\n}\n\n\n'; + await editorInput(page).fill(source); + assert.equal(await readEditorSource(page), source); + await page.locator('#run-code').click(); + await expectEventually(() => api.requests.length, 1, 'source is submitted once'); + assert.equal(api.requests[0].source, source); + await waitForOutput(page, 'passed'); + await assertDraft(page, firstExercise.id, source); + await page.reload(); + await editorInput(page).waitFor({ state: 'visible' }); + assert.equal(await readEditorSource(page), source, 'reload retains all trailing newlines'); + }); + + await run(browser, 'editor-long-korean-document-wrap-scroll-and-resize', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + const source = `class Solution {\n${Array.from({ length: 100 }, (_, index) => ` // ${index}: ${'긴한글식별자와문자열 '.repeat(8)}`).join('\n')}\n}\n\n`; + await editorInput(page).fill(source); + assert.equal(await readEditorSource(page), source, 'virtualized long document is copied in full'); + await page.locator('#run-code').click(); + await expectEventually(() => api.requests.length, 1, 'long Unicode source reaches the mock API'); + assert.equal(api.requests[0].source, source); + await waitForOutput(page, 'passed'); + await assertDraft(page, firstExercise.id, source); + for (const viewport of [{ width: 320, height: 720 }, { width: 844, height: 390 }, { width: 390, height: 844 }]) { + await page.setViewportSize(viewport); + await noViewportOverflow(page); + const scroll = await editorScroller(page).evaluate(element => { + element.scrollTop = element.scrollHeight; + element.scrollLeft = 180; + return { left: element.scrollLeft, top: element.scrollTop, width: element.clientWidth, scrollWidth: element.scrollWidth }; + }); + assert.ok(scroll.top > 0 && scroll.left > 0 && scroll.scrollWidth > scroll.width, 'long document scrolls inside CodeMirror'); + assert.equal(await readEditorSource(page), source); + } + await chooseTheme(page, 'light'); + await page.locator('#code-wrap-toggle').click(); + await expectEventually(() => editorScroller(page).evaluate(element => element.scrollWidth <= element.clientWidth + 1), true, 'wrap contains long lines inside the editor'); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + await noViewportOverflow(page); + await activeTabVisible(page); + await expectEventually(() => page.locator('#toast').evaluate(element => element.classList.contains('is-visible')), false, 'copy confirmation has dismissed before layout evidence'); + await page.locator('#code-editor').scrollIntoViewIfNeeded(); + await screenshot(page, 'editor-mobile-200-long-korean', false); + await page.setViewportSize({ width: 320, height: 720 }); + await noViewportOverflow(page); + await page.locator('.code-settings').scrollIntoViewIfNeeded(); + await screenshot(page, 'editor-320-200-controls', false); + await page.locator('#code-wrap-toggle').click(); + assert.equal(await readEditorSource(page), source); + await page.reload(); + await editorInput(page).waitFor({ state: 'visible' }); + assert.equal(await readEditorSource(page), source); + }); + + await run(browser, 'editor-document-character-limit-is-atomic', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + const accepted = `//${'가'.repeat(49_998)}`; + await editorInput(page).fill(accepted); + assert.equal(await readEditorSource(page), accepted, 'exactly 50,000 characters are accepted'); + await page.keyboard.press('ControlOrMeta+End'); + await page.keyboard.insertText('나'); + assert.match(await page.locator('#toast').textContent(), /50,000/); + assert.equal(await readEditorSource(page), accepted, 'an over-limit insertion leaves the prior document intact'); + await assertDraft(page, firstExercise.id, accepted); + await page.locator('#run-code').click(); + await waitForOutput(page, 'validation_error'); + assert.equal(api.requests.length, 0, 'the independent UTF-8 request limit is enforced before transmission'); + await page.locator('#lab-reset').click(); + await page.locator('#lab-undo').click(); + assert.equal(await readEditorSource(page), accepted, 'reset undo preserves a boundary-size document'); + }); + + await run(browser, 'editor-theme-during-request-preserves-execution-snapshot', async ({ context, page }) => { + const gate = deferred(); + context.once('close', () => gate.resolve()); + const api = await mockApi(context, { respond: async request => { await gate.promise; return { body: executionResult(request) }; } }); + await openLesson(page, first, 'lab'); + const source = `${firstExercise.template}\n// immutable request snapshot`; + await editorInput(page).fill(source); + await page.locator('#submit-code').click(); + await expectEventually(() => api.requests.length, 1, 'pending snapshot is captured'); + await chooseTheme(page, 'contrast'); + await page.locator('#code-wrap-toggle').click(); + assert.equal(api.requests[0].source, source); + gate.resolve(); + await waitForOutput(page, 'passed'); + assert.equal(await achievement(page, 'lab'), 'true', 'presentation-only changes do not invalidate matching source'); + assert.equal(await readEditorSource(page), source); + assert.equal(api.requests.length, 1, 'theme change sends no additional execution request'); + }); + + await run(browser, 'editor-invalid-and-denied-preferences-keep-learning-usable', async ({ context, page }) => { + const api = await mockApi(context); + await context.addInitScript(key => localStorage.setItem(key, '{ invalid-json'), editorPreferencesKey); + await openLesson(page, first, 'lab'); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'dark'); + await page.evaluate(() => { Storage.prototype.setItem = function () { throw new DOMException('QA blocked preferences', 'SecurityError'); }; }); + await chooseTheme(page, 'light'); + assert.match(await page.locator('#storage-label').textContent(), /이번 방문/); + const source = `${firstExercise.template}\n// memory-only preferences`; + await editorInput(page).fill(source); + await stepTo(page, 'example'); + assert.equal(await page.locator('.code-shell').getAttribute('data-editor-theme'), 'light'); + await stepTo(page, 'lab'); + assert.equal(await readEditorSource(page), source); + await page.locator('#run-code').click(); + await waitForOutput(page, 'passed'); + assert.equal(api.requests[0].source, source); + }); + + await run(browser, 'editor-bundle-failure-keeps-plain-source-editing-and-execution', async ({ context, page }) => { + const api = await mockApi(context); + await context.route('**/assets/editor.bundle.js', route => route.abort('failed')); + await openLesson(page, first, 'example'); + assert.equal(await page.locator('.code-block code').textContent(), first.example.code); + assert.match(await page.locator('.syntax-note').textContent(), /원문/); + await stepTo(page, 'lab'); + assert.equal(await page.locator('#code-editor').getAttribute('data-editor-engine'), 'textarea-fallback'); + const fallback = page.locator('#code-editor [data-testid="code-editor-input"]'); + assert.equal(await fallback.evaluate(element => element.tagName), 'TEXTAREA'); + assert.match(await page.locator('#editor-hint').textContent(), /기본 텍스트 편집기/); + const source = `${firstExercise.template}\n// fallback source\n\n`; + await fallback.fill(source); + await chooseTheme(page, 'light'); + await page.locator('#code-wrap-toggle').click(); + assert.equal(await fallback.inputValue(), source); + assert.equal(await fallback.getAttribute('wrap'), 'soft'); + await page.locator('#run-code').click(); + await waitForOutput(page, 'passed'); + assert.equal(api.requests[0].source, source); + await page.locator('#lab-reset').click(); + await page.locator('#lab-undo').click(); + assert.equal(await fallback.inputValue(), source); + await page.setViewportSize({ width: 320, height: 720 }); + await noViewportOverflow(page); + await fallback.scrollIntoViewIfNeeded(); + await screenshot(page, 'editor-fallback-320', false); + }); + + await run(browser, 'editor-selection-and-caret-visual-states', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + const source = 'class Palette {\n String label = "한글 문자열";\n int count = 7;\n // 선택 영역과 커서 가독성\n void run() { count++; }\n}'; + await editorInput(page).fill(source); + const selectionContrasts = []; + for (const theme of ['dark', 'light', 'contrast']) { + await chooseTheme(page, theme); + await editorInput(page).focus(); + await page.keyboard.press('ControlOrMeta+a'); + assert.equal(await page.evaluate(() => getSelection().toString()), source); + await page.locator('.editor-shell').scrollIntoViewIfNeeded(); + await screenshot(page, `editor-${theme}-selection`, false, { caret: 'initial' }); + const background = await page.locator('#code-editor .cm-selectionBackground').first().evaluate(element => getComputedStyle(element).backgroundColor); + const palette = await tokenPalette(page, '#code-editor'); + for (const [category, foreground] of Object.entries(palette)) selectionContrasts.push({ theme, category, ratio: contrastRatio(foreground, background) }); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowRight'); + assert.equal(await page.evaluate(() => getSelection().isCollapsed), true); + await expectEventually(() => page.locator('#code-editor .cm-cursor').first().evaluate(element => getComputedStyle(element).visibility), 'visible', 'focused editor caret is visible'); + await screenshot(page, `editor-${theme}-caret`, false, { caret: 'initial' }); + } + console.log('SELECTION_CONTRAST', JSON.stringify(selectionContrasts)); + for (const { theme, category, ratio } of selectionContrasts) assert.ok(ratio >= 4.5, `${theme}/${category} selected text contrast ${ratio.toFixed(2)}:1 is below 4.5:1`); + await noViewportOverflow(page); + }); + + await run(browser, 'editor-mobile-theme-palette-and-controls', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + for (const theme of ['dark', 'light', 'contrast']) { + await chooseTheme(page, theme); + await noViewportOverflow(page); + await page.locator('.code-settings').scrollIntoViewIfNeeded(); + await screenshot(page, `editor-mobile-${theme}`, false); + } + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + await noViewportOverflow(page); + await activeTabVisible(page); + await page.locator('.code-settings').scrollIntoViewIfNeeded(); + await screenshot(page, 'editor-mobile-200-theme', false); + await page.locator('#run-code').scrollIntoViewIfNeeded(); + await screenshot(page, 'editor-mobile-200-execution-controls', false); + }, { viewport: { width: 390, height: 844 } }); } finally { await browser.close(); } diff --git a/tests/editor.test.mjs b/tests/editor.test.mjs new file mode 100644 index 0000000..c06b379 --- /dev/null +++ b/tests/editor.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { javaHighlightSpans } from '../dist/editor.js'; + +function tokens(source) { + const spans = javaHighlightSpans(source); + let end = 0; + for (const span of spans) { + assert.ok(Number.isInteger(span.from) && Number.isInteger(span.to)); + assert.ok(span.from >= end && span.to > span.from && span.to <= source.length, 'highlight spans stay ordered and within the source'); + assert.match(span.classes, /^tok-[a-z]+(?: tok-[a-z]+)*$/); + end = span.to; + } + return spans.map(span => ({ text: source.slice(span.from, span.to), classes: span.classes })); +} + +function contains(entries, text, category) { + assert.ok(entries.some(entry => entry.text === text && entry.classes.split(' ').includes(`tok-${category}`)), `${JSON.stringify(text)} is highlighted as ${category}`); +} + +test('Java syntax distinguishes class declarations, types, variables, and method declarations and calls', () => { + const entries = tokens('class Palette { String label = "한글"; int count = 7; void run() { count++; run(); } }'); + for (const [text, category] of [ + ['class', 'keyword'], ['Palette', 'class'], ['String', 'type'], ['int', 'type'], + ['label', 'variable'], ['count', 'variable'], ['run', 'method'], ['"한글"', 'string'], ['7', 'number'], + ]) contains(entries, text, category); + assert.equal(entries.filter(entry => entry.text === 'run' && entry.classes === 'tok-method').length, 2, 'declaration and invocation share the method category'); +}); + +test('Comments and string literals keep Java-looking and HTML-looking text in their own category', () => { + const comment = '// class Fake { int unsafe = 1; }'; + const literal = '" class Fake"'; + const entries = tokens(`class Safe { ${comment}\nString value = ${literal}; }`); + contains(entries, comment, 'comment'); + contains(entries, literal, 'string'); + assert.equal(entries.some(entry => entry.text === 'Fake' && entry.classes === 'tok-class'), false); +}); + +test('Supported lesson syntax keeps class and type categories for generic, record, sealed, and text-block examples', () => { + const entries = tokens('sealed interface Shape permits Box {}\nrecord Box(T value) implements Shape {}\nclass Demo { String text = """\n한글\n"""; }'); + for (const [text, category] of [['Shape', 'class'], ['Box', 'class'], ['Demo', 'class'], ['sealed', 'keyword'], ['record', 'keyword'], ['String', 'type']]) contains(entries, text, category); + assert.ok(entries.some(entry => entry.classes === 'tok-string' && entry.text.includes('한글'))); +}); + +test('Empty, incomplete, Unicode, and trailing-newline source yields bounded syntax spans without mutation', () => { + for (const source of ['', '\n\n', 'class', 'class 한글 { String 문자 = "😀', 'class A {\r\n// 끝\r\n}\r\n', ' '.repeat(1000)]) { + const original = source; + tokens(source); + assert.equal(source, original); + } +}); From ae2756995139f907006b773fc9b5db21ddac7fbc Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:25:02 +0900 Subject: [PATCH 07/29] =?UTF-8?q?feat(desktop):=20Windows=20=ED=95=99?= =?UTF-8?q?=EC=8A=B5=20=EC=95=B1=EA=B3=BC=20=EC=8B=A4=EB=AC=B4=2022?= =?UTF-8?q?=EB=8B=A8=EC=9B=90=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 JDK·Maven 실행, 다중 파일 편집, 진도 저장과 실무 기준 검증을 연결한다. 설치·시각 최종 게이트 및 v0.2 배포는 인계 문서에 미완료로 기록한다. --- .github/workflows/verify.yml | 44 +- .gitignore | 2 + AGENTS.md | 2 +- README.md | 17 +- curriculum/professional/README.md | 14 + curriculum/professional/catalog.json | 1204 +++++++++++++ .../src/test/java/edu/lab/ContractTest.java | 10 + .../lessons/build-gradle-ci/lesson.json | 240 +++ .../reference/BUILD-COMPARISON.md | 4 + .../build-gradle-ci/reference/Dockerfile | 7 + .../build-gradle-ci/reference/build.gradle | 19 + .../reference/deployment-examples/ci.yml | 17 + .../lessons/build-gradle-ci/reference/pom.xml | 2 + .../build-gradle-ci/reference/settings.gradle | 1 + .../src/main/java/edu/lab/BuildMain.java | 13 + .../starter/BUILD-COMPARISON.md | 4 + .../build-gradle-ci/starter/Dockerfile | 7 + .../build-gradle-ci/starter/build.gradle | 19 + .../starter/deployment-examples/ci.yml | 17 + .../lessons/build-gradle-ci/starter/pom.xml | 2 + .../build-gradle-ci/starter/settings.gradle | 1 + .../src/main/java/edu/lab/BuildMain.java | 10 + .../src/test/java/edu/lab/ContractTest.java | 11 + .../lessons/build-maven/lesson.json | 166 ++ .../lessons/build-maven/reference/pom.xml | 2 + .../src/main/java/edu/lab/OrderAmounts.java | 10 + .../lessons/build-maven/starter/pom.xml | 2 + .../src/main/java/edu/lab/OrderAmounts.java | 7 + .../src/test/java/edu/lab/ContractTest.java | 20 + .../lessons/flyway-migrations/lesson.json | 192 ++ .../flyway-migrations/reference/pom.xml | 4 + .../src/main/java/edu/lab/MigrationLab.java | 9 + .../db/migration/V1__create_orders.sql | 1 + .../db/migration/V2__request_key.sql | 2 + .../lessons/flyway-migrations/starter/pom.xml | 4 + .../src/main/java/edu/lab/MigrationLab.java | 9 + .../db/migration/V1__create_orders.sql | 1 + .../db/migration/V2__request_key.sql | 2 + .../src/test/java/edu/lab/ContractTest.java | 28 + .../lessons/jpa-state/lesson.json | 199 +++ .../lessons/jpa-state/reference/pom.xml | 5 + .../src/main/java/edu/lab/JpaLab.java | 11 + .../src/main/java/edu/lab/OrderEntity.java | 21 + .../main/resources/META-INF/persistence.xml | 14 + .../lessons/jpa-state/starter/pom.xml | 5 + .../starter/src/main/java/edu/lab/JpaLab.java | 11 + .../src/main/java/edu/lab/OrderEntity.java | 18 + .../main/resources/META-INF/persistence.xml | 14 + .../src/test/java/edu/lab/ContractTest.java | 34 + .../lessons/json-validation/lesson.json | 177 ++ .../lessons/json-validation/reference/pom.xml | 5 + .../java/edu/lab/CreateOrderController.java | 21 + .../lessons/json-validation/starter/pom.xml | 5 + .../java/edu/lab/CreateOrderController.java | 19 + .../src/test/java/edu/lab/ContractTest.java | 47 + .../lessons/jwt-validation/lesson.json | 174 ++ .../lessons/jwt-validation/reference/pom.xml | 4 + .../src/main/java/edu/lab/JwtLab.java | 24 + .../lessons/jwt-validation/starter/pom.xml | 4 + .../starter/src/main/java/edu/lab/JwtLab.java | 12 + .../src/test/java/edu/lab/ContractTest.java | 70 + .../lessons/kafka-idempotency/lesson.json | 181 ++ .../kafka-idempotency/reference/FIXTURE.md | 4 + .../kafka-idempotency/reference/pom.xml | 4 + .../src/main/java/edu/lab/EventHandler.java | 34 + .../kafka-idempotency/starter/FIXTURE.md | 4 + .../lessons/kafka-idempotency/starter/pom.xml | 4 + .../src/main/java/edu/lab/EventHandler.java | 10 + .../src/test/java/edu/lab/ContractTest.java | 7 + .../lessons/lombok-values/lesson.json | 164 ++ .../lessons/lombok-values/reference/pom.xml | 3 + .../src/main/java/edu/lab/OrderKey.java | 13 + .../lessons/lombok-values/starter/pom.xml | 3 + .../src/main/java/edu/lab/OrderKey.java | 12 + .../src/test/java/edu/lab/ContractTest.java | 18 + .../professional/lessons/mvc-rest/lesson.json | 168 ++ .../lessons/mvc-rest/reference/pom.xml | 5 + .../main/java/edu/lab/OrdersController.java | 17 + .../lessons/mvc-rest/starter/pom.xml | 5 + .../main/java/edu/lab/OrdersController.java | 14 + .../src/test/java/edu/lab/ContractTest.java | 32 + .../lessons/mybatis-mapping/lesson.json | 181 ++ .../lessons/mybatis-mapping/reference/pom.xml | 4 + .../src/main/java/edu/lab/MyBatisLab.java | 26 + .../main/resources/edu/lab/OrderMapper.xml | 10 + .../lessons/mybatis-mapping/starter/pom.xml | 4 + .../src/main/java/edu/lab/MyBatisLab.java | 26 + .../main/resources/edu/lab/OrderMapper.xml | 11 + .../src/test/java/edu/lab/ContractTest.java | 28 + .../lessons/observability/lesson.json | 179 ++ .../lessons/observability/reference/pom.xml | 4 + .../src/main/java/edu/lab/OrderTelemetry.java | 22 + .../lessons/observability/starter/pom.xml | 4 + .../src/main/java/edu/lab/OrderTelemetry.java | 11 + .../src/test/java/edu/lab/ContractTest.java | 29 + .../lessons/oidc-identity/lesson.json | 173 ++ .../lessons/oidc-identity/reference/pom.xml | 6 + .../src/main/java/edu/lab/IdentityLab.java | 45 + .../lessons/oidc-identity/starter/pom.xml | 6 + .../src/main/java/edu/lab/IdentityLab.java | 39 + .../src/test/java/edu/lab/ContractTest.java | 31 + .../lessons/optimistic-cancel/lesson.json | 194 +++ .../optimistic-cancel/reference/pom.xml | 5 + .../main/java/edu/lab/CancellableOrder.java | 24 + .../main/java/edu/lab/CancellationLab.java | 10 + .../main/resources/META-INF/persistence.xml | 14 + .../lessons/optimistic-cancel/starter/pom.xml | 5 + .../main/java/edu/lab/CancellableOrder.java | 20 + .../main/java/edu/lab/CancellationLab.java | 10 + .../main/resources/META-INF/persistence.xml | 14 + .../src/test/java/edu/lab/ContractTest.java | 66 + .../lessons/orders-capstone/lesson.json | 221 +++ .../lessons/orders-capstone/reference/pom.xml | 10 + .../src/main/java/edu/lab/CapstoneLab.java | 87 + .../src/main/resources/application.properties | 6 + .../resources/db/migration/V1__orders.sql | 3 + .../lessons/orders-capstone/starter/pom.xml | 10 + .../src/main/java/edu/lab/CapstoneLab.java | 69 + .../src/main/resources/application.properties | 6 + .../resources/db/migration/V1__orders.sql | 3 + .../src/test/java/edu/lab/ContractTest.java | 37 + .../lessons/redis-cache/lesson.json | 179 ++ .../lessons/redis-cache/reference/FIXTURE.md | 4 + .../lessons/redis-cache/reference/pom.xml | 3 + .../src/main/java/edu/lab/StockCache.java | 29 + .../lessons/redis-cache/starter/FIXTURE.md | 4 + .../lessons/redis-cache/starter/pom.xml | 3 + .../src/main/java/edu/lab/StockCache.java | 21 + .../src/test/java/edu/lab/ContractTest.java | 18 + .../lessons/security-csrf/lesson.json | 171 ++ .../lessons/security-csrf/reference/pom.xml | 6 + .../src/main/java/edu/lab/SecurityLab.java | 28 + .../lessons/security-csrf/starter/pom.xml | 6 + .../src/main/java/edu/lab/SecurityLab.java | 24 + .../src/test/java/edu/lab/ContractTest.java | 7 + .../lessons/spring-aop/lesson.json | 159 ++ .../lessons/spring-aop/reference/pom.xml | 4 + .../src/main/java/edu/lab/AopLab.java | 19 + .../lessons/spring-aop/starter/pom.xml | 4 + .../starter/src/main/java/edu/lab/AopLab.java | 15 + .../src/test/java/edu/lab/ContractTest.java | 8 + .../lessons/spring-di/lesson.json | 161 ++ .../lessons/spring-di/reference/pom.xml | 3 + .../src/main/java/edu/lab/DiLab.java | 20 + .../lessons/spring-di/starter/pom.xml | 3 + .../starter/src/main/java/edu/lab/DiLab.java | 18 + .../src/test/java/edu/lab/ContractTest.java | 21 + .../professional/lessons/sql-jdbc/lesson.json | 180 ++ .../lessons/sql-jdbc/reference/pom.xml | 4 + .../main/java/edu/lab/JdbcReservations.java | 30 + .../lessons/sql-jdbc/starter/pom.xml | 4 + .../main/java/edu/lab/JdbcReservations.java | 11 + .../src/test/java/edu/lab/ContractTest.java | 8 + .../org.mockito.plugins.MockMaker | 1 + .../lessons/test-collaborators/lesson.json | 168 ++ .../test-collaborators/reference/pom.xml | 3 + .../src/main/java/edu/lab/Checkout.java | 12 + .../test-collaborators/starter/pom.xml | 3 + .../src/main/java/edu/lab/Checkout.java | 9 + .../src/test/java/edu/lab/ContractTest.java | 7 + .../lessons/test-contracts/lesson.json | 161 ++ .../lessons/test-contracts/reference/pom.xml | 2 + .../src/main/java/edu/lab/Reservation.java | 12 + .../lessons/test-contracts/starter/pom.xml | 2 + .../src/main/java/edu/lab/Reservation.java | 9 + .../src/test/java/edu/lab/ContractTest.java | 21 + .../professional/lessons/tx-proxy/lesson.json | 172 ++ .../lessons/tx-proxy/reference/pom.xml | 5 + .../src/main/java/edu/lab/TransactionLab.java | 33 + .../lessons/tx-proxy/starter/pom.xml | 5 + .../src/main/java/edu/lab/TransactionLab.java | 30 + curriculum/professional/pom.xml | 14 + curriculum/professional/profiles.json | 1547 +++++++++++++++++ desktop/build-config.cjs | 11 + desktop/job-runner.ps1 | 108 ++ desktop/main.cjs | 102 ++ desktop/preload.cjs | 20 + desktop/reference-policy.cjs | 55 + desktop/storage.cjs | 29 + dist/app.js | 565 ++++-- dist/editor.js | 8 +- dist/index.html | 16 +- dist/styles.css | 37 +- docs/architecture.md | 12 +- docs/deployment.md | 6 + docs/desktop-qa-report.md | 96 + docs/development-handoff.md | 41 + docs/education-roadmap.md | 12 +- docs/installation.md | 28 + docs/local-execution.md | 90 + docs/milestones.md | 10 +- docs/product-plan.md | 12 +- docs/professional-curriculum.md | 129 ++ docs/verification.md | 10 +- package-lock.json | 4 +- package.json | 11 +- scripts/build-professional.mjs | 46 + scripts/build-worker.mjs | 9 +- scripts/check-professional.mjs | 64 + scripts/check-worker.mjs | 10 + scripts/serve.mjs | 2 +- server/local/engine.mjs | 176 ++ server/local/errors.mjs | 13 + server/local/http.mjs | 101 ++ server/local/process.mjs | 106 ++ server/local/projects.mjs | 131 ++ server/local/resources.mjs | 36 + server/local/settings.mjs | 116 ++ server/local/workspace.mjs | 49 + tests/browser.mjs | 12 +- tests/desktop.mjs | 562 ++++++ tests/local/contracts.test.mjs | 153 ++ tests/local/http.test.mjs | 33 + tests/local/java-smoke.mjs | 64 + tests/local/process-fixture.mjs | 17 + tests/local/process.test.mjs | 61 + tests/local/reference-policy.test.mjs | 64 + tests/professional.test.mjs | 122 ++ worker/index.js | 4 +- 219 files changed, 11987 insertions(+), 142 deletions(-) create mode 100644 curriculum/professional/README.md create mode 100644 curriculum/professional/catalog.json create mode 100644 curriculum/professional/lessons/build-gradle-ci/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/build-gradle-ci/lesson.json create mode 100644 curriculum/professional/lessons/build-gradle-ci/reference/BUILD-COMPARISON.md create mode 100644 curriculum/professional/lessons/build-gradle-ci/reference/Dockerfile create mode 100644 curriculum/professional/lessons/build-gradle-ci/reference/build.gradle create mode 100644 curriculum/professional/lessons/build-gradle-ci/reference/deployment-examples/ci.yml create mode 100644 curriculum/professional/lessons/build-gradle-ci/reference/pom.xml create mode 100644 curriculum/professional/lessons/build-gradle-ci/reference/settings.gradle create mode 100644 curriculum/professional/lessons/build-gradle-ci/reference/src/main/java/edu/lab/BuildMain.java create mode 100644 curriculum/professional/lessons/build-gradle-ci/starter/BUILD-COMPARISON.md create mode 100644 curriculum/professional/lessons/build-gradle-ci/starter/Dockerfile create mode 100644 curriculum/professional/lessons/build-gradle-ci/starter/build.gradle create mode 100644 curriculum/professional/lessons/build-gradle-ci/starter/deployment-examples/ci.yml create mode 100644 curriculum/professional/lessons/build-gradle-ci/starter/pom.xml create mode 100644 curriculum/professional/lessons/build-gradle-ci/starter/settings.gradle create mode 100644 curriculum/professional/lessons/build-gradle-ci/starter/src/main/java/edu/lab/BuildMain.java create mode 100644 curriculum/professional/lessons/build-maven/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/build-maven/lesson.json create mode 100644 curriculum/professional/lessons/build-maven/reference/pom.xml create mode 100644 curriculum/professional/lessons/build-maven/reference/src/main/java/edu/lab/OrderAmounts.java create mode 100644 curriculum/professional/lessons/build-maven/starter/pom.xml create mode 100644 curriculum/professional/lessons/build-maven/starter/src/main/java/edu/lab/OrderAmounts.java create mode 100644 curriculum/professional/lessons/flyway-migrations/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/flyway-migrations/lesson.json create mode 100644 curriculum/professional/lessons/flyway-migrations/reference/pom.xml create mode 100644 curriculum/professional/lessons/flyway-migrations/reference/src/main/java/edu/lab/MigrationLab.java create mode 100644 curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V1__create_orders.sql create mode 100644 curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V2__request_key.sql create mode 100644 curriculum/professional/lessons/flyway-migrations/starter/pom.xml create mode 100644 curriculum/professional/lessons/flyway-migrations/starter/src/main/java/edu/lab/MigrationLab.java create mode 100644 curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V1__create_orders.sql create mode 100644 curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V2__request_key.sql create mode 100644 curriculum/professional/lessons/jpa-state/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/jpa-state/lesson.json create mode 100644 curriculum/professional/lessons/jpa-state/reference/pom.xml create mode 100644 curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/JpaLab.java create mode 100644 curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/OrderEntity.java create mode 100644 curriculum/professional/lessons/jpa-state/reference/src/main/resources/META-INF/persistence.xml create mode 100644 curriculum/professional/lessons/jpa-state/starter/pom.xml create mode 100644 curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/JpaLab.java create mode 100644 curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/OrderEntity.java create mode 100644 curriculum/professional/lessons/jpa-state/starter/src/main/resources/META-INF/persistence.xml create mode 100644 curriculum/professional/lessons/json-validation/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/json-validation/lesson.json create mode 100644 curriculum/professional/lessons/json-validation/reference/pom.xml create mode 100644 curriculum/professional/lessons/json-validation/reference/src/main/java/edu/lab/CreateOrderController.java create mode 100644 curriculum/professional/lessons/json-validation/starter/pom.xml create mode 100644 curriculum/professional/lessons/json-validation/starter/src/main/java/edu/lab/CreateOrderController.java create mode 100644 curriculum/professional/lessons/jwt-validation/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/jwt-validation/lesson.json create mode 100644 curriculum/professional/lessons/jwt-validation/reference/pom.xml create mode 100644 curriculum/professional/lessons/jwt-validation/reference/src/main/java/edu/lab/JwtLab.java create mode 100644 curriculum/professional/lessons/jwt-validation/starter/pom.xml create mode 100644 curriculum/professional/lessons/jwt-validation/starter/src/main/java/edu/lab/JwtLab.java create mode 100644 curriculum/professional/lessons/kafka-idempotency/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/kafka-idempotency/lesson.json create mode 100644 curriculum/professional/lessons/kafka-idempotency/reference/FIXTURE.md create mode 100644 curriculum/professional/lessons/kafka-idempotency/reference/pom.xml create mode 100644 curriculum/professional/lessons/kafka-idempotency/reference/src/main/java/edu/lab/EventHandler.java create mode 100644 curriculum/professional/lessons/kafka-idempotency/starter/FIXTURE.md create mode 100644 curriculum/professional/lessons/kafka-idempotency/starter/pom.xml create mode 100644 curriculum/professional/lessons/kafka-idempotency/starter/src/main/java/edu/lab/EventHandler.java create mode 100644 curriculum/professional/lessons/lombok-values/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/lombok-values/lesson.json create mode 100644 curriculum/professional/lessons/lombok-values/reference/pom.xml create mode 100644 curriculum/professional/lessons/lombok-values/reference/src/main/java/edu/lab/OrderKey.java create mode 100644 curriculum/professional/lessons/lombok-values/starter/pom.xml create mode 100644 curriculum/professional/lessons/lombok-values/starter/src/main/java/edu/lab/OrderKey.java create mode 100644 curriculum/professional/lessons/mvc-rest/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/mvc-rest/lesson.json create mode 100644 curriculum/professional/lessons/mvc-rest/reference/pom.xml create mode 100644 curriculum/professional/lessons/mvc-rest/reference/src/main/java/edu/lab/OrdersController.java create mode 100644 curriculum/professional/lessons/mvc-rest/starter/pom.xml create mode 100644 curriculum/professional/lessons/mvc-rest/starter/src/main/java/edu/lab/OrdersController.java create mode 100644 curriculum/professional/lessons/mybatis-mapping/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/mybatis-mapping/lesson.json create mode 100644 curriculum/professional/lessons/mybatis-mapping/reference/pom.xml create mode 100644 curriculum/professional/lessons/mybatis-mapping/reference/src/main/java/edu/lab/MyBatisLab.java create mode 100644 curriculum/professional/lessons/mybatis-mapping/reference/src/main/resources/edu/lab/OrderMapper.xml create mode 100644 curriculum/professional/lessons/mybatis-mapping/starter/pom.xml create mode 100644 curriculum/professional/lessons/mybatis-mapping/starter/src/main/java/edu/lab/MyBatisLab.java create mode 100644 curriculum/professional/lessons/mybatis-mapping/starter/src/main/resources/edu/lab/OrderMapper.xml create mode 100644 curriculum/professional/lessons/observability/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/observability/lesson.json create mode 100644 curriculum/professional/lessons/observability/reference/pom.xml create mode 100644 curriculum/professional/lessons/observability/reference/src/main/java/edu/lab/OrderTelemetry.java create mode 100644 curriculum/professional/lessons/observability/starter/pom.xml create mode 100644 curriculum/professional/lessons/observability/starter/src/main/java/edu/lab/OrderTelemetry.java create mode 100644 curriculum/professional/lessons/oidc-identity/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/oidc-identity/lesson.json create mode 100644 curriculum/professional/lessons/oidc-identity/reference/pom.xml create mode 100644 curriculum/professional/lessons/oidc-identity/reference/src/main/java/edu/lab/IdentityLab.java create mode 100644 curriculum/professional/lessons/oidc-identity/starter/pom.xml create mode 100644 curriculum/professional/lessons/oidc-identity/starter/src/main/java/edu/lab/IdentityLab.java create mode 100644 curriculum/professional/lessons/optimistic-cancel/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/optimistic-cancel/lesson.json create mode 100644 curriculum/professional/lessons/optimistic-cancel/reference/pom.xml create mode 100644 curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellableOrder.java create mode 100644 curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellationLab.java create mode 100644 curriculum/professional/lessons/optimistic-cancel/reference/src/main/resources/META-INF/persistence.xml create mode 100644 curriculum/professional/lessons/optimistic-cancel/starter/pom.xml create mode 100644 curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellableOrder.java create mode 100644 curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellationLab.java create mode 100644 curriculum/professional/lessons/optimistic-cancel/starter/src/main/resources/META-INF/persistence.xml create mode 100644 curriculum/professional/lessons/orders-capstone/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/orders-capstone/lesson.json create mode 100644 curriculum/professional/lessons/orders-capstone/reference/pom.xml create mode 100644 curriculum/professional/lessons/orders-capstone/reference/src/main/java/edu/lab/CapstoneLab.java create mode 100644 curriculum/professional/lessons/orders-capstone/reference/src/main/resources/application.properties create mode 100644 curriculum/professional/lessons/orders-capstone/reference/src/main/resources/db/migration/V1__orders.sql create mode 100644 curriculum/professional/lessons/orders-capstone/starter/pom.xml create mode 100644 curriculum/professional/lessons/orders-capstone/starter/src/main/java/edu/lab/CapstoneLab.java create mode 100644 curriculum/professional/lessons/orders-capstone/starter/src/main/resources/application.properties create mode 100644 curriculum/professional/lessons/orders-capstone/starter/src/main/resources/db/migration/V1__orders.sql create mode 100644 curriculum/professional/lessons/redis-cache/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/redis-cache/lesson.json create mode 100644 curriculum/professional/lessons/redis-cache/reference/FIXTURE.md create mode 100644 curriculum/professional/lessons/redis-cache/reference/pom.xml create mode 100644 curriculum/professional/lessons/redis-cache/reference/src/main/java/edu/lab/StockCache.java create mode 100644 curriculum/professional/lessons/redis-cache/starter/FIXTURE.md create mode 100644 curriculum/professional/lessons/redis-cache/starter/pom.xml create mode 100644 curriculum/professional/lessons/redis-cache/starter/src/main/java/edu/lab/StockCache.java create mode 100644 curriculum/professional/lessons/security-csrf/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/security-csrf/lesson.json create mode 100644 curriculum/professional/lessons/security-csrf/reference/pom.xml create mode 100644 curriculum/professional/lessons/security-csrf/reference/src/main/java/edu/lab/SecurityLab.java create mode 100644 curriculum/professional/lessons/security-csrf/starter/pom.xml create mode 100644 curriculum/professional/lessons/security-csrf/starter/src/main/java/edu/lab/SecurityLab.java create mode 100644 curriculum/professional/lessons/spring-aop/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/spring-aop/lesson.json create mode 100644 curriculum/professional/lessons/spring-aop/reference/pom.xml create mode 100644 curriculum/professional/lessons/spring-aop/reference/src/main/java/edu/lab/AopLab.java create mode 100644 curriculum/professional/lessons/spring-aop/starter/pom.xml create mode 100644 curriculum/professional/lessons/spring-aop/starter/src/main/java/edu/lab/AopLab.java create mode 100644 curriculum/professional/lessons/spring-di/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/spring-di/lesson.json create mode 100644 curriculum/professional/lessons/spring-di/reference/pom.xml create mode 100644 curriculum/professional/lessons/spring-di/reference/src/main/java/edu/lab/DiLab.java create mode 100644 curriculum/professional/lessons/spring-di/starter/pom.xml create mode 100644 curriculum/professional/lessons/spring-di/starter/src/main/java/edu/lab/DiLab.java create mode 100644 curriculum/professional/lessons/sql-jdbc/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/sql-jdbc/lesson.json create mode 100644 curriculum/professional/lessons/sql-jdbc/reference/pom.xml create mode 100644 curriculum/professional/lessons/sql-jdbc/reference/src/main/java/edu/lab/JdbcReservations.java create mode 100644 curriculum/professional/lessons/sql-jdbc/starter/pom.xml create mode 100644 curriculum/professional/lessons/sql-jdbc/starter/src/main/java/edu/lab/JdbcReservations.java create mode 100644 curriculum/professional/lessons/test-collaborators/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/test-collaborators/grader/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker create mode 100644 curriculum/professional/lessons/test-collaborators/lesson.json create mode 100644 curriculum/professional/lessons/test-collaborators/reference/pom.xml create mode 100644 curriculum/professional/lessons/test-collaborators/reference/src/main/java/edu/lab/Checkout.java create mode 100644 curriculum/professional/lessons/test-collaborators/starter/pom.xml create mode 100644 curriculum/professional/lessons/test-collaborators/starter/src/main/java/edu/lab/Checkout.java create mode 100644 curriculum/professional/lessons/test-contracts/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/test-contracts/lesson.json create mode 100644 curriculum/professional/lessons/test-contracts/reference/pom.xml create mode 100644 curriculum/professional/lessons/test-contracts/reference/src/main/java/edu/lab/Reservation.java create mode 100644 curriculum/professional/lessons/test-contracts/starter/pom.xml create mode 100644 curriculum/professional/lessons/test-contracts/starter/src/main/java/edu/lab/Reservation.java create mode 100644 curriculum/professional/lessons/tx-proxy/grader/src/test/java/edu/lab/ContractTest.java create mode 100644 curriculum/professional/lessons/tx-proxy/lesson.json create mode 100644 curriculum/professional/lessons/tx-proxy/reference/pom.xml create mode 100644 curriculum/professional/lessons/tx-proxy/reference/src/main/java/edu/lab/TransactionLab.java create mode 100644 curriculum/professional/lessons/tx-proxy/starter/pom.xml create mode 100644 curriculum/professional/lessons/tx-proxy/starter/src/main/java/edu/lab/TransactionLab.java create mode 100644 curriculum/professional/pom.xml create mode 100644 curriculum/professional/profiles.json create mode 100644 desktop/build-config.cjs create mode 100644 desktop/job-runner.ps1 create mode 100644 desktop/main.cjs create mode 100644 desktop/preload.cjs create mode 100644 desktop/reference-policy.cjs create mode 100644 desktop/storage.cjs create mode 100644 docs/desktop-qa-report.md create mode 100644 docs/development-handoff.md create mode 100644 docs/installation.md create mode 100644 docs/local-execution.md create mode 100644 docs/professional-curriculum.md create mode 100644 scripts/build-professional.mjs create mode 100644 scripts/check-professional.mjs create mode 100644 server/local/engine.mjs create mode 100644 server/local/errors.mjs create mode 100644 server/local/http.mjs create mode 100644 server/local/process.mjs create mode 100644 server/local/projects.mjs create mode 100644 server/local/resources.mjs create mode 100644 server/local/settings.mjs create mode 100644 server/local/workspace.mjs create mode 100644 tests/desktop.mjs create mode 100644 tests/local/contracts.test.mjs create mode 100644 tests/local/http.test.mjs create mode 100644 tests/local/java-smoke.mjs create mode 100644 tests/local/process-fixture.mjs create mode 100644 tests/local/process.test.mjs create mode 100644 tests/local/reference-policy.test.mjs create mode 100644 tests/professional.test.mjs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 4181bfc..6b1647d 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -20,8 +20,8 @@ jobs: node-version: '22' cache: npm - run: npm ci - - run: npm run check - run: npm run build + - run: npm run check - run: npm run test:worker java: name: Java examples (${{ matrix.java }}) @@ -55,3 +55,45 @@ jobs: - run: npm run test:browser env: QA_ARTIFACT_DIR: work/java-edu-qa + professional: + name: Professional projects (${{ matrix.java }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: ['17', '21'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '22' + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - name: Verify reference and unfinished starter projects + run: | + export MAVEN_HOME="$(dirname "$(dirname "$(readlink -f "$(command -v mvn)")")")" + node scripts/check-professional.mjs + local-windows: + name: Windows local runner (${{ matrix.java }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + java: ['8', '11', '17', '21'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '22' + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - run: npm run test:local + - name: Verify actual local JVM checks + shell: pwsh + run: | + Set-Item -Path 'Env:JAVA_EDU_JDK_${{ matrix.java }}' -Value $env:JAVA_HOME + node tests/local/java-smoke.mjs diff --git a/.gitignore b/.gitignore index 35fb58b..64b9a83 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ dist/server/ dist/.openai/ dist/assets/editor.bundle.js desktop-release/ +dist/downloads/ +dist/data/professional.js diff --git a/AGENTS.md b/AGENTS.md index 53698b5..4a808eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,6 @@ 역할: 총괄은 범위·통합·검증 책임; 연구자는 공식 명세와 공고 원문 대조; 교육 설계자는 교육 데이터와 독립 Java 예제; 프런트엔드 담당자는 dist의 화면·상호작용; 독립 리뷰어는 정확성·보안·회귀; QA는 실제 기능·합성 화면 검사. 역할은 전문 자격을 의미하지 않는다. -미지원 Java 버전이나 preview 문법을 정식 예제로 표시하지 않는다. 공고 관찰을 채용 통계로 만들지 않는다. 브라우저 퀴즈와 Java 실행 검증을 구분한다. 실제 실행하지 않은 테스트는 미실행으로 기록한다. 실행기 계약은 docs/architecture.md를 따른다. 교재 기준·컴파일 대상·실제 실행 JVM을 분리해서 표시한다. 프로토타입은 외부 Wandbox의 실제 JVM21을 사용하며 Java8/11/17 실제 JVM이라고 주장하지 않는다. Spring·DB 통합 단원은 해당 실행 환경 검증 후 공개한다. +미지원 Java 버전이나 preview 문법을 정식 예제로 표시하지 않는다. 공고 관찰을 채용 통계로 만들지 않는다. 브라우저 퀴즈와 Java 실행 검증을 구분한다. 실제 실행하지 않은 테스트는 미실행으로 기록한다. 실행기 계약은 docs/architecture.md와 docs/local-execution.md를 따른다. 교재 기준·컴파일 대상·실제 실행 JVM을 분리해서 표시한다. 웹 코어 실행은 외부 Wandbox의 실제 JVM21을 사용하며 Java8/11/17 실제 JVM이라고 주장하지 않는다. Windows 설치형은 등록한 로컬 JDK를 사용하며 원격 제공자를 호출하지 않는다. 실무 교재의 정적 공개·기준 테스트 실행·설치형 통합 검증을 분리한다. Redis/Kafka 실제 연동은 선택 환경이며 미검증을 숨기지 않는다. 이 개인 IDE를 OS 보안 샌드박스로 표현하지 않는다. 사용자는 마일스톤별 커밋·푸시와 완성된 프로토타입/서비스의 배포를 승인했다. origin은 jbsim999/java-edu, main과 단기 작업 브랜치를 사용한다. 강제 푸시·미검증 병합·공개 범위 변경·유료 자원 구매 권한은 만들지 않는다. GitHub 저장소는 공개이며 새 Sites 프로토타입은 기본 owner-private를 유지한다. diff --git a/README.md b/README.md index 1517791..74782ca 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 공식 Java 문서 기반으로 다른 언어 경험자의 Java 전환 학습과 현직자의 심화를 연결하는 한국어 교육 서비스입니다. OOP·타입·동시성을 깊게 다루고, 수업 끝에서 Java 코드를 직접 작성·실행·검증합니다. -현재 상태: **프로토타입 배포 완료**. [Java Learning Lab](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 계정만 접근할 수 있습니다. Java 코어 12수업·24문항·12코딩 과제와 5단계 학습실을 구현했습니다. Spring·Lombok·MyBatis·OAuth2/OIDC·JPA·Redis·Kafka 및 운영 과정은 교육 로드맵에 포함하며 후속 실습 기반에서 확장합니다. 전체 교육 과정 완료를 뜻하지 않습니다. +현재 구성: **Windows 설치형 + 웹 교재 v0.2**. Java 코어12수업과 실무22수업, 총68문항과34코딩 과제를 연결합니다. Spring·Lombok·MyBatis·JPA·트랜잭션·Security·OAuth2/OIDC·JWT·Redis·Kafka·관측·빌드/운영 과정을 포함합니다. 기본20개 실무 프로젝트는 별도 DB 서버 없이 실행하며 Redis/Kafka 두 단원은 선택 환경입니다. 설치형과34단원 웹의 최종 출시 검증을 진행 중입니다. - [기획안](docs/product-plan.md) - [교육 로드맵](docs/education-roadmap.md) @@ -12,17 +12,28 @@ - [공식 Java 호환 기준](docs/java-version-matrix.md) - [채용 근거](docs/job-evidence.md) - [구현된 코어 과정](docs/curriculum.md) +- [실무22단원](docs/professional-curriculum.md) +- [Windows 설치와 사용](docs/installation.md) +- [로컬 실행 계약](docs/local-execution.md) - [협업 규칙](CONTRIBUTING.md) - [실제 검증과 미확인 범위](docs/verification.md) - [배포 소스·버전 기록](docs/deployment.md) - [독립 브라우저 QA](docs/qa-report.md) +- [문법 강조·테마 QA](docs/editor-qa-report.md) +- [Windows 앱·실무 통합 QA](docs/desktop-qa-report.md) 수업은 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩의 5단계입니다. 객관식 성취·코드 행동 검증·설계 평가를 구분합니다. -교재는 Java 8·11·17·21 기준입니다. 첫 프로토타입의 외부 실행 환경은 **Java21**이며 8·11·17 컴파일 대상과 해당 JVM 실행을 혼동하지 않습니다. 실행 버튼을 누를 때 작성 코드가 Wandbox로 전송되며 운영 로그에 남을 수 있습니다. 민감정보를 입력하지 마세요. +교재는 Java8·11·17·21 기준입니다. 설치형은 등록한 실제 JDK로 코드를 실행하며 작성 코드를 Wandbox로 전송하지 않습니다. 현대 Spring 프로젝트의 컴파일 대상은17이고 실행 JDK는17/21입니다. 웹 Java 코어 실행은 Wandbox의 실제 JVM21을 사용하며8·11·17 컴파일 대상과 해당 JVM 실행을 혼동하지 않습니다. 웹 실행·제출 버튼을 누를 때 코드가 Wandbox로 전송되며 운영 로그에 남을 수 있습니다. -Node22+로 `npm ci`, `npm run dev`를 실행하면 `http://127.0.0.1:4173`에서 학습실을 사용할 수 있습니다. 진도·코드·메모는 이 브라우저에 저장됩니다. 편집기는 일반 텍스트 편집기이며 자동 완성을 제공하지 않습니다. +학습자는 Windows 설치 파일과 JDK를 준비하며 실무 프로젝트에는 Maven도 등록합니다. 앱의 진도·코드·메모·테마는 앱 재시작 뒤에도 보존됩니다. 웹과 앱 사이 자동 동기화는 제공하지 않습니다. 편집기는 Java 문법에 따라 클래스·타입·변수·메서드 색상을 구분하고 다크·라이트·고대비 테마, 줄 번호·들여쓰기·괄호 표시를 제공합니다. 의미 분석 기반 IDE나 자동 완성 엔진은 포함하지 않습니다. + +개발자는 Node22+로 `npm ci`, `npm run dev`를 실행하면 `http://127.0.0.1:4173`에서 학습실을 사용할 수 있습니다. `npm run desktop`으로 개발 앱, `npm run build:desktop`으로 Windows x64 NSIS 설치 파일을 생성합니다. JDK/Maven은 앱 설정의 폴더 선택으로 등록하므로 시스템 PATH를 변경하지 않습니다. `npm run check`는 콘텐츠와 실행 API 검사, `npm run test:java`는 설치된 JDK의 예제·기준 답안 검증입니다. `npm run test:browser`는 서버가 실행 중일 때 모의 API로 기능을 검사합니다. 로컬 Edge는 `PLAYWRIGHT_CHANNEL=msedge`를 지정하고 CI는 Chromium을 사용합니다. 검사 정의와 실제 결과를 구분해 기록합니다. `npm run build`는 정적 UI와 실행 API를 하나의 Worker로 묶습니다. `npm run test:worker`는 빌드 산출물의 경로·CSP·개인 파일 제외·인증 필수 동작을 검사합니다. 네트워크 실행 테스트는 별도 명시적 모드에서만 호출하며 기본 테스트는 외부로 코드를 보내지 않습니다. + +`npm run test:local`은 로컬 실행·Windows 프로세스 종료 계약, `npm run test:professional`은 JAVA_HOME/MAVEN_HOME에서 기준 프로젝트 통과와 미완성 과제 실패를 검사합니다. Redis/Kafka 두 단원은 기본 검증에서 명시적으로 제외하며 `--compile-only`로 의존성과 컴파일만 따로 확인할 수 있습니다. `tests/desktop.mjs`는 실제 개발 Electron·로컬 JDK/Maven 흐름과 모의 API 웹 검사를 구분합니다. + +[웹 교재](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)는 현재 소유자 전용 접근을 유지하며 배포된 버전은 코어12수업과 편집기 테마입니다. Windows 설치 파일은 로컬 빌드가 완료됐고, 설치·제거와 최종 시각 검수 후 공개 저장소의 [Releases](https://github.com/jbsim999/java-edu/releases)에 게시할 예정입니다. 새 프로젝트에서 이어갈 상태는 [개발 인계](docs/development-handoff.md)를 먼저 확인합니다. diff --git a/curriculum/professional/README.md b/curriculum/professional/README.md new file mode 100644 index 0000000..204fa50 --- /dev/null +++ b/curriculum/professional/README.md @@ -0,0 +1,14 @@ +# 실무 프로젝트 원본 + +공통 부모 POM과 22개 독립 학습 프로젝트입니다. 설계·선수 관계·프로필·실행 범위는 [실무 교육 과정](../../docs/professional-curriculum.md)을 읽으세요. + +- catalog.json: 22개 수업의 경로, profileId, exerciseId, editableFiles, files, testFiles +- profiles.json: Java 17 대상, Java 17+ 실행 요구, 의존성과 외부 자원 요구 +- lessons//lesson.json: 이론·예시·문제·실무·직접 코딩 계약 +- lessons//reference: 공개 기준 구현 +- lessons//starter: 출발 코드 +- lessons//grader: 공개 행동 검사 + +source-prepared와 executionVerified는 다릅니다. 실제 실행 결과는 별도 검증 기록과 연결해야 합니다. Redis/Kafka 자원이 없거나 설치형에서 미지원이면 통합 실습은 실행되지 않습니다. + +이 디렉터리 전체 구조를 보존하세요. 각 reference/starter POM은 공통 pom.xml 및 형제 grader를 참조합니다. 실제 학습 실행은 Windows 설치형의 환경 준비와 고정 Maven 경로를 사용합니다. 원격 실행 서버로 코드나 실제 자격증명을 보내지 않습니다. diff --git a/curriculum/professional/catalog.json b/curriculum/professional/catalog.json new file mode 100644 index 0000000..0bb569a --- /dev/null +++ b/curriculum/professional/catalog.json @@ -0,0 +1,1204 @@ +{ + "schemaVersion": 1, + "checkedAt": "2026-10-12", + "status": "source-prepared", + "lessons": [ + { + "id": "build-maven", + "order": 1, + "title": "Maven으로 재현 가능한 주문 계산 빌드", + "profileId": "professional-junit", + "prerequisites": [ + "core:oop", + "core:testing-design" + ], + "lessonPath": "lessons/build-maven/lesson.json", + "referenceDir": "lessons/build-maven/reference", + "starterDir": "lessons/build-maven/starter", + "graderDir": "lessons/build-maven/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/OrderAmounts.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/OrderAmounts.java", + "templatePath": "lessons/build-maven/starter/src/main/java/edu/lab/OrderAmounts.java", + "referencePath": "lessons/build-maven/reference/src/main/java/edu/lab/OrderAmounts.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/build-maven/starter/pom.xml", + "referencePath": "lessons/build-maven/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/build-maven/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "build-maven-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "a9d4d663d97d617409faa0998490cce985e0199084dc3fa55e42351f452d2405", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "test-contracts", + "order": 2, + "title": "JUnit·AssertJ로 경계와 상태 전이 검증", + "profileId": "professional-junit", + "prerequisites": [ + "build-maven" + ], + "lessonPath": "lessons/test-contracts/lesson.json", + "referenceDir": "lessons/test-contracts/reference", + "starterDir": "lessons/test-contracts/starter", + "graderDir": "lessons/test-contracts/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/Reservation.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/Reservation.java", + "templatePath": "lessons/test-contracts/starter/src/main/java/edu/lab/Reservation.java", + "referencePath": "lessons/test-contracts/reference/src/main/java/edu/lab/Reservation.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/test-contracts/starter/pom.xml", + "referencePath": "lessons/test-contracts/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/test-contracts/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "test-contracts-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "cbdeec57521987b40feed67ef6c3af7d4964f6b8804b51c5abae9447dc147028", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "test-collaborators", + "order": 3, + "title": "Mockito로 외부 협력의 실패를 통제", + "profileId": "professional-mockito", + "prerequisites": [ + "test-contracts", + "core:polymorphism" + ], + "lessonPath": "lessons/test-collaborators/lesson.json", + "referenceDir": "lessons/test-collaborators/reference", + "starterDir": "lessons/test-collaborators/starter", + "graderDir": "lessons/test-collaborators/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/Checkout.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/Checkout.java", + "templatePath": "lessons/test-collaborators/starter/src/main/java/edu/lab/Checkout.java", + "referencePath": "lessons/test-collaborators/reference/src/main/java/edu/lab/Checkout.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/test-collaborators/starter/pom.xml", + "referencePath": "lessons/test-collaborators/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/test-collaborators/grader/src/test/java/edu/lab/ContractTest.java" + }, + { + "path": "src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker", + "sourcePath": "lessons/test-collaborators/grader/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker" + } + ], + "exerciseId": "test-collaborators-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "9b36daa6c1fdc7557702c6825819f8b92f1384bb562f8b95e8b9f79368bab733", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "lombok-values", + "order": 4, + "title": "Lombok 생성 코드와 동등성의 범위", + "profileId": "professional-lombok", + "prerequisites": [ + "test-contracts", + "core:collections" + ], + "lessonPath": "lessons/lombok-values/lesson.json", + "referenceDir": "lessons/lombok-values/reference", + "starterDir": "lessons/lombok-values/starter", + "graderDir": "lessons/lombok-values/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/OrderKey.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/OrderKey.java", + "templatePath": "lessons/lombok-values/starter/src/main/java/edu/lab/OrderKey.java", + "referencePath": "lessons/lombok-values/reference/src/main/java/edu/lab/OrderKey.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/lombok-values/starter/pom.xml", + "referencePath": "lessons/lombok-values/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/lombok-values/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "lombok-values-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "65bcae5900047877a822e89821af9b5ac4a511c1b6a634d002757f4cee5efa9a", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "spring-di", + "order": 5, + "title": "Spring IoC·DI와 객체 수명", + "profileId": "professional-spring-core", + "prerequisites": [ + "test-collaborators" + ], + "lessonPath": "lessons/spring-di/lesson.json", + "referenceDir": "lessons/spring-di/reference", + "starterDir": "lessons/spring-di/starter", + "graderDir": "lessons/spring-di/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/DiLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/DiLab.java", + "templatePath": "lessons/spring-di/starter/src/main/java/edu/lab/DiLab.java", + "referencePath": "lessons/spring-di/reference/src/main/java/edu/lab/DiLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/spring-di/starter/pom.xml", + "referencePath": "lessons/spring-di/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/spring-di/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "spring-di-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "092bd10703609e7a73d032f092ed2ace7c53fdf37819c64a4c41504b156ed844", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "spring-aop", + "order": 6, + "title": "프록시·AOP 호출 경로의 반례", + "profileId": "professional-spring-aop", + "prerequisites": [ + "spring-di" + ], + "lessonPath": "lessons/spring-aop/lesson.json", + "referenceDir": "lessons/spring-aop/reference", + "starterDir": "lessons/spring-aop/starter", + "graderDir": "lessons/spring-aop/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/AopLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/AopLab.java", + "templatePath": "lessons/spring-aop/starter/src/main/java/edu/lab/AopLab.java", + "referencePath": "lessons/spring-aop/reference/src/main/java/edu/lab/AopLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/spring-aop/starter/pom.xml", + "referencePath": "lessons/spring-aop/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/spring-aop/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "spring-aop-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "203bd280bfe19ad5cbaef8117c8ab0d00b95d07fe7f1a1f2f5988a187b82bad9", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "mvc-rest", + "order": 7, + "title": "MVC·REST의 상태 코드와 자원 계약", + "profileId": "professional-spring-web", + "prerequisites": [ + "spring-di" + ], + "lessonPath": "lessons/mvc-rest/lesson.json", + "referenceDir": "lessons/mvc-rest/reference", + "starterDir": "lessons/mvc-rest/starter", + "graderDir": "lessons/mvc-rest/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/OrdersController.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/OrdersController.java", + "templatePath": "lessons/mvc-rest/starter/src/main/java/edu/lab/OrdersController.java", + "referencePath": "lessons/mvc-rest/reference/src/main/java/edu/lab/OrdersController.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/mvc-rest/starter/pom.xml", + "referencePath": "lessons/mvc-rest/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/mvc-rest/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "mvc-rest-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "cf818104cfb16da2cfc56a8a887429f7354bfbaf23b2edb5db949abe45065bdf", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "json-validation", + "order": 8, + "title": "Jackson·Validation의 입력 경계", + "profileId": "professional-spring-web", + "prerequisites": [ + "mvc-rest" + ], + "lessonPath": "lessons/json-validation/lesson.json", + "referenceDir": "lessons/json-validation/reference", + "starterDir": "lessons/json-validation/starter", + "graderDir": "lessons/json-validation/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/CreateOrderController.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/CreateOrderController.java", + "templatePath": "lessons/json-validation/starter/src/main/java/edu/lab/CreateOrderController.java", + "referencePath": "lessons/json-validation/reference/src/main/java/edu/lab/CreateOrderController.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/json-validation/starter/pom.xml", + "referencePath": "lessons/json-validation/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/json-validation/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "json-validation-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "cb2dd529c781109c07cf6272989e94ab712c38e29484390922d7e38fb4f046b9", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "sql-jdbc", + "order": 9, + "title": "SQL·JDBC로 재고 변경과 롤백", + "profileId": "professional-jdbc", + "prerequisites": [ + "test-contracts", + "core:exceptions" + ], + "lessonPath": "lessons/sql-jdbc/lesson.json", + "referenceDir": "lessons/sql-jdbc/reference", + "starterDir": "lessons/sql-jdbc/starter", + "graderDir": "lessons/sql-jdbc/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/JdbcReservations.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/JdbcReservations.java", + "templatePath": "lessons/sql-jdbc/starter/src/main/java/edu/lab/JdbcReservations.java", + "referencePath": "lessons/sql-jdbc/reference/src/main/java/edu/lab/JdbcReservations.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/sql-jdbc/starter/pom.xml", + "referencePath": "lessons/sql-jdbc/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/sql-jdbc/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "sql-jdbc-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "408fed487770c066b90e1e7c078dc734c57e66818a2bfeb14f65d66372dc02ba", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "mybatis-mapping", + "order": 10, + "title": "MyBatis 파라미터 바인딩과 XML 매핑", + "profileId": "professional-mybatis", + "prerequisites": [ + "sql-jdbc" + ], + "lessonPath": "lessons/mybatis-mapping/lesson.json", + "referenceDir": "lessons/mybatis-mapping/reference", + "starterDir": "lessons/mybatis-mapping/starter", + "graderDir": "lessons/mybatis-mapping/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/resources/edu/lab/OrderMapper.xml" + ], + "files": [ + { + "path": "src/main/java/edu/lab/MyBatisLab.java", + "templatePath": "lessons/mybatis-mapping/starter/src/main/java/edu/lab/MyBatisLab.java", + "referencePath": "lessons/mybatis-mapping/reference/src/main/java/edu/lab/MyBatisLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/mybatis-mapping/starter/pom.xml", + "referencePath": "lessons/mybatis-mapping/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/edu/lab/OrderMapper.xml", + "templatePath": "lessons/mybatis-mapping/starter/src/main/resources/edu/lab/OrderMapper.xml", + "referencePath": "lessons/mybatis-mapping/reference/src/main/resources/edu/lab/OrderMapper.xml", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/mybatis-mapping/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "mybatis-mapping-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "127b6b7a7bdcf465e0b3b55b32791f14bef3accc1f246aa163497b27102bb36c", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "jpa-state", + "order": 11, + "title": "JPA·Hibernate의 상태와 변경 감지", + "profileId": "professional-jpa", + "prerequisites": [ + "sql-jdbc", + "lombok-values" + ], + "lessonPath": "lessons/jpa-state/lesson.json", + "referenceDir": "lessons/jpa-state/reference", + "starterDir": "lessons/jpa-state/starter", + "graderDir": "lessons/jpa-state/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/OrderEntity.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/JpaLab.java", + "templatePath": "lessons/jpa-state/starter/src/main/java/edu/lab/JpaLab.java", + "referencePath": "lessons/jpa-state/reference/src/main/java/edu/lab/JpaLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/jpa-state/starter/pom.xml", + "referencePath": "lessons/jpa-state/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "templatePath": "lessons/jpa-state/starter/src/main/resources/META-INF/persistence.xml", + "referencePath": "lessons/jpa-state/reference/src/main/resources/META-INF/persistence.xml", + "editable": false + }, + { + "path": "src/main/java/edu/lab/OrderEntity.java", + "templatePath": "lessons/jpa-state/starter/src/main/java/edu/lab/OrderEntity.java", + "referencePath": "lessons/jpa-state/reference/src/main/java/edu/lab/OrderEntity.java", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/jpa-state/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "jpa-state-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "c721dc8a92215ad6b837e104656f0ba8defb5781844f845447f5194a1737332c", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "flyway-migrations", + "order": 12, + "title": "Flyway로 스키마 변경을 기록", + "profileId": "professional-flyway", + "prerequisites": [ + "sql-jdbc" + ], + "lessonPath": "lessons/flyway-migrations/lesson.json", + "referenceDir": "lessons/flyway-migrations/reference", + "starterDir": "lessons/flyway-migrations/starter", + "graderDir": "lessons/flyway-migrations/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/resources/db/migration/V2__request_key.sql" + ], + "files": [ + { + "path": "src/main/java/edu/lab/MigrationLab.java", + "templatePath": "lessons/flyway-migrations/starter/src/main/java/edu/lab/MigrationLab.java", + "referencePath": "lessons/flyway-migrations/reference/src/main/java/edu/lab/MigrationLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/flyway-migrations/starter/pom.xml", + "referencePath": "lessons/flyway-migrations/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V1__create_orders.sql", + "templatePath": "lessons/flyway-migrations/starter/src/main/resources/db/migration/V1__create_orders.sql", + "referencePath": "lessons/flyway-migrations/reference/src/main/resources/db/migration/V1__create_orders.sql", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V2__request_key.sql", + "templatePath": "lessons/flyway-migrations/starter/src/main/resources/db/migration/V2__request_key.sql", + "referencePath": "lessons/flyway-migrations/reference/src/main/resources/db/migration/V2__request_key.sql", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/flyway-migrations/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "flyway-migrations-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 1, + "starterSatisfied": false, + "sourceFingerprint": "f26b6d4349c223d9c0ee58c03a190193431592dd68baa2cdec3775fcc42ecd6f", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "tx-proxy", + "order": 13, + "title": "Spring 트랜잭션 프록시와 롤백 경계", + "profileId": "professional-spring-tx", + "prerequisites": [ + "spring-aop", + "sql-jdbc" + ], + "recommendedOneOf": [ + "mybatis-mapping", + "jpa-state" + ], + "lessonPath": "lessons/tx-proxy/lesson.json", + "referenceDir": "lessons/tx-proxy/reference", + "starterDir": "lessons/tx-proxy/starter", + "graderDir": "lessons/tx-proxy/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/TransactionLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/TransactionLab.java", + "templatePath": "lessons/tx-proxy/starter/src/main/java/edu/lab/TransactionLab.java", + "referencePath": "lessons/tx-proxy/reference/src/main/java/edu/lab/TransactionLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/tx-proxy/starter/pom.xml", + "referencePath": "lessons/tx-proxy/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/tx-proxy/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "tx-proxy-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "bec5b6f575b6d3e3a8875aa5cd5db2b14f6e5e84f99e76bc0a8c435e0339a0ab", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "optimistic-cancel", + "order": 14, + "title": "낙관적 잠금과 중복 취소의 의미", + "profileId": "professional-jpa", + "prerequisites": [ + "jpa-state", + "tx-proxy" + ], + "lessonPath": "lessons/optimistic-cancel/lesson.json", + "referenceDir": "lessons/optimistic-cancel/reference", + "starterDir": "lessons/optimistic-cancel/starter", + "graderDir": "lessons/optimistic-cancel/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/CancellableOrder.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/CancellationLab.java", + "templatePath": "lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellationLab.java", + "referencePath": "lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellationLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/optimistic-cancel/starter/pom.xml", + "referencePath": "lessons/optimistic-cancel/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "templatePath": "lessons/optimistic-cancel/starter/src/main/resources/META-INF/persistence.xml", + "referencePath": "lessons/optimistic-cancel/reference/src/main/resources/META-INF/persistence.xml", + "editable": false + }, + { + "path": "src/main/java/edu/lab/CancellableOrder.java", + "templatePath": "lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellableOrder.java", + "referencePath": "lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellableOrder.java", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/optimistic-cancel/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "optimistic-cancel-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "e5e66d061683ca073cec3b47255908664a8be1efbe2f2223808aa499c2b23ec0", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "security-csrf", + "order": 15, + "title": "Spring Security의 인증·권한·CSRF", + "profileId": "professional-security", + "prerequisites": [ + "json-validation" + ], + "lessonPath": "lessons/security-csrf/lesson.json", + "referenceDir": "lessons/security-csrf/reference", + "starterDir": "lessons/security-csrf/starter", + "graderDir": "lessons/security-csrf/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/SecurityLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/SecurityLab.java", + "templatePath": "lessons/security-csrf/starter/src/main/java/edu/lab/SecurityLab.java", + "referencePath": "lessons/security-csrf/reference/src/main/java/edu/lab/SecurityLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/security-csrf/starter/pom.xml", + "referencePath": "lessons/security-csrf/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/security-csrf/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "security-csrf-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "cad4dad0bbd2cd397625790a2880f107777c9222fcaf45fa9ef39664f99e47a4", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "oidc-identity", + "order": 16, + "title": "OAuth2·OIDC의 역할과 계정 식별 경계", + "profileId": "professional-oidc-fixture", + "prerequisites": [ + "security-csrf" + ], + "lessonPath": "lessons/oidc-identity/lesson.json", + "referenceDir": "lessons/oidc-identity/reference", + "starterDir": "lessons/oidc-identity/starter", + "graderDir": "lessons/oidc-identity/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/IdentityLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/IdentityLab.java", + "templatePath": "lessons/oidc-identity/starter/src/main/java/edu/lab/IdentityLab.java", + "referencePath": "lessons/oidc-identity/reference/src/main/java/edu/lab/IdentityLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/oidc-identity/starter/pom.xml", + "referencePath": "lessons/oidc-identity/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/oidc-identity/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "oidc-identity-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "efb7ff61b59c5dc1f7d5aa5c28fb1d966c164b72dc6c9af2dda2b586d2335cd0", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "jwt-validation", + "order": 17, + "title": "JWT 서명·발급자·대상·만료 검증", + "profileId": "professional-jwt-local", + "prerequisites": [ + "security-csrf" + ], + "lessonPath": "lessons/jwt-validation/lesson.json", + "referenceDir": "lessons/jwt-validation/reference", + "starterDir": "lessons/jwt-validation/starter", + "graderDir": "lessons/jwt-validation/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/JwtLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/JwtLab.java", + "templatePath": "lessons/jwt-validation/starter/src/main/java/edu/lab/JwtLab.java", + "referencePath": "lessons/jwt-validation/reference/src/main/java/edu/lab/JwtLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/jwt-validation/starter/pom.xml", + "referencePath": "lessons/jwt-validation/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/jwt-validation/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "jwt-validation-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 5, + "starterSatisfied": false, + "sourceFingerprint": "d38b26b60eefc5c0ff1a5de48f9e990ae37322631559da4c24fc6f1e7f4574c4", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "redis-cache", + "order": 18, + "title": "Redis TTL·무효화·키 소유권", + "profileId": "professional-redis", + "prerequisites": [ + "tx-proxy" + ], + "lessonPath": "lessons/redis-cache/lesson.json", + "referenceDir": "lessons/redis-cache/reference", + "starterDir": "lessons/redis-cache/starter", + "graderDir": "lessons/redis-cache/grader", + "sourceStatus": "source-prepared", + "executionVerified": false, + "webPublication": "static-only-after-content-review", + "editableFiles": [ + "src/main/java/edu/lab/StockCache.java" + ], + "exerciseId": "redis-cache-project", + "files": [ + { + "path": "src/main/java/edu/lab/StockCache.java", + "templatePath": "lessons/redis-cache/starter/src/main/java/edu/lab/StockCache.java", + "referencePath": "lessons/redis-cache/reference/src/main/java/edu/lab/StockCache.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/redis-cache/starter/pom.xml", + "referencePath": "lessons/redis-cache/reference/pom.xml", + "editable": false + }, + { + "path": "FIXTURE.md", + "templatePath": "lessons/redis-cache/starter/FIXTURE.md", + "referencePath": "lessons/redis-cache/reference/FIXTURE.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/redis-cache/grader/src/test/java/edu/lab/ContractTest.java" + } + ] + }, + { + "id": "kafka-idempotency", + "order": 19, + "title": "Kafka 재전달과 DB 멱등 처리", + "profileId": "professional-kafka", + "prerequisites": [ + "tx-proxy", + "flyway-migrations" + ], + "lessonPath": "lessons/kafka-idempotency/lesson.json", + "referenceDir": "lessons/kafka-idempotency/reference", + "starterDir": "lessons/kafka-idempotency/starter", + "graderDir": "lessons/kafka-idempotency/grader", + "sourceStatus": "source-prepared", + "executionVerified": false, + "webPublication": "static-only-after-content-review", + "editableFiles": [ + "src/main/java/edu/lab/EventHandler.java" + ], + "exerciseId": "kafka-idempotency-project", + "files": [ + { + "path": "src/main/java/edu/lab/EventHandler.java", + "templatePath": "lessons/kafka-idempotency/starter/src/main/java/edu/lab/EventHandler.java", + "referencePath": "lessons/kafka-idempotency/reference/src/main/java/edu/lab/EventHandler.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/kafka-idempotency/starter/pom.xml", + "referencePath": "lessons/kafka-idempotency/reference/pom.xml", + "editable": false + }, + { + "path": "FIXTURE.md", + "templatePath": "lessons/kafka-idempotency/starter/FIXTURE.md", + "referencePath": "lessons/kafka-idempotency/reference/FIXTURE.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/kafka-idempotency/grader/src/test/java/edu/lab/ContractTest.java" + } + ] + }, + { + "id": "observability", + "order": 20, + "title": "Micrometer 지표와 관측 실패 신호", + "profileId": "professional-observability", + "prerequisites": [ + "spring-di", + "test-collaborators" + ], + "lessonPath": "lessons/observability/lesson.json", + "referenceDir": "lessons/observability/reference", + "starterDir": "lessons/observability/starter", + "graderDir": "lessons/observability/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/OrderTelemetry.java" + ], + "exerciseId": "observability-project", + "files": [ + { + "path": "src/main/java/edu/lab/OrderTelemetry.java", + "templatePath": "lessons/observability/starter/src/main/java/edu/lab/OrderTelemetry.java", + "referencePath": "lessons/observability/reference/src/main/java/edu/lab/OrderTelemetry.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/observability/starter/pom.xml", + "referencePath": "lessons/observability/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/observability/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 1, + "starterSatisfied": false, + "sourceFingerprint": "92e8fff2dd813d24d2a77243bf640196e2bbabcde8ec61f95343ce9e953a3c2e", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "build-gradle-ci", + "order": 21, + "title": "Gradle·Docker·CI의 재현 조건", + "profileId": "professional-gradle-ci", + "prerequisites": [ + "build-maven", + "test-contracts" + ], + "lessonPath": "lessons/build-gradle-ci/lesson.json", + "referenceDir": "lessons/build-gradle-ci/reference", + "starterDir": "lessons/build-gradle-ci/starter", + "graderDir": "lessons/build-gradle-ci/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/BuildMain.java" + ], + "exerciseId": "build-gradle-ci-project", + "files": [ + { + "path": "src/main/java/edu/lab/BuildMain.java", + "templatePath": "lessons/build-gradle-ci/starter/src/main/java/edu/lab/BuildMain.java", + "referencePath": "lessons/build-gradle-ci/reference/src/main/java/edu/lab/BuildMain.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/build-gradle-ci/starter/pom.xml", + "referencePath": "lessons/build-gradle-ci/reference/pom.xml", + "editable": false + }, + { + "path": "settings.gradle", + "templatePath": "lessons/build-gradle-ci/starter/settings.gradle", + "referencePath": "lessons/build-gradle-ci/reference/settings.gradle", + "editable": false + }, + { + "path": "build.gradle", + "templatePath": "lessons/build-gradle-ci/starter/build.gradle", + "referencePath": "lessons/build-gradle-ci/reference/build.gradle", + "editable": false + }, + { + "path": "Dockerfile", + "templatePath": "lessons/build-gradle-ci/starter/Dockerfile", + "referencePath": "lessons/build-gradle-ci/reference/Dockerfile", + "editable": false + }, + { + "path": "deployment-examples/ci.yml", + "templatePath": "lessons/build-gradle-ci/starter/deployment-examples/ci.yml", + "referencePath": "lessons/build-gradle-ci/reference/deployment-examples/ci.yml", + "editable": false + }, + { + "path": "BUILD-COMPARISON.md", + "templatePath": "lessons/build-gradle-ci/starter/BUILD-COMPARISON.md", + "referencePath": "lessons/build-gradle-ci/reference/BUILD-COMPARISON.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/build-gradle-ci/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "62e994695134b10e2f7284eebd74a5673eb2f1f2359028b85b22661f2a25b4ff", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "orders-capstone", + "order": 22, + "title": "주문·예약·취소 통합 계약", + "profileId": "professional-capstone", + "prerequisites": [ + "json-validation", + "tx-proxy", + "security-csrf", + "flyway-migrations" + ], + "lessonPath": "lessons/orders-capstone/lesson.json", + "referenceDir": "lessons/orders-capstone/reference", + "starterDir": "lessons/orders-capstone/starter", + "graderDir": "lessons/orders-capstone/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/CapstoneLab.java" + ], + "exerciseId": "orders-capstone-project", + "files": [ + { + "path": "src/main/java/edu/lab/CapstoneLab.java", + "templatePath": "lessons/orders-capstone/starter/src/main/java/edu/lab/CapstoneLab.java", + "referencePath": "lessons/orders-capstone/reference/src/main/java/edu/lab/CapstoneLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/orders-capstone/starter/pom.xml", + "referencePath": "lessons/orders-capstone/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/application.properties", + "templatePath": "lessons/orders-capstone/starter/src/main/resources/application.properties", + "referencePath": "lessons/orders-capstone/reference/src/main/resources/application.properties", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V1__orders.sql", + "templatePath": "lessons/orders-capstone/starter/src/main/resources/db/migration/V1__orders.sql", + "referencePath": "lessons/orders-capstone/reference/src/main/resources/db/migration/V1__orders.sql", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/orders-capstone/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 6, + "starterSatisfied": false, + "sourceFingerprint": "da15b7d861d2b0be337ec2cb9e5a67f8f72e089b444043a84e821298bf159431", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + } + ] +} diff --git a/curriculum/professional/lessons/build-gradle-ci/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/build-gradle-ci/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..1943ca3 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,10 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void businessContract() { assertThat(BuildMain.total(1)).isEqualTo(19900); assertThat(BuildMain.total(2)).isEqualTo(39800); } + @Test void rejectsInvalidAndOverflow() { + assertThatThrownBy(() -> BuildMain.total(0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> BuildMain.total(Integer.MAX_VALUE)).isInstanceOf(ArithmeticException.class); + } +} diff --git a/curriculum/professional/lessons/build-gradle-ci/lesson.json b/curriculum/professional/lessons/build-gradle-ci/lesson.json new file mode 100644 index 0000000..6665cd2 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/lesson.json @@ -0,0 +1,240 @@ +{ + "id": "build-gradle-ci", + "title": "Gradle·Docker·CI의 재현 조건", + "description": "같은 업무 테스트를 Maven과 Gradle 설정에 연결하고 빌드 결과와 컨테이너 실행 조건을 비교합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "빌드 성공이 배포 환경의 정상 실행까지 의미하려면 어떤 증거가 더 필요한가?", + "동일 업무 합계 계약", + "잘못된 수량·오버플로 거절", + "두 빌드의 테스트 경로 설명" + ], + "theory": [ + { + "heading": "빌드 도구보다 고정된 계약", + "text": "Maven과 Gradle은 의존성·컴파일·테스트 과정을 표현하는 도구입니다. 같은 Java 17 대상과 BOM, 같은 ContractTest를 사용하도록 설정해 도구 차이와 업무 차이를 분리합니다. 설치형 자동 실행은 승인된 Maven 경로만 사용하며 Gradle 비교 설정은 별도 실행 검증 대상입니다." + }, + { + "heading": "CI와 산출물 연결", + "text": "테스트한 커밋에서 만든 산출물을 배포해야 검증 근거가 연결됩니다. CI의 JDK 버전, 테스트 검색, 실패 전파, 아티팩트 보존을 확인합니다. 예시 workflow는 다운로드 프로젝트의 설명 파일이며 저장소 자동 배포 작업으로 설치하지 않습니다." + }, + { + "heading": "컨테이너의 범위", + "text": "Java release 대상과 컨테이너의 실제 JVM을 구분합니다. 예시 Dockerfile은 비특권 사용자로 이미 만든 JAR를 실행하는 작은 CLI 이미지입니다. 이미지 digest·취약점·호스트 플랫폼·실행 자원·비밀 관리까지 검증한 운영 배포 설정은 아닙니다." + } + ], + "example": { + "title": "BuildMain 기준 구현", + "code": "package edu.lab;\npublic final class BuildMain {\n public static int total(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0) throw new IllegalArgumentException(\"quantity\");\n return Math.multiplyExact(19900,quantity);\n // END EXERCISE\n }\n public static void main(String[] args) {\n if (args.length != 1) throw new IllegalArgumentException(\"one quantity argument required\");\n System.out.println(total(Integer.parseInt(args[0])));\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 동일 업무 합계 계약 · 잘못된 수량·오버플로 거절 · 두 빌드의 테스트 경로 설명.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/BuildMain.java", + "language": "java", + "code": "package edu.lab;\npublic final class BuildMain {\n public static int total(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0) throw new IllegalArgumentException(\"quantity\");\n return Math.multiplyExact(19900,quantity);\n // END EXERCISE\n }\n public static void main(String[] args) {\n if (args.length != 1) throw new IllegalArgumentException(\"one quantity argument required\");\n System.out.println(total(Integer.parseInt(args[0])));\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-gradle-ciorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\n" + }, + { + "path": "settings.gradle", + "language": "text", + "code": "rootProject.name = 'build-gradle-ci'\n" + }, + { + "path": "build.gradle", + "language": "text", + "code": "plugins { id 'java' }\ngroup = 'edu.javaedu'\nversion = '0.1.0'\nrepositories { mavenCentral() }\njava { toolchain { languageVersion = JavaLanguageVersion.of(17) } }\ntasks.withType(JavaCompile).configureEach { options.release = 17; options.encoding = 'UTF-8' }\ndependencies {\n testImplementation platform('org.springframework.boot:spring-boot-dependencies:3.5.16')\n testImplementation 'org.junit.jupiter:junit-jupiter'\n testImplementation 'org.assertj:assertj-core'\n testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\nsourceSets {\n test {\n java.srcDirs = ['../grader/src/test/java']\n resources.srcDirs = ['../grader/src/test/resources']\n }\n}\ntest { useJUnitPlatform() }\n" + }, + { + "path": "Dockerfile", + "language": "text", + "code": "# 검토용 학습 예시. 먼저 Maven package로 JAR를 만들고 이미지·플랫폼·digest를 별도 검증하세요.\nFROM eclipse-temurin:21-jre\nWORKDIR /app\nCOPY target/build-gradle-ci-0.1.0.jar /app/app.jar\nUSER 10001:10001\nENTRYPOINT [\"java\",\"-cp\",\"/app/app.jar\",\"edu.lab.BuildMain\"]\nCMD [\"2\"]\n" + }, + { + "path": "deployment-examples/ci.yml", + "language": "text", + "code": "# 학습용 비교 예시이며 실제 .github/workflows 경로에 설치되지 않습니다.\nname: project-contract\non: [push, pull_request]\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-java@v4\n with:\n distribution: temurin\n java-version: '21'\n cache: maven\n - run: mvn -B -ntp -f curriculum/professional/lessons/build-gradle-ci/reference/pom.xml verify\n# 실제 도입 시 저장소 경로·action commit pin·산출물 보존 정책을 검증합니다.\n" + }, + { + "path": "BUILD-COMPARISON.md", + "language": "markdown", + "code": "자동 검사 경로는 Maven입니다. Gradle 설정은 같은 테스트 소스를 읽는 비교 자료이며 설치형에서 Gradle 임의 빌드를 실행하지 않습니다.\nGradle 실행에는 해당 버전과 JDK 17 toolchain 준비가 별도로 필요합니다. Wrapper 바이너리는 포함하지 않습니다.\nDockerfile은 Maven package가 만든 target/build-gradle-ci-0.1.0.jar를 사용합니다. 컨테이너의 JVM은 21이며 컴파일 대상 17과 다릅니다.\nCI 예시 경로는 professional 디렉터리가 저장소 루트 아래 curriculum에 있을 때를 가정합니다. 실제 워크플로, action 고정 commit, 이미지 digest, 취약점 검증과 자동 배포는 수행하지 않았습니다.\n" + } + ] + }, + "quiz": [ + { + "id": "build-gradle-ci-q1", + "question": "Java 17 release로 만든 코드의 컨테이너가 Java 21 JVM을 쓰면 기록해야 할 구분은?", + "options": [ + "모두 Java 17 실행으로 기록한다.", + "컴파일 대상 17과 실제 실행 JVM 21을 각각 기록한다.", + "모두 Java 21 문법으로 작성됐다고 기록한다.", + "JAR 파일만 있으면 JVM 정보는 필요 없다." + ], + "correctIndex": 1, + "explanation": "컴파일 대상은 허용 문법·API·클래스 파일, 실행 JVM은 실제 프로세스 환경입니다. 한 버전 값으로 합치면 검증 범위를 잘못 설명합니다." + }, + { + "id": "build-gradle-ci-q2", + "question": "테스트한 소스와 배포 산출물의 근거를 연결하려면?", + "options": [ + "배포 직전에 개발자 PC에서 임의로 다시 빌드한다.", + "테스트한 커밋·빌드 환경·생성된 산출물 식별을 보존한다.", + "최신이라는 파일 이름만 확인한다.", + "컨테이너를 쓰면 소스 식별은 생략한다." + ], + "correctIndex": 1, + "explanation": "검증 대상과 실행 산출물의 연결이 있어야 실패 분석과 재현이 가능합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "같은 업무 테스트를 Maven과 Gradle 설정에 연결하고 빌드 결과와 컨테이너 실행 조건을 비교합니다.", + "tasks": [ + "BuildMain.total은 19,900원 상품의 양수 수량 합계를 계산하며 오버플로를 거절합니다.", + "Maven과 Gradle 설정이 같은 grader 테스트를 읽는 경로를 설명하세요.", + "설치형 자동 채점은 Maven으로 수행합니다. Gradle·Docker·CI는 제공한 설정의 비교·검토 과제이며 아직 실행 검증 전입니다.", + "Docker 예시는 JAR 생성 후 실행하는 CLI이며 웹 서버·배포 자동화가 아닙니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Gradle Java plugin", + "url": "https://docs.gradle.org/current/userguide/java_plugin.html", + "edition": "Gradle current guide", + "checkedAt": "2026-10-12" + }, + { + "title": "Maven lifecycle", + "url": "https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html", + "edition": "Maven", + "checkedAt": "2026-10-12" + }, + { + "title": "Dockerfile reference", + "url": "https://docs.docker.com/reference/dockerfile/", + "edition": "Docker", + "checkedAt": "2026-10-12" + }, + { + "title": "GitHub Actions Java Maven", + "url": "https://docs.github.com/en/actions/tutorials/build-and-test-code/java-with-maven", + "edition": "GitHub Actions", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Maven", + "Gradle", + "Docker", + "CI" + ], + "exerciseId": "build-gradle-ci-project", + "professionalExercise": { + "id": "build-gradle-ci-project", + "profileId": "professional-gradle-ci", + "mode": "project", + "instructions": [ + "BuildMain.total은 19,900원 상품의 양수 수량 합계를 계산하며 오버플로를 거절합니다.", + "Maven과 Gradle 설정이 같은 grader 테스트를 읽는 경로를 설명하세요.", + "설치형 자동 채점은 Maven으로 수행합니다. Gradle·Docker·CI는 제공한 설정의 비교·검토 과제이며 아직 실행 검증 전입니다.", + "Docker 예시는 JAR 생성 후 실행하는 CLI이며 웹 서버·배포 자동화가 아닙니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/BuildMain.java" + ], + "template": "package edu.lab;\npublic final class BuildMain {\n public static int total(int quantity) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n public static void main(String[] args) {\n if (args.length != 1) throw new IllegalArgumentException(\"one quantity argument required\");\n System.out.println(total(Integer.parseInt(args[0])));\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/BuildMain.java", + "templatePath": "lessons/build-gradle-ci/starter/src/main/java/edu/lab/BuildMain.java", + "referencePath": "lessons/build-gradle-ci/reference/src/main/java/edu/lab/BuildMain.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/build-gradle-ci/starter/pom.xml", + "referencePath": "lessons/build-gradle-ci/reference/pom.xml", + "editable": false + }, + { + "path": "settings.gradle", + "templatePath": "lessons/build-gradle-ci/starter/settings.gradle", + "referencePath": "lessons/build-gradle-ci/reference/settings.gradle", + "editable": false + }, + { + "path": "build.gradle", + "templatePath": "lessons/build-gradle-ci/starter/build.gradle", + "referencePath": "lessons/build-gradle-ci/reference/build.gradle", + "editable": false + }, + { + "path": "Dockerfile", + "templatePath": "lessons/build-gradle-ci/starter/Dockerfile", + "referencePath": "lessons/build-gradle-ci/reference/Dockerfile", + "editable": false + }, + { + "path": "deployment-examples/ci.yml", + "templatePath": "lessons/build-gradle-ci/starter/deployment-examples/ci.yml", + "referencePath": "lessons/build-gradle-ci/reference/deployment-examples/ci.yml", + "editable": false + }, + { + "path": "BUILD-COMPARISON.md", + "templatePath": "lessons/build-gradle-ci/starter/BUILD-COMPARISON.md", + "referencePath": "lessons/build-gradle-ci/reference/BUILD-COMPARISON.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/build-gradle-ci/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "동일 업무 합계 계약", + "잘못된 수량·오버플로 거절", + "두 빌드의 테스트 경로 설명" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "build-maven", + "test-contracts" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "62e994695134b10e2f7284eebd74a5673eb2f1f2359028b85b22661f2a25b4ff", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/BUILD-COMPARISON.md b/curriculum/professional/lessons/build-gradle-ci/reference/BUILD-COMPARISON.md new file mode 100644 index 0000000..c39921f --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/BUILD-COMPARISON.md @@ -0,0 +1,4 @@ +자동 검사 경로는 Maven입니다. Gradle 설정은 같은 테스트 소스를 읽는 비교 자료이며 설치형에서 Gradle 임의 빌드를 실행하지 않습니다. +Gradle 실행에는 해당 버전과 JDK 17 toolchain 준비가 별도로 필요합니다. Wrapper 바이너리는 포함하지 않습니다. +Dockerfile은 Maven package가 만든 target/build-gradle-ci-0.1.0.jar를 사용합니다. 컨테이너의 JVM은 21이며 컴파일 대상 17과 다릅니다. +CI 예시 경로는 professional 디렉터리가 저장소 루트 아래 curriculum에 있을 때를 가정합니다. 실제 워크플로, action 고정 commit, 이미지 digest, 취약점 검증과 자동 배포는 수행하지 않았습니다. diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/Dockerfile b/curriculum/professional/lessons/build-gradle-ci/reference/Dockerfile new file mode 100644 index 0000000..186a54c --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/Dockerfile @@ -0,0 +1,7 @@ +# 검토용 학습 예시. 먼저 Maven package로 JAR를 만들고 이미지·플랫폼·digest를 별도 검증하세요. +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY target/build-gradle-ci-0.1.0.jar /app/app.jar +USER 10001:10001 +ENTRYPOINT ["java","-cp","/app/app.jar","edu.lab.BuildMain"] +CMD ["2"] diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/build.gradle b/curriculum/professional/lessons/build-gradle-ci/reference/build.gradle new file mode 100644 index 0000000..0abadea --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/build.gradle @@ -0,0 +1,19 @@ +plugins { id 'java' } +group = 'edu.javaedu' +version = '0.1.0' +repositories { mavenCentral() } +java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } +tasks.withType(JavaCompile).configureEach { options.release = 17; options.encoding = 'UTF-8' } +dependencies { + testImplementation platform('org.springframework.boot:spring-boot-dependencies:3.5.16') + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.assertj:assertj-core' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} +sourceSets { + test { + java.srcDirs = ['../grader/src/test/java'] + resources.srcDirs = ['../grader/src/test/resources'] + } +} +test { useJUnitPlatform() } diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/deployment-examples/ci.yml b/curriculum/professional/lessons/build-gradle-ci/reference/deployment-examples/ci.yml new file mode 100644 index 0000000..b4e1895 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/deployment-examples/ci.yml @@ -0,0 +1,17 @@ +# 학습용 비교 예시이며 실제 .github/workflows 경로에 설치되지 않습니다. +name: project-contract +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + - run: mvn -B -ntp -f curriculum/professional/lessons/build-gradle-ci/reference/pom.xml verify +# 실제 도입 시 저장소 경로·action commit pin·산출물 보존 정책을 검증합니다. diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/pom.xml b/curriculum/professional/lessons/build-gradle-ci/reference/pom.xml new file mode 100644 index 0000000..046c816 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-gradle-ciorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/settings.gradle b/curriculum/professional/lessons/build-gradle-ci/reference/settings.gradle new file mode 100644 index 0000000..1236141 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'build-gradle-ci' diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/src/main/java/edu/lab/BuildMain.java b/curriculum/professional/lessons/build-gradle-ci/reference/src/main/java/edu/lab/BuildMain.java new file mode 100644 index 0000000..a3dd87a --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/src/main/java/edu/lab/BuildMain.java @@ -0,0 +1,13 @@ +package edu.lab; +public final class BuildMain { + public static int total(int quantity) { + // BEGIN EXERCISE + if (quantity <= 0) throw new IllegalArgumentException("quantity"); + return Math.multiplyExact(19900,quantity); + // END EXERCISE + } + public static void main(String[] args) { + if (args.length != 1) throw new IllegalArgumentException("one quantity argument required"); + System.out.println(total(Integer.parseInt(args[0]))); + } +} diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/BUILD-COMPARISON.md b/curriculum/professional/lessons/build-gradle-ci/starter/BUILD-COMPARISON.md new file mode 100644 index 0000000..c39921f --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/BUILD-COMPARISON.md @@ -0,0 +1,4 @@ +자동 검사 경로는 Maven입니다. Gradle 설정은 같은 테스트 소스를 읽는 비교 자료이며 설치형에서 Gradle 임의 빌드를 실행하지 않습니다. +Gradle 실행에는 해당 버전과 JDK 17 toolchain 준비가 별도로 필요합니다. Wrapper 바이너리는 포함하지 않습니다. +Dockerfile은 Maven package가 만든 target/build-gradle-ci-0.1.0.jar를 사용합니다. 컨테이너의 JVM은 21이며 컴파일 대상 17과 다릅니다. +CI 예시 경로는 professional 디렉터리가 저장소 루트 아래 curriculum에 있을 때를 가정합니다. 실제 워크플로, action 고정 commit, 이미지 digest, 취약점 검증과 자동 배포는 수행하지 않았습니다. diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/Dockerfile b/curriculum/professional/lessons/build-gradle-ci/starter/Dockerfile new file mode 100644 index 0000000..186a54c --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/Dockerfile @@ -0,0 +1,7 @@ +# 검토용 학습 예시. 먼저 Maven package로 JAR를 만들고 이미지·플랫폼·digest를 별도 검증하세요. +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY target/build-gradle-ci-0.1.0.jar /app/app.jar +USER 10001:10001 +ENTRYPOINT ["java","-cp","/app/app.jar","edu.lab.BuildMain"] +CMD ["2"] diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/build.gradle b/curriculum/professional/lessons/build-gradle-ci/starter/build.gradle new file mode 100644 index 0000000..0abadea --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/build.gradle @@ -0,0 +1,19 @@ +plugins { id 'java' } +group = 'edu.javaedu' +version = '0.1.0' +repositories { mavenCentral() } +java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } +tasks.withType(JavaCompile).configureEach { options.release = 17; options.encoding = 'UTF-8' } +dependencies { + testImplementation platform('org.springframework.boot:spring-boot-dependencies:3.5.16') + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.assertj:assertj-core' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} +sourceSets { + test { + java.srcDirs = ['../grader/src/test/java'] + resources.srcDirs = ['../grader/src/test/resources'] + } +} +test { useJUnitPlatform() } diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/deployment-examples/ci.yml b/curriculum/professional/lessons/build-gradle-ci/starter/deployment-examples/ci.yml new file mode 100644 index 0000000..b4e1895 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/deployment-examples/ci.yml @@ -0,0 +1,17 @@ +# 학습용 비교 예시이며 실제 .github/workflows 경로에 설치되지 않습니다. +name: project-contract +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + - run: mvn -B -ntp -f curriculum/professional/lessons/build-gradle-ci/reference/pom.xml verify +# 실제 도입 시 저장소 경로·action commit pin·산출물 보존 정책을 검증합니다. diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/pom.xml b/curriculum/professional/lessons/build-gradle-ci/starter/pom.xml new file mode 100644 index 0000000..046c816 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-gradle-ciorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/settings.gradle b/curriculum/professional/lessons/build-gradle-ci/starter/settings.gradle new file mode 100644 index 0000000..1236141 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'build-gradle-ci' diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/src/main/java/edu/lab/BuildMain.java b/curriculum/professional/lessons/build-gradle-ci/starter/src/main/java/edu/lab/BuildMain.java new file mode 100644 index 0000000..fc79926 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/src/main/java/edu/lab/BuildMain.java @@ -0,0 +1,10 @@ +package edu.lab; +public final class BuildMain { + public static int total(int quantity) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + public static void main(String[] args) { + if (args.length != 1) throw new IllegalArgumentException("one quantity argument required"); + System.out.println(total(Integer.parseInt(args[0]))); + } +} diff --git a/curriculum/professional/lessons/build-maven/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/build-maven/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..e0fb6bf --- /dev/null +++ b/curriculum/professional/lessons/build-maven/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,11 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void normalAndZeroPrice() { assertThat(OrderAmounts.lineTotal(1500, 2)).isEqualTo(3000); assertThat(OrderAmounts.lineTotal(0, 1)).isZero(); } + @Test void invalidInputsAndOverflow() { + assertThatIllegalArgumentException().isThrownBy(() -> OrderAmounts.lineTotal(-1,1)); + assertThatIllegalArgumentException().isThrownBy(() -> OrderAmounts.lineTotal(100,0)); + assertThatExceptionOfType(ArithmeticException.class).isThrownBy(() -> OrderAmounts.lineTotal(Integer.MAX_VALUE,2)); + } +} diff --git a/curriculum/professional/lessons/build-maven/lesson.json b/curriculum/professional/lessons/build-maven/lesson.json new file mode 100644 index 0000000..0ed8977 --- /dev/null +++ b/curriculum/professional/lessons/build-maven/lesson.json @@ -0,0 +1,166 @@ +{ + "id": "build-maven", + "title": "Maven으로 재현 가능한 주문 계산 빌드", + "description": "컴파일 대상, 의존성, 테스트 실행이 빌드에 어떤 계약으로 남는지 확인합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "동일 소스를 다른 환경에서 검증하려면 무엇을 고정해야 하는가?", + "정상 곱셈", + "0원 상품", + "잘못된 수량 거절", + "정수 오버플로 거절" + ], + "theory": [ + { + "heading": "빌드는 실행 조건의 문서", + "text": "JDK 실행 버전과 javac release 대상은 다릅니다. 이 과정은 Java 17 대상 소스와 고정된 Maven 의존성 관리 정보를 제공하며, 실제 실행 JDK는 설치형에서 따로 표시합니다." + }, + { + "heading": "금액 규칙과 빌드 재현성", + "text": "의존성 이름만 적는 것으로 재현성이 완성되지 않습니다. 플러그인·BOM·문자 인코딩·테스트 검색 규칙을 고정하고 네트워크 의존성 준비와 실제 테스트 실행을 구분합니다. 금액 계산에는 음수·0·오버플로 계약이 필요합니다." + } + ], + "example": { + "title": "OrderAmounts 기준 구현", + "code": "package edu.lab;\npublic final class OrderAmounts {\n private OrderAmounts() {}\n public static int lineTotal(int unitPrice, int quantity) {\n // BEGIN EXERCISE\n if (unitPrice < 0 || quantity <= 0) throw new IllegalArgumentException(\"invalid amount\");\n return Math.multiplyExact(unitPrice, quantity);\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 정상 곱셈 · 0원 상품 · 잘못된 수량 거절 · 정수 오버플로 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/OrderAmounts.java", + "language": "java", + "code": "package edu.lab;\npublic final class OrderAmounts {\n private OrderAmounts() {}\n public static int lineTotal(int unitPrice, int quantity) {\n // BEGIN EXERCISE\n if (unitPrice < 0 || quantity <= 0) throw new IllegalArgumentException(\"invalid amount\");\n return Math.multiplyExact(unitPrice, quantity);\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-mavenorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\n" + } + ] + }, + "quiz": [ + { + "id": "build-maven-q1", + "question": "BOM의 dependencyManagement에서 버전을 관리하는 라이브러리를 실제로 쓰려면?", + "options": [ + "BOM을 import하면 관리되는 모든 라이브러리가 자동 추가된다.", + "프로젝트 dependencies에도 해당 의존성을 선언한다.", + "라이브러리 JAR를 소스 폴더에 복사하면 Maven이 자동 인식한다.", + "컴파일 플러그인의 release 값에 라이브러리 이름을 넣는다." + ], + "correctIndex": 1, + "explanation": "BOM의 dependencyManagement는 의존성을 추가하는 것이 아니라 선언될 의존성의 버전과 기본 설정을 관리합니다." + }, + { + "id": "build-maven-q2", + "question": "JDK 21에서 javac --release 17로 컴파일하는 의미는?", + "options": [ + "테스트 JVM이 자동으로 17이 된다.", + "Java 17 대상 언어·클래스 파일·공개 API 범위로 컴파일한다.", + "Java 21 API를 모두 쓸 수 있고 클래스 파일만 17이 된다.", + "네이티브 라이브러리의 모든 호환성이 검증된다." + ], + "correctIndex": 1, + "explanation": "컴파일 대상과 실제 테스트 JVM은 다릅니다. release는 대상 언어·API·클래스 파일을 제한하지만 실제 Java 17 실행 검사를 대신하지 않습니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "컴파일 대상, 의존성, 테스트 실행이 빌드에 어떤 계약으로 남는지 확인합니다.", + "tasks": [ + "OrderAmounts.lineTotal(int unitPrice, int quantity)를 구현하세요.", + "unitPrice는 0 이상, quantity는 1 이상입니다. 위반하면 IllegalArgumentException을 던집니다.", + "int 범위 초과는 Math.multiplyExact를 통해 ArithmeticException으로 알립니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Maven dependency mechanism", + "url": "https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html", + "edition": "Maven 3", + "checkedAt": "2026-10-12" + }, + { + "title": "Java 17 Math.multiplyExact", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Math.html", + "edition": "Java SE 17", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "build-maven-project", + "professionalExercise": { + "id": "build-maven-project", + "profileId": "professional-junit", + "mode": "project", + "instructions": [ + "OrderAmounts.lineTotal(int unitPrice, int quantity)를 구현하세요.", + "unitPrice는 0 이상, quantity는 1 이상입니다. 위반하면 IllegalArgumentException을 던집니다.", + "int 범위 초과는 Math.multiplyExact를 통해 ArithmeticException으로 알립니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/OrderAmounts.java" + ], + "template": "package edu.lab;\npublic final class OrderAmounts {\n private OrderAmounts() {}\n public static int lineTotal(int unitPrice, int quantity) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/OrderAmounts.java", + "templatePath": "lessons/build-maven/starter/src/main/java/edu/lab/OrderAmounts.java", + "referencePath": "lessons/build-maven/reference/src/main/java/edu/lab/OrderAmounts.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/build-maven/starter/pom.xml", + "referencePath": "lessons/build-maven/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/build-maven/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "정상 곱셈", + "0원 상품", + "잘못된 수량 거절", + "정수 오버플로 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "core:oop", + "core:testing-design" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "a9d4d663d97d617409faa0998490cce985e0199084dc3fa55e42351f452d2405", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/build-maven/reference/pom.xml b/curriculum/professional/lessons/build-maven/reference/pom.xml new file mode 100644 index 0000000..b7a83d6 --- /dev/null +++ b/curriculum/professional/lessons/build-maven/reference/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-mavenorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/build-maven/reference/src/main/java/edu/lab/OrderAmounts.java b/curriculum/professional/lessons/build-maven/reference/src/main/java/edu/lab/OrderAmounts.java new file mode 100644 index 0000000..a88ac9e --- /dev/null +++ b/curriculum/professional/lessons/build-maven/reference/src/main/java/edu/lab/OrderAmounts.java @@ -0,0 +1,10 @@ +package edu.lab; +public final class OrderAmounts { + private OrderAmounts() {} + public static int lineTotal(int unitPrice, int quantity) { + // BEGIN EXERCISE + if (unitPrice < 0 || quantity <= 0) throw new IllegalArgumentException("invalid amount"); + return Math.multiplyExact(unitPrice, quantity); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/build-maven/starter/pom.xml b/curriculum/professional/lessons/build-maven/starter/pom.xml new file mode 100644 index 0000000..b7a83d6 --- /dev/null +++ b/curriculum/professional/lessons/build-maven/starter/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-mavenorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/build-maven/starter/src/main/java/edu/lab/OrderAmounts.java b/curriculum/professional/lessons/build-maven/starter/src/main/java/edu/lab/OrderAmounts.java new file mode 100644 index 0000000..02a5573 --- /dev/null +++ b/curriculum/professional/lessons/build-maven/starter/src/main/java/edu/lab/OrderAmounts.java @@ -0,0 +1,7 @@ +package edu.lab; +public final class OrderAmounts { + private OrderAmounts() {} + public static int lineTotal(int unitPrice, int quantity) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/flyway-migrations/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/flyway-migrations/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..1b1e4a0 --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,20 @@ +package edu.lab; +import java.sql.*; +import java.util.UUID; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void appliesOnceAndEnforcesRequestKey() throws Exception { + JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + var flyway = MigrationLab.configured(ds); + assertThat(flyway.migrate().migrationsExecuted).isEqualTo(2); + assertThat(flyway.migrate().migrationsExecuted).isZero(); + try (Connection c = ds.getConnection(); Statement s = c.createStatement()) { + s.executeUpdate("insert into orders(id,quantity,request_key) values('A',1,'req-1')"); + assertThatThrownBy(() -> s.executeUpdate("insert into orders(id,quantity,request_key) values('B',2,'req-1')")) + .isInstanceOf(SQLException.class); + try (ResultSet result = s.executeQuery("select count(*) from orders")) { result.next(); assertThat(result.getInt(1)).isEqualTo(1); } + } + } +} diff --git a/curriculum/professional/lessons/flyway-migrations/lesson.json b/curriculum/professional/lessons/flyway-migrations/lesson.json new file mode 100644 index 0000000..6eb9ae5 --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/lesson.json @@ -0,0 +1,192 @@ +{ + "id": "flyway-migrations", + "title": "Flyway로 스키마 변경을 기록", + "description": "테이블 초기화와 운영 스키마 변경 이력을 구분하고 요청 키의 유일성을 DB 계약으로 남깁니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "애플리케이션 배포와 스키마 변경의 순서를 어떻게 재현하는가?", + "두 버전 적용", + "재실행 시 0개 적용", + "중복 요청 키 거절" + ], + "theory": [ + { + "heading": "버전 있는 변경 이력", + "text": "Flyway는 적용된 버전과 체크섬을 기록합니다. 이미 배포된 버전 파일을 수정하기보다 새 마이그레이션으로 다음 상태를 표현합니다. 재실행 시 이미 적용된 버전은 다시 적용되지 않습니다." + }, + { + "heading": "제약 추가의 데이터 전제", + "text": "UNIQUE를 추가하기 전에 기존 데이터의 중복과 NULL 의미를 확인해야 합니다. 이 과제는 새 학습 DB에 nullable request_key와 UNIQUE를 추가합니다. 실제 서비스의 데이터 정리·대형 테이블 잠금·롤링 배포 전략은 추가 검증이 필요합니다." + }, + { + "heading": "확장 후 전환", + "text": "앱과 DB를 동시에 원자적으로 교체할 수 없는 배포에서는 추가 가능한 스키마를 먼저 배포하고 코드 전환 뒤 제거하는 방식이 유용할 수 있습니다. 모든 DB의 DDL 트랜잭션 동작이 같다는 가정은 피합니다." + } + ], + "example": { + "title": "MigrationLab 기준 구현", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport org.flywaydb.core.Flyway;\npublic final class MigrationLab {\n private MigrationLab() {}\n public static Flyway configured(DataSource source) {\n return Flyway.configure().dataSource(source).locations(\"classpath:db/migration\").load();\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 1개 JUnit 테스트 통과.\n검증 항목: 두 버전 적용 · 재실행 시 0개 적용 · 중복 요청 키 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/MigrationLab.java", + "language": "java", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport org.flywaydb.core.Flyway;\npublic final class MigrationLab {\n private MigrationLab() {}\n public static Flyway configured(DataSource source) {\n return Flyway.configure().dataSource(source).locations(\"classpath:db/migration\").load();\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlflyway-migrationsorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.flywaydbflyway-core\ncom.h2databaseh2\n" + }, + { + "path": "src/main/resources/db/migration/V1__create_orders.sql", + "language": "sql", + "code": "CREATE TABLE orders (id VARCHAR(60) PRIMARY KEY, quantity INT NOT NULL CHECK (quantity > 0));\n" + }, + { + "path": "src/main/resources/db/migration/V2__request_key.sql", + "language": "sql", + "code": "ALTER TABLE orders ADD COLUMN request_key VARCHAR(80);\nALTER TABLE orders ADD CONSTRAINT uq_order_request UNIQUE (request_key);\n" + } + ] + }, + "quiz": [ + { + "id": "flyway-migrations-q1", + "question": "이미 배포되어 적용된 V1 파일의 의미를 바꾸어야 할 때 일반적인 선택은?", + "options": [ + "새 버전 마이그레이션으로 다음 상태를 표현한다.", + "V1 내용을 바꾸고 이력은 무시한다.", + "모든 DB에서 clean을 실행한다.", + "파일 이름만 V1으로 유지하면 체크섬은 무관하다." + ], + "correctIndex": 0, + "explanation": "이미 적용된 변경 이력을 유지하고 다음 버전을 추가해야 다른 환경에서도 같은 순서를 재현할 수 있습니다." + }, + { + "id": "flyway-migrations-q2", + "question": "UNIQUE 제약을 기존 운영 테이블에 추가하기 전에 필요한 확인은?", + "options": [ + "기존 중복·NULL 정책·DDL 잠금과 배포 호환성", + "Java 클래스의 줄 수", + "JSON 필드 순서", + "서비스 포트 번호만" + ], + "correctIndex": 0, + "explanation": "제약 추가는 기존 데이터와 DB의 DDL 특성, 동시 배포 코드에 영향을 받습니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "테이블 초기화와 운영 스키마 변경 이력을 구분하고 요청 키의 유일성을 DB 계약으로 남깁니다.", + "tasks": [ + "V1은 orders 기본 테이블, V2는 request_key 열과 UNIQUE 제약을 추가합니다.", + "서로 다른 주문에 같은 non-null request_key를 저장하면 DB가 거절해야 합니다.", + "같은 DB에 migrate를 다시 실행하면 이미 적용된 마이그레이션 수는 0입니다.", + "학습용 DB만 대상으로 하며 clean 호출을 구현하지 않습니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Flyway Java API", + "url": "https://documentation.red-gate.com/flyway/reference/usage/api-java", + "edition": "Flyway", + "checkedAt": "2026-10-12" + }, + { + "title": "Flyway versioned migrations", + "url": "https://documentation.red-gate.com/flyway/flyway-concepts/migrations/versioned-migrations", + "edition": "Flyway", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Flyway", + "SQL" + ], + "exerciseId": "flyway-migrations-project", + "professionalExercise": { + "id": "flyway-migrations-project", + "profileId": "professional-flyway", + "mode": "project", + "instructions": [ + "V1은 orders 기본 테이블, V2는 request_key 열과 UNIQUE 제약을 추가합니다.", + "서로 다른 주문에 같은 non-null request_key를 저장하면 DB가 거절해야 합니다.", + "같은 DB에 migrate를 다시 실행하면 이미 적용된 마이그레이션 수는 0입니다.", + "학습용 DB만 대상으로 하며 clean 호출을 구현하지 않습니다." + ], + "editableFiles": [ + "src/main/resources/db/migration/V2__request_key.sql" + ], + "template": "package edu.lab;\nimport javax.sql.DataSource;\nimport org.flywaydb.core.Flyway;\npublic final class MigrationLab {\n private MigrationLab() {}\n public static Flyway configured(DataSource source) {\n return Flyway.configure().dataSource(source).locations(\"classpath:db/migration\").load();\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/MigrationLab.java", + "templatePath": "lessons/flyway-migrations/starter/src/main/java/edu/lab/MigrationLab.java", + "referencePath": "lessons/flyway-migrations/reference/src/main/java/edu/lab/MigrationLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/flyway-migrations/starter/pom.xml", + "referencePath": "lessons/flyway-migrations/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V1__create_orders.sql", + "templatePath": "lessons/flyway-migrations/starter/src/main/resources/db/migration/V1__create_orders.sql", + "referencePath": "lessons/flyway-migrations/reference/src/main/resources/db/migration/V1__create_orders.sql", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V2__request_key.sql", + "templatePath": "lessons/flyway-migrations/starter/src/main/resources/db/migration/V2__request_key.sql", + "referencePath": "lessons/flyway-migrations/reference/src/main/resources/db/migration/V2__request_key.sql", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/flyway-migrations/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "두 버전 적용", + "재실행 시 0개 적용", + "중복 요청 키 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "sql-jdbc" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 1, + "starterSatisfied": false, + "sourceFingerprint": "f26b6d4349c223d9c0ee58c03a190193431592dd68baa2cdec3775fcc42ecd6f", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/flyway-migrations/reference/pom.xml b/curriculum/professional/lessons/flyway-migrations/reference/pom.xml new file mode 100644 index 0000000..a802b94 --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlflyway-migrationsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.flywaydbflyway-core +com.h2databaseh2 diff --git a/curriculum/professional/lessons/flyway-migrations/reference/src/main/java/edu/lab/MigrationLab.java b/curriculum/professional/lessons/flyway-migrations/reference/src/main/java/edu/lab/MigrationLab.java new file mode 100644 index 0000000..67c305a --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/reference/src/main/java/edu/lab/MigrationLab.java @@ -0,0 +1,9 @@ +package edu.lab; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +public final class MigrationLab { + private MigrationLab() {} + public static Flyway configured(DataSource source) { + return Flyway.configure().dataSource(source).locations("classpath:db/migration").load(); + } +} diff --git a/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V1__create_orders.sql b/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V1__create_orders.sql new file mode 100644 index 0000000..fe082ab --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V1__create_orders.sql @@ -0,0 +1 @@ +CREATE TABLE orders (id VARCHAR(60) PRIMARY KEY, quantity INT NOT NULL CHECK (quantity > 0)); diff --git a/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V2__request_key.sql b/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V2__request_key.sql new file mode 100644 index 0000000..0b32e30 --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V2__request_key.sql @@ -0,0 +1,2 @@ +ALTER TABLE orders ADD COLUMN request_key VARCHAR(80); +ALTER TABLE orders ADD CONSTRAINT uq_order_request UNIQUE (request_key); diff --git a/curriculum/professional/lessons/flyway-migrations/starter/pom.xml b/curriculum/professional/lessons/flyway-migrations/starter/pom.xml new file mode 100644 index 0000000..a802b94 --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlflyway-migrationsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.flywaydbflyway-core +com.h2databaseh2 diff --git a/curriculum/professional/lessons/flyway-migrations/starter/src/main/java/edu/lab/MigrationLab.java b/curriculum/professional/lessons/flyway-migrations/starter/src/main/java/edu/lab/MigrationLab.java new file mode 100644 index 0000000..67c305a --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/starter/src/main/java/edu/lab/MigrationLab.java @@ -0,0 +1,9 @@ +package edu.lab; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +public final class MigrationLab { + private MigrationLab() {} + public static Flyway configured(DataSource source) { + return Flyway.configure().dataSource(source).locations("classpath:db/migration").load(); + } +} diff --git a/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V1__create_orders.sql b/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V1__create_orders.sql new file mode 100644 index 0000000..fe082ab --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V1__create_orders.sql @@ -0,0 +1 @@ +CREATE TABLE orders (id VARCHAR(60) PRIMARY KEY, quantity INT NOT NULL CHECK (quantity > 0)); diff --git a/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V2__request_key.sql b/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V2__request_key.sql new file mode 100644 index 0000000..3b2a22d --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V2__request_key.sql @@ -0,0 +1,2 @@ +-- request_key 열과 UNIQUE 제약을 추가하세요. +SELECT 1; diff --git a/curriculum/professional/lessons/jpa-state/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/jpa-state/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..b5b8318 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,28 @@ +package edu.lab; +import jakarta.persistence.*; +import org.junit.jupiter.api.*; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + EntityManagerFactory factory; + @BeforeEach void setup() { + factory = JpaLab.open(); + var em = factory.createEntityManager(); em.getTransaction().begin(); + em.persist(new OrderEntity("A", 2)); em.getTransaction().commit(); em.close(); + } + @AfterEach void close() { factory.close(); } + int storedQuantity() { var em = factory.createEntityManager(); try { return em.find(OrderEntity.class,"A").quantity(); } finally { em.close(); } } + @Test void managedChangesAreFlushed() { + var em = factory.createEntityManager(); em.getTransaction().begin(); + em.find(OrderEntity.class,"A").changeQuantity(4); em.getTransaction().commit(); em.close(); + assertThat(storedQuantity()).isEqualTo(4); + } + @Test void detachedChangesAreNotAutomaticallySaved() { + var em = factory.createEntityManager(); OrderEntity detached = em.find(OrderEntity.class,"A"); em.close(); + detached.changeQuantity(7); assertThat(storedQuantity()).isEqualTo(2); + } + @Test void rejectsInvalidTransitionWithoutMutation() { + OrderEntity order = new OrderEntity("B",3); + assertThatThrownBy(() -> order.changeQuantity(0)).isInstanceOf(IllegalArgumentException.class); + assertThat(order.quantity()).isEqualTo(3); + } +} diff --git a/curriculum/professional/lessons/jpa-state/lesson.json b/curriculum/professional/lessons/jpa-state/lesson.json new file mode 100644 index 0000000..296ee4b --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/lesson.json @@ -0,0 +1,199 @@ +{ + "id": "jpa-state", + "title": "JPA·Hibernate의 상태와 변경 감지", + "description": "영속 상태와 분리 상태를 구분하고 공개 setter 대신 업무 메서드로 주문 불변식을 지킵니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "객체의 필드를 바꾼 결과가 언제 DB에 반영되는가?", + "관리 엔티티 변경 감지", + "분리 객체 변경 비반영", + "실패 시 객체 수량 보존" + ], + "theory": [ + { + "heading": "영속성 컨텍스트와 변경 감지", + "text": "EntityManager가 관리하는 엔티티의 변경은 트랜잭션 flush·commit 시 SQL로 동기화될 수 있습니다. 메서드를 호출한 순간과 SQL 실행 순간은 같지 않습니다. 관리 범위를 벗어난 detached 객체를 바꿔도 자동으로 DB에 반영되지 않습니다." + }, + { + "heading": "엔티티와 값 객체의 차이", + "text": "엔티티는 식별자·수명·영속 상태를 갖습니다. 모든 필드를 포함하는 Lombok @Data를 기계적으로 붙이면 변경되는 hashCode, 연관관계 순환, 지연 로딩 접근을 유발할 수 있습니다. 이 예제는 동등성 전략을 무리하게 일반화하지 않고 업무 변경 메서드에 집중합니다." + }, + { + "heading": "캡슐화와 ORM 제약", + "text": "보호된 무인자 생성자는 ORM의 생성 경로이며 일반 호출자의 올바른 생성 경로와 다릅니다. 임의 setter 대신 changeQuantity가 양수 불변식을 지킵니다. DB 제약과 다른 쓰기 경로까지 함께 설계해야 전체 시스템의 불변식을 보장할 수 있습니다." + } + ], + "example": { + "title": "JpaLab 기준 구현", + "code": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class JpaLab {\n private JpaLab() {}\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"orders\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 3개 JUnit 테스트 통과.\n검증 항목: 관리 엔티티 변경 감지 · 분리 객체 변경 비반영 · 실패 시 객체 수량 보존.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/JpaLab.java", + "language": "java", + "code": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class JpaLab {\n private JpaLab() {}\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"orders\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljpa-stateorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.hibernate.ormhibernate-core\njakarta.persistencejakarta.persistence-api\ncom.h2databaseh2\n" + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "language": "xml", + "code": "\n\n \n org.hibernate.jpa.HibernatePersistenceProvideredu.lab.OrderEntity\n true\n \n \n \n \n \n \n \n \n\n" + }, + { + "path": "src/main/java/edu/lab/OrderEntity.java", + "language": "java", + "code": "package edu.lab;\nimport jakarta.persistence.*;\n@Entity\n@Table(name=\"purchase_orders\")\npublic class OrderEntity {\n @Id private String id;\n private int quantity;\n protected OrderEntity() {}\n public OrderEntity(String id, int quantity) {\n if (id == null || id.isBlank()) throw new IllegalArgumentException(\"id\");\n this.id = id; changeQuantity(quantity);\n }\n public String id() { return id; }\n public int quantity() { return quantity; }\n public void changeQuantity(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0) throw new IllegalArgumentException(\"quantity\");\n this.quantity = quantity;\n // END EXERCISE\n }\n}\n" + } + ] + }, + "quiz": [ + { + "id": "jpa-state-q1", + "question": "EntityManager를 닫은 뒤 detached 주문의 수량을 바꾸면?", + "options": [ + "setter 호출만으로 항상 UPDATE된다.", + "모든 엔티티는 JVM 종료까지 관리 상태다.", + "@Entity가 있으면 트랜잭션이 필요 없다.", + "다시 영속화하는 별도 과정 없이는 DB에 자동 반영되지 않는다." + ], + "correctIndex": 3, + "explanation": "변경 감지는 영속성 컨텍스트의 관리 상태에서 이루어집니다. 분리 객체에 대한 일반 자바 변경은 DB에 자동 전달되지 않습니다." + }, + { + "id": "jpa-state-q2", + "question": "엔티티에 모든 필드 기반 equals/hashCode를 적용하기 전에 확인할 사항은?", + "options": [ + "들여쓰기만 확인한다.", + "변경 가능한 필드·연관관계·프록시와 식별자 전략을 검토한다.", + "DB 컬럼 순서만 확인한다.", + "record로 바꾸면 모든 JPA 제약이 없어진다." + ], + "correctIndex": 1, + "explanation": "엔티티의 동등성과 해시 안정성은 저장 전후의 식별자와 객체 수명, 연관관계에 따라 달라집니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "영속 상태와 분리 상태를 구분하고 공개 setter 대신 업무 메서드로 주문 불변식을 지킵니다.", + "tasks": [ + "OrderEntity.changeQuantity는 양수만 허용하고 실패하면 기존 수량을 유지합니다.", + "관리 상태에서 수량을 변경하고 commit한 뒤 새 EntityManager로 읽으면 변경이 보입니다.", + "EntityManager를 닫은 뒤 detached 엔티티를 변경해도 DB 값은 바뀌지 않습니다.", + "create-drop 설정은 폐기 가능한 학습 DB용이며 운영 마이그레이션 정책이 아닙니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Hibernate persistence context", + "url": "https://docs.hibernate.org/orm/6.6/userguide/html_single/#pc", + "edition": "Hibernate ORM 6.6", + "checkedAt": "2026-10-12" + }, + { + "title": "Jakarta Persistence", + "url": "https://jakarta.ee/specifications/persistence/3.1/", + "edition": "Jakarta Persistence 3.1", + "checkedAt": "2026-10-12" + }, + { + "title": "Lombok EqualsAndHashCode", + "url": "https://projectlombok.org/features/EqualsAndHashCode", + "edition": "Lombok", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "JPA", + "Hibernate" + ], + "exerciseId": "jpa-state-project", + "professionalExercise": { + "id": "jpa-state-project", + "profileId": "professional-jpa", + "mode": "project", + "instructions": [ + "OrderEntity.changeQuantity는 양수만 허용하고 실패하면 기존 수량을 유지합니다.", + "관리 상태에서 수량을 변경하고 commit한 뒤 새 EntityManager로 읽으면 변경이 보입니다.", + "EntityManager를 닫은 뒤 detached 엔티티를 변경해도 DB 값은 바뀌지 않습니다.", + "create-drop 설정은 폐기 가능한 학습 DB용이며 운영 마이그레이션 정책이 아닙니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/OrderEntity.java" + ], + "template": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class JpaLab {\n private JpaLab() {}\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"orders\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/JpaLab.java", + "templatePath": "lessons/jpa-state/starter/src/main/java/edu/lab/JpaLab.java", + "referencePath": "lessons/jpa-state/reference/src/main/java/edu/lab/JpaLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/jpa-state/starter/pom.xml", + "referencePath": "lessons/jpa-state/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "templatePath": "lessons/jpa-state/starter/src/main/resources/META-INF/persistence.xml", + "referencePath": "lessons/jpa-state/reference/src/main/resources/META-INF/persistence.xml", + "editable": false + }, + { + "path": "src/main/java/edu/lab/OrderEntity.java", + "templatePath": "lessons/jpa-state/starter/src/main/java/edu/lab/OrderEntity.java", + "referencePath": "lessons/jpa-state/reference/src/main/java/edu/lab/OrderEntity.java", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/jpa-state/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "관리 엔티티 변경 감지", + "분리 객체 변경 비반영", + "실패 시 객체 수량 보존" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "sql-jdbc", + "lombok-values" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "c721dc8a92215ad6b837e104656f0ba8defb5781844f845447f5194a1737332c", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/jpa-state/reference/pom.xml b/curriculum/professional/lessons/jpa-state/reference/pom.xml new file mode 100644 index 0000000..46823d4 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/reference/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljpa-stateorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.hibernate.ormhibernate-core +jakarta.persistencejakarta.persistence-api +com.h2databaseh2 diff --git a/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/JpaLab.java b/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/JpaLab.java new file mode 100644 index 0000000..6bb00cb --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/JpaLab.java @@ -0,0 +1,11 @@ +package edu.lab; +import jakarta.persistence.*; +import java.util.Map; +import java.util.UUID; +public final class JpaLab { + private JpaLab() {} + public static EntityManagerFactory open() { + return Persistence.createEntityManagerFactory("orders", Map.of( + "jakarta.persistence.jdbc.url", "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1")); + } +} diff --git a/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/OrderEntity.java b/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/OrderEntity.java new file mode 100644 index 0000000..7bf6ad9 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/OrderEntity.java @@ -0,0 +1,21 @@ +package edu.lab; +import jakarta.persistence.*; +@Entity +@Table(name="purchase_orders") +public class OrderEntity { + @Id private String id; + private int quantity; + protected OrderEntity() {} + public OrderEntity(String id, int quantity) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("id"); + this.id = id; changeQuantity(quantity); + } + public String id() { return id; } + public int quantity() { return quantity; } + public void changeQuantity(int quantity) { + // BEGIN EXERCISE + if (quantity <= 0) throw new IllegalArgumentException("quantity"); + this.quantity = quantity; + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/jpa-state/reference/src/main/resources/META-INF/persistence.xml b/curriculum/professional/lessons/jpa-state/reference/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..c13eb01 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/reference/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,14 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvideredu.lab.OrderEntity + true + + + + + + + + + diff --git a/curriculum/professional/lessons/jpa-state/starter/pom.xml b/curriculum/professional/lessons/jpa-state/starter/pom.xml new file mode 100644 index 0000000..46823d4 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/starter/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljpa-stateorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.hibernate.ormhibernate-core +jakarta.persistencejakarta.persistence-api +com.h2databaseh2 diff --git a/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/JpaLab.java b/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/JpaLab.java new file mode 100644 index 0000000..6bb00cb --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/JpaLab.java @@ -0,0 +1,11 @@ +package edu.lab; +import jakarta.persistence.*; +import java.util.Map; +import java.util.UUID; +public final class JpaLab { + private JpaLab() {} + public static EntityManagerFactory open() { + return Persistence.createEntityManagerFactory("orders", Map.of( + "jakarta.persistence.jdbc.url", "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1")); + } +} diff --git a/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/OrderEntity.java b/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/OrderEntity.java new file mode 100644 index 0000000..6ec2240 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/OrderEntity.java @@ -0,0 +1,18 @@ +package edu.lab; +import jakarta.persistence.*; +@Entity +@Table(name="purchase_orders") +public class OrderEntity { + @Id private String id; + private int quantity; + protected OrderEntity() {} + public OrderEntity(String id, int quantity) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("id"); + this.id = id; changeQuantity(quantity); + } + public String id() { return id; } + public int quantity() { return quantity; } + public void changeQuantity(int quantity) { + throw new UnsupportedOperationException("양수 불변식을 구현하세요"); + } +} diff --git a/curriculum/professional/lessons/jpa-state/starter/src/main/resources/META-INF/persistence.xml b/curriculum/professional/lessons/jpa-state/starter/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..c13eb01 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/starter/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,14 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvideredu.lab.OrderEntity + true + + + + + + + + + diff --git a/curriculum/professional/lessons/json-validation/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/json-validation/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..54cbc44 --- /dev/null +++ b/curriculum/professional/lessons/json-validation/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,34 @@ +package edu.lab; +import com.fasterxml.jackson.databind.*; +import org.junit.jupiter.api.*; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +class ContractTest { + private LocalValidatorFactoryBean validator; + private MockMvc mvc; + @BeforeEach void setup() { + validator = new LocalValidatorFactoryBean(); validator.afterPropertiesSet(); + ObjectMapper mapper = new ObjectMapper().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mvc = standaloneSetup(new CreateOrderController()).setValidator(validator) + .setMessageConverters(new MappingJackson2HttpMessageConverter(mapper)).build(); + } + @AfterEach void cleanup() { validator.close(); } + private void rejects(String json) throws Exception { + mvc.perform(post("/orders").contentType("application/json").content(json)).andExpect(status().isBadRequest()); + } + @Test void acceptsValidInput() throws Exception { + mvc.perform(post("/orders").contentType("application/json").content("{\"sku\":\"BOOK\",\"quantity\":2}")) + .andExpect(status().isCreated()).andExpect(jsonPath("$.sku").value("BOOK")).andExpect(jsonPath("$.quantity").value(2)); + } + @Test void validatesFields() throws Exception { + rejects("{\"sku\":\"BOOK\",\"quantity\":0}"); rejects("{\"sku\":\" \",\"quantity\":1}"); + rejects("{\"sku\":\"BOOK\"}"); + } + @Test void rejectsMalformedAndUnknownFields() throws Exception { + rejects("{broken"); rejects("{\"sku\":\"BOOK\",\"quantity\":1,\"quanity\":8}"); + } +} diff --git a/curriculum/professional/lessons/json-validation/lesson.json b/curriculum/professional/lessons/json-validation/lesson.json new file mode 100644 index 0000000..50c3840 --- /dev/null +++ b/curriculum/professional/lessons/json-validation/lesson.json @@ -0,0 +1,177 @@ +{ + "id": "json-validation", + "title": "Jackson·Validation의 입력 경계", + "description": "JSON 역직렬화 실패, Bean Validation 실패, 업무 불변식 위반을 구분하고 입력 정책을 고정합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "자료형에 들어온 값은 언제 업무에 사용할 수 있는가?", + "정상 생성 201", + "0 수량·공백 sku 400", + "깨진 JSON·미지 필드 400" + ], + "theory": [ + { + "heading": "세 가지 입력 경계", + "text": "유효한 JSON이라고 해서 유효한 주문은 아닙니다. 파싱·자료형 변환·필드 제약·업무 규칙을 단계별로 구분하면 400 응답과 내부 오류를 혼동하지 않습니다. @Valid는 요청 객체 검증을 MVC 경계에 연결합니다." + }, + { + "heading": "누락과 기본값", + "text": "원시 int의 누락이 0으로 변환되는 설정에서는 @Min(1)이 거절합니다. nullable Integer를 쓰면 @NotNull이 별도로 필요할 수 있습니다. 의도한 부재 의미를 타입과 검증 규칙에 함께 기록합니다." + }, + { + "heading": "호환성과 엄격성", + "text": "알 수 없는 필드를 거절할지 무시할지는 API 정책입니다. 이 예제는 오타를 발견하도록 FAIL_ON_UNKNOWN_PROPERTIES를 명시적으로 켭니다. 이를 모든 API의 보편적 정답으로 적용하면 점진 배포 호환성을 해칠 수 있습니다." + } + ], + "example": { + "title": "CreateOrderController 기준 구현", + "code": "package edu.lab;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotBlank;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class CreateOrderController {\n // BEGIN MODEL\n public record Request(@NotBlank String sku, @Min(1) int quantity) {}\n // END MODEL\n public record Created(String sku, int quantity) {}\n @PostMapping\n @ResponseStatus(HttpStatus.CREATED)\n public Created create(@Valid @RequestBody Request request) {\n // BEGIN EXERCISE\n return new Created(request.sku(), request.quantity());\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 3개 JUnit 테스트 통과.\n검증 항목: 정상 생성 201 · 0 수량·공백 sku 400 · 깨진 JSON·미지 필드 400.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/CreateOrderController.java", + "language": "java", + "code": "package edu.lab;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotBlank;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class CreateOrderController {\n // BEGIN MODEL\n public record Request(@NotBlank String sku, @Min(1) int quantity) {}\n // END MODEL\n public record Created(String sku, int quantity) {}\n @PostMapping\n @ResponseStatus(HttpStatus.CREATED)\n public Created create(@Valid @RequestBody Request request) {\n // BEGIN EXERCISE\n return new Created(request.sku(), request.quantity());\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljson-validationorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-web\norg.springframework.bootspring-boot-starter-validation\norg.springframework.bootspring-boot-starter-testtest\n" + } + ] + }, + "quiz": [ + { + "id": "json-validation-q1", + "question": "이 과제의 quantity가 누락되어 0이 되면 어떻게 처리되는가?", + "options": [ + "@Min(1) 검증으로 400이 된다.", + "정상 수량 1로 자동 보정된다.", + "@Valid가 있으면 모든 값이 허용된다.", + "int는 항상 요청에 존재하므로 누락할 수 없다." + ], + "correctIndex": 0, + "explanation": "요청 객체 생성 후 @Valid가 제약을 검사합니다. int의 0 기본값이 양수 조건에 맞지 않으므로 MVC는 요청을 거절합니다." + }, + { + "id": "json-validation-q2", + "question": "알 수 없는 JSON 필드를 거절하는 정책의 설명으로 맞는 것은?", + "options": [ + "항상 API 호환성이 좋아진다.", + "ObjectMapper 설정과 API 호환성 요구를 함께 결정해야 한다.", + "Bean Validation이 자동으로 모든 미지 필드를 거절한다.", + "HTTP 상태 코드와 무관하므로 문서화할 필요가 없다." + ], + "correctIndex": 1, + "explanation": "JSON 변환 정책과 값 검증은 별개이며 엄격한 필드 정책은 오타 발견과 점진 배포 사이의 선택을 요구합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "JSON 역직렬화 실패, Bean Validation 실패, 업무 불변식 위반을 구분하고 입력 정책을 고정합니다.", + "tasks": [ + "sku는 공백이 아니고 quantity는 1 이상이어야 합니다.", + "POST /orders의 유효한 JSON은 201과 sku·quantity를 반환합니다.", + "잘못된 JSON, 잘못된 필드 값, 알 수 없는 필드는 400입니다.", + "메서드 안의 업무 규칙과 @Valid 요청 검증이 같은 검사라고 가정하지 마세요.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Spring MVC validation", + "url": "https://docs.spring.io/spring-framework/reference/6.2/web/webmvc/mvc-controller/ann-validation.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Jackson databind deserialization features", + "url": "https://github.com/FasterXML/jackson-databind/wiki/Deserialization-Features", + "edition": "Jackson 2", + "checkedAt": "2026-10-12" + }, + { + "title": "Jakarta Validation specification", + "url": "https://jakarta.ee/specifications/bean-validation/3.0/", + "edition": "Jakarta Bean Validation 3.0", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Spring MVC", + "Jackson", + "Validation" + ], + "exerciseId": "json-validation-project", + "professionalExercise": { + "id": "json-validation-project", + "profileId": "professional-spring-web", + "mode": "project", + "instructions": [ + "sku는 공백이 아니고 quantity는 1 이상이어야 합니다.", + "POST /orders의 유효한 JSON은 201과 sku·quantity를 반환합니다.", + "잘못된 JSON, 잘못된 필드 값, 알 수 없는 필드는 400입니다.", + "메서드 안의 업무 규칙과 @Valid 요청 검증이 같은 검사라고 가정하지 마세요." + ], + "editableFiles": [ + "src/main/java/edu/lab/CreateOrderController.java" + ], + "template": "package edu.lab;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotBlank;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class CreateOrderController {\n // BEGIN MODEL\n public record Request(@NotBlank String sku, @Min(1) int quantity) {}\n // END MODEL\n public record Created(String sku, int quantity) {}\n @PostMapping\n @ResponseStatus(HttpStatus.CREATED)\n public Created create(@Valid @RequestBody Request request) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/CreateOrderController.java", + "templatePath": "lessons/json-validation/starter/src/main/java/edu/lab/CreateOrderController.java", + "referencePath": "lessons/json-validation/reference/src/main/java/edu/lab/CreateOrderController.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/json-validation/starter/pom.xml", + "referencePath": "lessons/json-validation/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/json-validation/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "정상 생성 201", + "0 수량·공백 sku 400", + "깨진 JSON·미지 필드 400" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "mvc-rest" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "cb2dd529c781109c07cf6272989e94ab712c38e29484390922d7e38fb4f046b9", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/json-validation/reference/pom.xml b/curriculum/professional/lessons/json-validation/reference/pom.xml new file mode 100644 index 0000000..bf79200 --- /dev/null +++ b/curriculum/professional/lessons/json-validation/reference/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljson-validationorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-testtest diff --git a/curriculum/professional/lessons/json-validation/reference/src/main/java/edu/lab/CreateOrderController.java b/curriculum/professional/lessons/json-validation/reference/src/main/java/edu/lab/CreateOrderController.java new file mode 100644 index 0000000..747282b --- /dev/null +++ b/curriculum/professional/lessons/json-validation/reference/src/main/java/edu/lab/CreateOrderController.java @@ -0,0 +1,21 @@ +package edu.lab; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/orders") +public class CreateOrderController { + // BEGIN MODEL + public record Request(@NotBlank String sku, @Min(1) int quantity) {} + // END MODEL + public record Created(String sku, int quantity) {} + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Created create(@Valid @RequestBody Request request) { + // BEGIN EXERCISE + return new Created(request.sku(), request.quantity()); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/json-validation/starter/pom.xml b/curriculum/professional/lessons/json-validation/starter/pom.xml new file mode 100644 index 0000000..bf79200 --- /dev/null +++ b/curriculum/professional/lessons/json-validation/starter/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljson-validationorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-testtest diff --git a/curriculum/professional/lessons/json-validation/starter/src/main/java/edu/lab/CreateOrderController.java b/curriculum/professional/lessons/json-validation/starter/src/main/java/edu/lab/CreateOrderController.java new file mode 100644 index 0000000..c2480e1 --- /dev/null +++ b/curriculum/professional/lessons/json-validation/starter/src/main/java/edu/lab/CreateOrderController.java @@ -0,0 +1,19 @@ +package edu.lab; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/orders") +public class CreateOrderController { + // BEGIN MODEL + public record Request(@NotBlank String sku, @Min(1) int quantity) {} + // END MODEL + public record Created(String sku, int quantity) {} + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Created create(@Valid @RequestBody Request request) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/jwt-validation/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/jwt-validation/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..be178b4 --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,47 @@ +package edu.lab; +import java.security.*; +import java.security.interfaces.RSAPublicKey; +import java.security.interfaces.RSAPrivateKey; +import java.time.*; +import java.util.List; +import com.nimbusds.jose.jwk.*; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.proc.SecurityContext; +import org.junit.jupiter.api.*; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.*; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + static KeyPair pair; + static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z"); + @BeforeAll static void keys() throws Exception { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); pair = generator.generateKeyPair(); } + JwtDecoder decoder() { return JwtLab.decoder((RSAPublicKey)pair.getPublic(),Clock.fixed(NOW,ZoneOffset.UTC)); } + String token(KeyPair signing, String issuer, String audience, Instant expiry, Instant nbf, String subject) { + RSAKey jwk = new RSAKey.Builder((RSAPublicKey)signing.getPublic()).privateKey((RSAPrivateKey)signing.getPrivate()).keyID("training").build(); + NimbusJwtEncoder encoder = new NimbusJwtEncoder(new ImmutableJWKSet(new JWKSet(jwk))); + var claims = JwtClaimsSet.builder().issuer(issuer).issuedAt(NOW.minusSeconds(120)); + if (audience != null) claims.audience(List.of(audience)); + if (expiry != null) claims.expiresAt(expiry); if (nbf != null) claims.notBefore(nbf); if (subject != null) claims.subject(subject); + return encoder.encode(JwtEncoderParameters.from(JwsHeader.with(SignatureAlgorithm.RS256).keyId("training").build(),claims.build())).getTokenValue(); + } + String valid() { return token(pair,JwtLab.ISSUER,"orders-api",NOW.plusSeconds(60),NOW.minusSeconds(1),"buyer-A"); } + @Test void acceptsValid() { assertThat(decoder().decode(valid()).getSubject()).isEqualTo("buyer-A"); } + @Test void rejectsIssuerAndAudience() { + assertThatThrownBy(() -> decoder().decode(token(pair,"https://other.invalid","orders-api",NOW.plusSeconds(60),null,"A"))).isInstanceOf(JwtException.class); + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"another-api",NOW.plusSeconds(60),null,"A"))).isInstanceOf(JwtException.class); + } + @Test void rejectsTimeAndMissingClaims() { + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"orders-api",NOW.minusSeconds(1),null,"A"))).isInstanceOf(JwtException.class); + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"orders-api",NOW.plusSeconds(60),NOW.plusSeconds(10),"A"))).isInstanceOf(JwtException.class); + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"orders-api",null,null,"A"))).isInstanceOf(JwtException.class); + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"orders-api",NOW.plusSeconds(60),null,null))).isInstanceOf(JwtException.class); + } + @Test void rejectsMissingAudienceAndExactExpiry() { + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,null,NOW.plusSeconds(60),null,"A"))).isInstanceOf(JwtException.class); + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"orders-api",NOW,null,"A"))).isInstanceOf(JwtException.class); + } + @Test void rejectsWrongKey() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); KeyPair other = generator.generateKeyPair(); + assertThatThrownBy(() -> decoder().decode(token(other,JwtLab.ISSUER,"orders-api",NOW.plusSeconds(60),null,"A"))).isInstanceOf(JwtException.class); + } +} diff --git a/curriculum/professional/lessons/jwt-validation/lesson.json b/curriculum/professional/lessons/jwt-validation/lesson.json new file mode 100644 index 0000000..7c8c1df --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/lesson.json @@ -0,0 +1,174 @@ +{ + "id": "jwt-validation", + "title": "JWT 서명·발급자·대상·만료 검증", + "description": "서명 성공과 토큰 수용을 분리하고 발급자·대상·시간·필수 claim을 독립적으로 거절합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "서명이 맞는 토큰이라도 우리 API가 받아들이면 안 되는 경우는?", + "정상 토큰 수용", + "issuer·audience 불일치 거절", + "만료·미래 nbf 거절", + "다른 키 서명 거절", + "필수 claim 누락 거절" + ], + "theory": [ + { + "heading": "서명과 수용 정책", + "text": "JWT 서명 검증은 허용된 키와 알고리즘으로 무결성을 확인하는 절차입니다. 같은 키로 발급된 토큰이라도 다른 API 대상이거나 다른 issuer, 만료된 토큰이면 수용하면 안 됩니다. claim을 디코딩하는 것만으로 검증이 완료되지 않습니다." + }, + { + "heading": "시간과 테스트 재현성", + "text": "시간 기반 검사는 Clock을 주입해 경계를 재현합니다. 실제 운영에서는 clock skew 정책과 시스템 시간 동기화를 고려해야 하지만 이 과제는 정확한 비교를 위해 허용 오차를 0으로 고정합니다. exp가 없으면 시간 검증이 충분하다고 가정하지 않고 필수 claim을 별도로 검사합니다." + }, + { + "heading": "키 수명과 로그", + "text": "테스트는 실행 중 생성한 RSA 키를 사용하며 외부 키 서버를 호출하지 않습니다. 운영 키 배포·회전·폐기·토큰 재사용 대응을 보장하지 않습니다. 토큰 원문과 개인 claim을 로그에 남기는 대신 거절 유형을 최소한으로 관측합니다." + } + ], + "example": { + "title": "JwtLab 기준 구현", + "code": "package edu.lab;\nimport java.security.interfaces.RSAPublicKey;\nimport java.time.Clock;\nimport java.time.Duration;\nimport org.springframework.security.oauth2.core.*;\nimport org.springframework.security.oauth2.jwt.*;\npublic final class JwtLab {\n public static final String ISSUER = \"https://training-issuer.invalid\";\n public static JwtDecoder decoder(RSAPublicKey publicKey, Clock clock) {\n // BEGIN EXERCISE\n NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();\n JwtTimestampValidator time = new JwtTimestampValidator(Duration.ZERO); time.setClock(clock);\n OAuth2TokenValidator required = jwt -> {\n boolean valid = jwt.getAudience() != null && jwt.getAudience().contains(\"orders-api\")\n && jwt.getExpiresAt() != null && jwt.getExpiresAt().isAfter(clock.instant())\n && jwt.getSubject() != null && !jwt.getSubject().isBlank();\n return valid ? OAuth2TokenValidatorResult.success()\n : OAuth2TokenValidatorResult.failure(new OAuth2Error(\"invalid_token\",\"required claims missing or wrong\",null));\n };\n decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(time, new JwtIssuerValidator(ISSUER), required));\n return decoder;\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 5개 JUnit 테스트 통과.\n검증 항목: 정상 토큰 수용 · issuer·audience 불일치 거절 · 만료·미래 nbf 거절 · 다른 키 서명 거절 · 필수 claim 누락 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/JwtLab.java", + "language": "java", + "code": "package edu.lab;\nimport java.security.interfaces.RSAPublicKey;\nimport java.time.Clock;\nimport java.time.Duration;\nimport org.springframework.security.oauth2.core.*;\nimport org.springframework.security.oauth2.jwt.*;\npublic final class JwtLab {\n public static final String ISSUER = \"https://training-issuer.invalid\";\n public static JwtDecoder decoder(RSAPublicKey publicKey, Clock clock) {\n // BEGIN EXERCISE\n NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();\n JwtTimestampValidator time = new JwtTimestampValidator(Duration.ZERO); time.setClock(clock);\n OAuth2TokenValidator required = jwt -> {\n boolean valid = jwt.getAudience() != null && jwt.getAudience().contains(\"orders-api\")\n && jwt.getExpiresAt() != null && jwt.getExpiresAt().isAfter(clock.instant())\n && jwt.getSubject() != null && !jwt.getSubject().isBlank();\n return valid ? OAuth2TokenValidatorResult.success()\n : OAuth2TokenValidatorResult.failure(new OAuth2Error(\"invalid_token\",\"required claims missing or wrong\",null));\n };\n decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(time, new JwtIssuerValidator(ISSUER), required));\n return decoder;\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljwt-validationorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.securityspring-security-oauth2-jose\norg.springframework.securityspring-security-oauth2-resource-server\n" + } + ] + }, + "quiz": [ + { + "id": "jwt-validation-q1", + "question": "서명은 유효하지만 audience가 다른 API인 JWT의 처리로 맞는 것은?", + "options": [ + "서명만 맞으면 항상 수용한다.", + "현재 API의 수용 정책에 따라 거절한다.", + "subject가 있으면 audience는 무시한다.", + "Base64 디코딩에 성공하면 수용한다." + ], + "correctIndex": 1, + "explanation": "토큰 수용은 무결성 외에 현재 API의 발급자·대상·시간 정책을 모두 충족해야 합니다." + }, + { + "id": "jwt-validation-q2", + "question": "만료 검증 테스트를 재현 가능하게 만드는 설계는?", + "options": [ + "현재 시스템 시간에 맞춰 테스트 상수를 매일 바꾼다.", + "Clock을 주입하고 고정 시각에서 경계를 검사한다.", + "테스트에서 시간 검증을 제거한다.", + "만료를 충분히 미래로 두고 실패 검사를 생략한다." + ], + "correctIndex": 1, + "explanation": "Clock 주입은 만료·not-before 경계를 실제 시간 경과와 분리해 재현합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "서명 성공과 토큰 수용을 분리하고 발급자·대상·시간·필수 claim을 독립적으로 거절합니다.", + "tasks": [ + "RS256과 주어진 RSA 공개키로 서명을 검증합니다.", + "issuer=https://training-issuer.invalid, audience=orders-api, nonblank subject, exp가 모두 필요합니다.", + "만료·미래 nbf·잘못된 issuer·audience·서명은 거절합니다.", + "Clock을 외부에서 받아 고정 시간으로 테스트하며 실제 개인키를 파일에 저장하지 않습니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Spring Security JWT resource server", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/oauth2/resource-server/jwt.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "JSON Web Token RFC 7519", + "url": "https://www.rfc-editor.org/rfc/rfc7519", + "edition": "RFC 7519", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "JWT", + "Spring Security" + ], + "exerciseId": "jwt-validation-project", + "professionalExercise": { + "id": "jwt-validation-project", + "profileId": "professional-jwt-local", + "mode": "project", + "instructions": [ + "RS256과 주어진 RSA 공개키로 서명을 검증합니다.", + "issuer=https://training-issuer.invalid, audience=orders-api, nonblank subject, exp가 모두 필요합니다.", + "만료·미래 nbf·잘못된 issuer·audience·서명은 거절합니다.", + "Clock을 외부에서 받아 고정 시간으로 테스트하며 실제 개인키를 파일에 저장하지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/JwtLab.java" + ], + "template": "package edu.lab;\nimport java.security.interfaces.RSAPublicKey;\nimport java.time.Clock;\nimport java.time.Duration;\nimport org.springframework.security.oauth2.core.*;\nimport org.springframework.security.oauth2.jwt.*;\npublic final class JwtLab {\n public static final String ISSUER = \"https://training-issuer.invalid\";\n public static JwtDecoder decoder(RSAPublicKey publicKey, Clock clock) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/JwtLab.java", + "templatePath": "lessons/jwt-validation/starter/src/main/java/edu/lab/JwtLab.java", + "referencePath": "lessons/jwt-validation/reference/src/main/java/edu/lab/JwtLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/jwt-validation/starter/pom.xml", + "referencePath": "lessons/jwt-validation/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/jwt-validation/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "정상 토큰 수용", + "issuer·audience 불일치 거절", + "만료·미래 nbf 거절", + "다른 키 서명 거절", + "필수 claim 누락 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "security-csrf" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 5, + "starterSatisfied": false, + "sourceFingerprint": "d38b26b60eefc5c0ff1a5de48f9e990ae37322631559da4c24fc6f1e7f4574c4", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/jwt-validation/reference/pom.xml b/curriculum/professional/lessons/jwt-validation/reference/pom.xml new file mode 100644 index 0000000..cb9c78a --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljwt-validationorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.securityspring-security-oauth2-jose +org.springframework.securityspring-security-oauth2-resource-server diff --git a/curriculum/professional/lessons/jwt-validation/reference/src/main/java/edu/lab/JwtLab.java b/curriculum/professional/lessons/jwt-validation/reference/src/main/java/edu/lab/JwtLab.java new file mode 100644 index 0000000..48f3e1e --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/reference/src/main/java/edu/lab/JwtLab.java @@ -0,0 +1,24 @@ +package edu.lab; +import java.security.interfaces.RSAPublicKey; +import java.time.Clock; +import java.time.Duration; +import org.springframework.security.oauth2.core.*; +import org.springframework.security.oauth2.jwt.*; +public final class JwtLab { + public static final String ISSUER = "https://training-issuer.invalid"; + public static JwtDecoder decoder(RSAPublicKey publicKey, Clock clock) { + // BEGIN EXERCISE + NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build(); + JwtTimestampValidator time = new JwtTimestampValidator(Duration.ZERO); time.setClock(clock); + OAuth2TokenValidator required = jwt -> { + boolean valid = jwt.getAudience() != null && jwt.getAudience().contains("orders-api") + && jwt.getExpiresAt() != null && jwt.getExpiresAt().isAfter(clock.instant()) + && jwt.getSubject() != null && !jwt.getSubject().isBlank(); + return valid ? OAuth2TokenValidatorResult.success() + : OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token","required claims missing or wrong",null)); + }; + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(time, new JwtIssuerValidator(ISSUER), required)); + return decoder; + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/jwt-validation/starter/pom.xml b/curriculum/professional/lessons/jwt-validation/starter/pom.xml new file mode 100644 index 0000000..cb9c78a --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljwt-validationorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.securityspring-security-oauth2-jose +org.springframework.securityspring-security-oauth2-resource-server diff --git a/curriculum/professional/lessons/jwt-validation/starter/src/main/java/edu/lab/JwtLab.java b/curriculum/professional/lessons/jwt-validation/starter/src/main/java/edu/lab/JwtLab.java new file mode 100644 index 0000000..9226883 --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/starter/src/main/java/edu/lab/JwtLab.java @@ -0,0 +1,12 @@ +package edu.lab; +import java.security.interfaces.RSAPublicKey; +import java.time.Clock; +import java.time.Duration; +import org.springframework.security.oauth2.core.*; +import org.springframework.security.oauth2.jwt.*; +public final class JwtLab { + public static final String ISSUER = "https://training-issuer.invalid"; + public static JwtDecoder decoder(RSAPublicKey publicKey, Clock clock) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/kafka-idempotency/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/kafka-idempotency/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..534aecc --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,70 @@ +package edu.lab; +import java.sql.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.TimeUnit; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.*; +import org.apache.kafka.clients.admin.*; +import org.apache.kafka.clients.consumer.*; +import org.apache.kafka.clients.producer.*; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.serialization.*; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + JdbcDataSource ds; EventHandler handler; + @BeforeEach void database() throws Exception { + ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + try (Connection c = ds.getConnection(); Statement s = c.createStatement()) { + s.execute("create table orders(id varchar(60) primary key, handled_count int not null)"); + s.execute("create table processed_events(event_id varchar(80) primary key, order_id varchar(60) not null)"); + s.execute("insert into orders values('A',0),('B',0)"); + } + handler = new EventHandler(ds); + } + int count(String sql) throws Exception { try (Connection c=ds.getConnection(); Statement s=c.createStatement(); ResultSet r=s.executeQuery(sql)) { r.next(); return r.getInt(1); } } + @Test void duplicatesAndFailuresAreAtomic() throws Exception { + assertThat(handler.handle("evt-1","A")).isTrue(); assertThat(handler.handle("evt-1","A")).isFalse(); + assertThatThrownBy(() -> handler.handle("evt-1","B")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> handler.handle("evt-missing","MISSING")).isInstanceOf(SQLException.class); + assertThat(count("select count(*) from processed_events")).isEqualTo(1); + assertThat(count("select handled_count from orders where id='A'")).isEqualTo(1); + assertThat(count("select handled_count from orders where id='B'")).isZero(); + } + @Test @Timeout(90) void duplicateKafkaPayloadHasOneDbEffect() throws Exception { + String brokers = System.getenv("JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS"); + if (brokers == null || !brokers.matches("(127\\.0\\.0\\.1|localhost):[0-9]{1,5}")) throw new IllegalStateException("학습 전용 loopback Kafka 환경이 필요합니다."); + int port = Integer.parseInt(brokers.substring(brokers.lastIndexOf(':')+1)); + if (port < 1 || port > 65535) throw new IllegalStateException("invalid port"); + String topic = "java-edu-" + UUID.randomUUID(); boolean created = false; + Properties adminProps = new Properties(); adminProps.put("bootstrap.servers",brokers); adminProps.put("default.api.timeout.ms","10000"); + try (Admin admin = Admin.create(adminProps)) { + try { + admin.createTopics(List.of(new NewTopic(topic,1,(short)1))).all().get(10,TimeUnit.SECONDS); created = true; + Properties producerProps = new Properties(); producerProps.put("bootstrap.servers",brokers); + producerProps.put("key.serializer",StringSerializer.class.getName()); producerProps.put("value.serializer",StringSerializer.class.getName()); + producerProps.put("max.block.ms","10000"); producerProps.put("request.timeout.ms","5000"); producerProps.put("delivery.timeout.ms","10000"); + try (KafkaProducer producer = new KafkaProducer<>(producerProps)) { + producer.send(new ProducerRecord<>(topic,"evt-kafka","evt-kafka|A")).get(10,TimeUnit.SECONDS); + producer.send(new ProducerRecord<>(topic,"evt-kafka","evt-kafka|A")).get(10,TimeUnit.SECONDS); + } + Properties consumerProps = new Properties(); consumerProps.put("bootstrap.servers",brokers); consumerProps.put("group.id",topic); + consumerProps.put("key.deserializer",StringDeserializer.class.getName()); consumerProps.put("value.deserializer",StringDeserializer.class.getName()); + consumerProps.put("enable.auto.commit","false"); consumerProps.put("default.api.timeout.ms","10000"); + try (KafkaConsumer consumer = new KafkaConsumer<>(consumerProps)) { + var partition = new TopicPartition(topic,0); consumer.assign(List.of(partition)); consumer.seekToBeginning(List.of(partition)); + int received=0; long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(15); + while (received < 2 && System.nanoTime() < deadline) { + for (var record : consumer.poll(Duration.ofMillis(500))) { + String[] fields = record.value().split("\\|",-1); + if (fields.length != 2) throw new IllegalArgumentException("fixture payload"); + handler.handle(fields[0],fields[1]); received++; + } + } + assertThat(received).isEqualTo(2); consumer.commitSync(); + } + assertThat(count("select handled_count from orders where id='A'")).isEqualTo(1); + } finally { if (created) admin.deleteTopics(List.of(topic)).all().get(10,TimeUnit.SECONDS); } + } + } +} diff --git a/curriculum/professional/lessons/kafka-idempotency/lesson.json b/curriculum/professional/lessons/kafka-idempotency/lesson.json new file mode 100644 index 0000000..09de21b --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/lesson.json @@ -0,0 +1,181 @@ +{ + "id": "kafka-idempotency", + "title": "Kafka 재전달과 DB 멱등 처리", + "description": "Kafka 전송과 DB 반영을 분리하고 같은 이벤트의 중복 처리 및 커밋 사이의 장애 구간을 설명합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "메시지를 다시 받더라도 DB 효과를 한 번만 적용하려면?", + "중복 이벤트 효과 한 번", + "DB 실패 시 이벤트 기록 롤백", + "다른 payload 중복 ID 거절", + "Kafka 전송 후 수동 오프셋 커밋" + ], + "theory": [ + { + "heading": "전달 횟수와 업무 효과", + "text": "메시지가 한 번 보였다는 사실과 업무 상태가 한 번 변경됐다는 사실은 다릅니다. 이 과제는 같은 eventId를 두 메시지로 전송하여 재전달 가능성을 모델링합니다. 브로커 장애나 프로세스 강제 종료 복구를 직접 재현한 검사는 아닙니다." + }, + { + "heading": "DB 멱등 처리", + "text": "처리한 eventId의 UNIQUE 기록과 업무 UPDATE를 같은 DB 트랜잭션에 둡니다. 중복 이벤트는 이전 payload와 일치하는지 확인한 뒤 효과를 반복하지 않습니다. 같은 ID의 다른 payload는 조용히 무시하지 않고 계약 위반으로 거절합니다." + }, + { + "heading": "오프셋과 DB 사이", + "text": "DB 커밋 뒤 오프셋을 커밋하면 두 커밋 사이 장애에서 중복 전달이 가능하므로 DB 멱등 처리가 필요합니다. Kafka 트랜잭션만으로 임의의 외부 DB까지 자동으로 exactly-once가 되는 것은 아닙니다. outbox·재시도·독성 메시지 처리 정책은 후속 설계 비교 대상입니다." + } + ], + "example": { + "title": "EventHandler 기준 구현", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\npublic final class EventHandler {\n private final DataSource source;\n public EventHandler(DataSource source) { this.source = java.util.Objects.requireNonNull(source); }\n public boolean handle(String eventId, String orderId) throws SQLException {\n // BEGIN EXERCISE\n if (eventId == null || eventId.isBlank() || orderId == null || orderId.isBlank()) throw new IllegalArgumentException(\"event identity\");\n try (Connection c = source.getConnection()) {\n c.setAutoCommit(false);\n try {\n try (PreparedStatement s = c.prepareStatement(\"insert into processed_events(event_id,order_id) values(?,?)\")) {\n s.setString(1,eventId); s.setString(2,orderId); s.executeUpdate();\n }\n try (PreparedStatement s = c.prepareStatement(\"update orders set handled_count=handled_count+1 where id=?\")) {\n s.setString(1,orderId); if (s.executeUpdate() != 1) throw new SQLException(\"unknown order\",\"02000\");\n }\n c.commit(); return true;\n } catch (SQLException failure) {\n try { c.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); throw failure; }\n if (!\"23505\".equals(failure.getSQLState())) throw failure;\n try (PreparedStatement s = c.prepareStatement(\"select order_id from processed_events where event_id=?\")) {\n s.setString(1,eventId);\n try (ResultSet result = s.executeQuery()) {\n if (result.next() && orderId.equals(result.getString(1))) return false;\n throw new IllegalArgumentException(\"same eventId with different payload\",failure);\n }\n }\n }\n }\n // END EXERCISE\n }\n}\n", + "output": "예상 검증 항목: 중복 이벤트 효과 한 번 · DB 실패 시 이벤트 기록 롤백 · 다른 payload 중복 ID 거절 · Kafka 전송 후 수동 오프셋 커밋. 아직 실행하지 않았습니다.", + "explanation": "기준 구현과 테스트 소스는 공개 학습 자료입니다. 실제 실행·의존성 해석·설치형 통합 검증 결과는 별도 증거로 표시합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/EventHandler.java", + "language": "java", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\npublic final class EventHandler {\n private final DataSource source;\n public EventHandler(DataSource source) { this.source = java.util.Objects.requireNonNull(source); }\n public boolean handle(String eventId, String orderId) throws SQLException {\n // BEGIN EXERCISE\n if (eventId == null || eventId.isBlank() || orderId == null || orderId.isBlank()) throw new IllegalArgumentException(\"event identity\");\n try (Connection c = source.getConnection()) {\n c.setAutoCommit(false);\n try {\n try (PreparedStatement s = c.prepareStatement(\"insert into processed_events(event_id,order_id) values(?,?)\")) {\n s.setString(1,eventId); s.setString(2,orderId); s.executeUpdate();\n }\n try (PreparedStatement s = c.prepareStatement(\"update orders set handled_count=handled_count+1 where id=?\")) {\n s.setString(1,orderId); if (s.executeUpdate() != 1) throw new SQLException(\"unknown order\",\"02000\");\n }\n c.commit(); return true;\n } catch (SQLException failure) {\n try { c.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); throw failure; }\n if (!\"23505\".equals(failure.getSQLState())) throw failure;\n try (PreparedStatement s = c.prepareStatement(\"select order_id from processed_events where event_id=?\")) {\n s.setString(1,eventId);\n try (ResultSet result = s.executeQuery()) {\n if (result.next() && orderId.equals(result.getString(1))) return false;\n throw new IllegalArgumentException(\"same eventId with different payload\",failure);\n }\n }\n }\n }\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlkafka-idempotencyorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.apache.kafkakafka-clients\ncom.h2databaseh2\n" + }, + { + "path": "FIXTURE.md", + "language": "markdown", + "code": "전용 로컬 Kafka 단일 브로커가 필요합니다. 예시 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:19092. localhost도 허용하며 외부 호스트는 거절합니다.\n브로커 advertised.listeners도 같은 loopback 주소를 광고해야 합니다. 실제 Docker 기동과 이미지 버전 검증은 별도 환경 준비 단계입니다.\n테스트는 java-edu- 토픽 한 개와 동일 이름 consumer group을 생성하고 자신이 만든 토픽만 삭제합니다. 업무 클러스터를 연결하지 마세요.\n중복 payload 두 건 전송은 재전달 가능성의 모델입니다. 브로커 장애 복구나 정확히 한 번의 분산 처리를 검증한 결과가 아닙니다.\n" + } + ] + }, + "quiz": [ + { + "id": "kafka-idempotency-q1", + "question": "DB 커밋 후 Kafka 오프셋 커밋 전에 프로세스가 멈추면?", + "options": [ + "DB 커밋도 자동으로 되돌아간다.", + "Kafka가 임의의 외부 DB를 자동 조정한다.", + "메시지는 어떤 경우에도 다시 전달되지 않는다.", + "같은 메시지가 다시 전달될 수 있어 DB 멱등 처리가 필요하다." + ], + "correctIndex": 3, + "explanation": "DB와 오프셋은 별도의 커밋입니다. 중간 장애로 다시 받은 메시지가 DB 효과를 반복하지 않도록 설계해야 합니다." + }, + { + "id": "kafka-idempotency-q2", + "question": "같은 eventId로 서로 다른 orderId가 오면 이 과제는 어떻게 처리하는가?", + "options": [ + "먼저 받은 eventId가 있으므로 무조건 성공한다.", + "두 주문에 모두 적용한다.", + "payload 충돌을 계약 위반으로 거절한다.", + "eventId를 새로 만들어 조용히 재처리한다." + ], + "correctIndex": 2, + "explanation": "멱등 키가 같다는 이유만으로 의미가 다른 요청을 동일 요청으로 수용해서는 안 됩니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "Kafka 전송과 DB 반영을 분리하고 같은 이벤트의 중복 처리 및 커밋 사이의 장애 구간을 설명합니다.", + "tasks": [ + "handle(eventId, orderId)는 첫 정상 적용에 true, 동일 payload의 중복 적용에는 false를 반환합니다.", + "processed_events 기록과 orders.handled_count 증가는 같은 DB 트랜잭션입니다.", + "없는 주문이나 같은 eventId의 다른 orderId는 거절하고 부분 변경을 남기지 않습니다.", + "실제 전송 검사는 전용 loopback Kafka와 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS가 필요하며 자원 미준비 시 실행되지 않습니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "KafkaConsumer Java API", + "url": "https://kafka.apache.org/39/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html", + "edition": "Kafka 3.9", + "checkedAt": "2026-10-12" + }, + { + "title": "Apache Kafka quick start", + "url": "https://kafka.apache.org/39/getting-started/quickstart/", + "edition": "Kafka 3.9", + "checkedAt": "2026-10-12" + }, + { + "title": "JDBC transactions", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.sql/java/sql/Connection.html", + "edition": "Java SE 17", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Kafka", + "Idempotency", + "Transaction" + ], + "exerciseId": "kafka-idempotency-project", + "professionalExercise": { + "id": "kafka-idempotency-project", + "profileId": "professional-kafka", + "mode": "project", + "instructions": [ + "handle(eventId, orderId)는 첫 정상 적용에 true, 동일 payload의 중복 적용에는 false를 반환합니다.", + "processed_events 기록과 orders.handled_count 증가는 같은 DB 트랜잭션입니다.", + "없는 주문이나 같은 eventId의 다른 orderId는 거절하고 부분 변경을 남기지 않습니다.", + "실제 전송 검사는 전용 loopback Kafka와 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS가 필요하며 자원 미준비 시 실행되지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/EventHandler.java" + ], + "template": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\npublic final class EventHandler {\n private final DataSource source;\n public EventHandler(DataSource source) { this.source = java.util.Objects.requireNonNull(source); }\n public boolean handle(String eventId, String orderId) throws SQLException {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/EventHandler.java", + "templatePath": "lessons/kafka-idempotency/starter/src/main/java/edu/lab/EventHandler.java", + "referencePath": "lessons/kafka-idempotency/reference/src/main/java/edu/lab/EventHandler.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/kafka-idempotency/starter/pom.xml", + "referencePath": "lessons/kafka-idempotency/reference/pom.xml", + "editable": false + }, + { + "path": "FIXTURE.md", + "templatePath": "lessons/kafka-idempotency/starter/FIXTURE.md", + "referencePath": "lessons/kafka-idempotency/reference/FIXTURE.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/kafka-idempotency/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "중복 이벤트 효과 한 번", + "DB 실패 시 이벤트 기록 롤백", + "다른 payload 중복 ID 거절", + "Kafka 전송 후 수동 오프셋 커밋" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": false, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "source-prepared", + "executionVerified": false, + "prerequisites": [ + "tx-proxy", + "flyway-migrations" + ] +} diff --git a/curriculum/professional/lessons/kafka-idempotency/reference/FIXTURE.md b/curriculum/professional/lessons/kafka-idempotency/reference/FIXTURE.md new file mode 100644 index 0000000..7d27a4d --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/reference/FIXTURE.md @@ -0,0 +1,4 @@ +전용 로컬 Kafka 단일 브로커가 필요합니다. 예시 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:19092. localhost도 허용하며 외부 호스트는 거절합니다. +브로커 advertised.listeners도 같은 loopback 주소를 광고해야 합니다. 실제 Docker 기동과 이미지 버전 검증은 별도 환경 준비 단계입니다. +테스트는 java-edu- 토픽 한 개와 동일 이름 consumer group을 생성하고 자신이 만든 토픽만 삭제합니다. 업무 클러스터를 연결하지 마세요. +중복 payload 두 건 전송은 재전달 가능성의 모델입니다. 브로커 장애 복구나 정확히 한 번의 분산 처리를 검증한 결과가 아닙니다. diff --git a/curriculum/professional/lessons/kafka-idempotency/reference/pom.xml b/curriculum/professional/lessons/kafka-idempotency/reference/pom.xml new file mode 100644 index 0000000..f94956f --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlkafka-idempotencyorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.apache.kafkakafka-clients +com.h2databaseh2 diff --git a/curriculum/professional/lessons/kafka-idempotency/reference/src/main/java/edu/lab/EventHandler.java b/curriculum/professional/lessons/kafka-idempotency/reference/src/main/java/edu/lab/EventHandler.java new file mode 100644 index 0000000..31799f3 --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/reference/src/main/java/edu/lab/EventHandler.java @@ -0,0 +1,34 @@ +package edu.lab; +import javax.sql.DataSource; +import java.sql.*; +public final class EventHandler { + private final DataSource source; + public EventHandler(DataSource source) { this.source = java.util.Objects.requireNonNull(source); } + public boolean handle(String eventId, String orderId) throws SQLException { + // BEGIN EXERCISE + if (eventId == null || eventId.isBlank() || orderId == null || orderId.isBlank()) throw new IllegalArgumentException("event identity"); + try (Connection c = source.getConnection()) { + c.setAutoCommit(false); + try { + try (PreparedStatement s = c.prepareStatement("insert into processed_events(event_id,order_id) values(?,?)")) { + s.setString(1,eventId); s.setString(2,orderId); s.executeUpdate(); + } + try (PreparedStatement s = c.prepareStatement("update orders set handled_count=handled_count+1 where id=?")) { + s.setString(1,orderId); if (s.executeUpdate() != 1) throw new SQLException("unknown order","02000"); + } + c.commit(); return true; + } catch (SQLException failure) { + try { c.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); throw failure; } + if (!"23505".equals(failure.getSQLState())) throw failure; + try (PreparedStatement s = c.prepareStatement("select order_id from processed_events where event_id=?")) { + s.setString(1,eventId); + try (ResultSet result = s.executeQuery()) { + if (result.next() && orderId.equals(result.getString(1))) return false; + throw new IllegalArgumentException("same eventId with different payload",failure); + } + } + } + } + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/kafka-idempotency/starter/FIXTURE.md b/curriculum/professional/lessons/kafka-idempotency/starter/FIXTURE.md new file mode 100644 index 0000000..7d27a4d --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/starter/FIXTURE.md @@ -0,0 +1,4 @@ +전용 로컬 Kafka 단일 브로커가 필요합니다. 예시 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:19092. localhost도 허용하며 외부 호스트는 거절합니다. +브로커 advertised.listeners도 같은 loopback 주소를 광고해야 합니다. 실제 Docker 기동과 이미지 버전 검증은 별도 환경 준비 단계입니다. +테스트는 java-edu- 토픽 한 개와 동일 이름 consumer group을 생성하고 자신이 만든 토픽만 삭제합니다. 업무 클러스터를 연결하지 마세요. +중복 payload 두 건 전송은 재전달 가능성의 모델입니다. 브로커 장애 복구나 정확히 한 번의 분산 처리를 검증한 결과가 아닙니다. diff --git a/curriculum/professional/lessons/kafka-idempotency/starter/pom.xml b/curriculum/professional/lessons/kafka-idempotency/starter/pom.xml new file mode 100644 index 0000000..f94956f --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlkafka-idempotencyorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.apache.kafkakafka-clients +com.h2databaseh2 diff --git a/curriculum/professional/lessons/kafka-idempotency/starter/src/main/java/edu/lab/EventHandler.java b/curriculum/professional/lessons/kafka-idempotency/starter/src/main/java/edu/lab/EventHandler.java new file mode 100644 index 0000000..5668d45 --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/starter/src/main/java/edu/lab/EventHandler.java @@ -0,0 +1,10 @@ +package edu.lab; +import javax.sql.DataSource; +import java.sql.*; +public final class EventHandler { + private final DataSource source; + public EventHandler(DataSource source) { this.source = java.util.Objects.requireNonNull(source); } + public boolean handle(String eventId, String orderId) throws SQLException { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/lombok-values/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/lombok-values/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..de96d6f --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,7 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void stableIdentity() { OrderKey key=new OrderKey("o-1"); java.util.Set set=new java.util.HashSet<>(); set.add(key); key.visit(); assertThat(set).contains(key,new OrderKey("o-1")); assertThat(key.getDisplayVisits()).isEqualTo(1); assertThat(key).isNotEqualTo(new OrderKey("o-2")); } + @Test void rejectsNullId() { assertThatNullPointerException().isThrownBy(()->new OrderKey(null)); } +} diff --git a/curriculum/professional/lessons/lombok-values/lesson.json b/curriculum/professional/lessons/lombok-values/lesson.json new file mode 100644 index 0000000..9f4b166 --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/lesson.json @@ -0,0 +1,164 @@ +{ + "id": "lombok-values", + "title": "Lombok 생성 코드와 동등성의 범위", + "description": "생성 코드가 만드는 동등성·생성자 계약을 해석합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "어떤 필드가 해시 기반 동등성에 참여해야 하는가?", + "같은 ID 동등성", + "가변 표시 값 제외", + "HashSet 조회 유지" + ], + "theory": [ + { + "heading": "애너테이션 처리 결과 읽기", + "text": "Lombok은 컴파일 시 생성 코드를 제공하는 도구입니다. 생성자·접근자·동등성 메서드를 직접 작성한 것과 비교하고 원하는 계약을 먼저 정합니다." + }, + { + "heading": "가변 필드와 엔티티의 함정", + "text": "방문 횟수 같은 가변 표시 값까지 equals/hashCode에 포함하면 HashSet에 넣은 뒤 조회가 깨질 수 있습니다. 이 수업은 안정적인 식별자를 가진 값 객체이며 JPA 엔티티 전체에 @Data를 붙이라는 예제가 아닙니다. 엔티티 ID 할당·프록시·연관관계는 JPA 단원에서 별도로 검토합니다." + } + ], + "example": { + "title": "OrderKey 기준 구현", + "code": "package edu.lab;\nimport lombok.Getter;\nimport lombok.NonNull;\nimport lombok.RequiredArgsConstructor;\nimport lombok.EqualsAndHashCode;\n@Getter\n@RequiredArgsConstructor\n@EqualsAndHashCode(onlyExplicitlyIncluded = true)\npublic final class OrderKey {\n @NonNull @EqualsAndHashCode.Include private final String orderId;\n private int displayVisits;\n public void visit() { displayVisits++; }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 같은 ID 동등성 · 가변 표시 값 제외 · HashSet 조회 유지.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/OrderKey.java", + "language": "java", + "code": "package edu.lab;\nimport lombok.Getter;\nimport lombok.NonNull;\nimport lombok.RequiredArgsConstructor;\nimport lombok.EqualsAndHashCode;\n@Getter\n@RequiredArgsConstructor\n@EqualsAndHashCode(onlyExplicitlyIncluded = true)\npublic final class OrderKey {\n @NonNull @EqualsAndHashCode.Include private final String orderId;\n private int displayVisits;\n public void visit() { displayVisits++; }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmllombok-valuesorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.projectlomboklombokprovidedorg.apache.maven.pluginsmaven-compiler-pluginlombok.launch.AnnotationProcessorHider$AnnotationProcessor\n" + } + ] + }, + "quiz": [ + { + "id": "lombok-values-q1", + "question": "OrderKey의 displayVisits를 equals/hashCode에서 제외하는 이유는?", + "options": [ + "방문 횟수가 바뀌어도 주문 식별과 해시 탐색 기준을 유지하기 위해서다.", + "모든 int 필드는 동등성에 사용할 수 없기 때문이다.", + "Lombok은 가변 필드를 컴파일하지 못하기 때문이다.", + "HashSet은 항상 객체 참조만 비교하기 때문이다." + ], + "correctIndex": 0, + "explanation": "HashSet은 저장된 해시와 equals를 사용합니다. 가변 방문 횟수가 해시에 포함되면 저장 뒤 같은 객체를 찾지 못할 수 있습니다." + }, + { + "id": "lombok-values-q2", + "question": "JPA 엔티티에 @Data를 그대로 적용하기 전에 무엇을 검토해야 하는가?", + "options": [ + "식별자 수명·가변 필드·연관관계 순환·지연 로딩 접근", + "소스 줄 수만", + "어노테이션의 알파벳 순서만", + "필드가 private인지 여부만" + ], + "correctIndex": 0, + "explanation": "생성된 메서드도 실제 객체 모델의 일부입니다. 엔티티의 동등성·toString·setter가 업무와 ORM 제약을 지키는지 확인해야 합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "생성 코드가 만드는 동등성·생성자 계약을 해석합니다.", + "tasks": [ + "OrderKey의 동등성과 hashCode는 불변 orderId만 사용하게 하세요.", + "visit()로 displayVisits가 바뀌어도 HashSet에서 키를 찾을 수 있어야 합니다.", + "Lombok @EqualsAndHashCode(onlyExplicitlyIncluded=true)와 Include의 생성 의미를 설명하세요.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Lombok EqualsAndHashCode", + "url": "https://projectlombok.org/features/EqualsAndHashCode", + "edition": "Lombok", + "checkedAt": "2026-10-12" + }, + { + "title": "Lombok constructor generation", + "url": "https://projectlombok.org/features/constructor", + "edition": "Lombok", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "lombok-values-project", + "professionalExercise": { + "id": "lombok-values-project", + "profileId": "professional-lombok", + "mode": "project", + "instructions": [ + "OrderKey의 동등성과 hashCode는 불변 orderId만 사용하게 하세요.", + "visit()로 displayVisits가 바뀌어도 HashSet에서 키를 찾을 수 있어야 합니다.", + "Lombok @EqualsAndHashCode(onlyExplicitlyIncluded=true)와 Include의 생성 의미를 설명하세요." + ], + "editableFiles": [ + "src/main/java/edu/lab/OrderKey.java" + ], + "template": "package edu.lab;\nimport lombok.Getter;\nimport lombok.NonNull;\nimport lombok.RequiredArgsConstructor;\n@Getter\n@RequiredArgsConstructor\npublic final class OrderKey {\n @NonNull private final String orderId;\n private int displayVisits;\n public void visit() { displayVisits++; }\n // orderId만 사용하는 동등성 계약을 Lombok으로 선언하세요.\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/OrderKey.java", + "templatePath": "lessons/lombok-values/starter/src/main/java/edu/lab/OrderKey.java", + "referencePath": "lessons/lombok-values/reference/src/main/java/edu/lab/OrderKey.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/lombok-values/starter/pom.xml", + "referencePath": "lessons/lombok-values/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/lombok-values/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "같은 ID 동등성", + "가변 표시 값 제외", + "HashSet 조회 유지" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "test-contracts", + "core:collections" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "65bcae5900047877a822e89821af9b5ac4a511c1b6a634d002757f4cee5efa9a", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/lombok-values/reference/pom.xml b/curriculum/professional/lessons/lombok-values/reference/pom.xml new file mode 100644 index 0000000..2d6ad4c --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/reference/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmllombok-valuesorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.projectlomboklombokprovidedorg.apache.maven.pluginsmaven-compiler-pluginlombok.launch.AnnotationProcessorHider$AnnotationProcessor diff --git a/curriculum/professional/lessons/lombok-values/reference/src/main/java/edu/lab/OrderKey.java b/curriculum/professional/lessons/lombok-values/reference/src/main/java/edu/lab/OrderKey.java new file mode 100644 index 0000000..5c92484 --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/reference/src/main/java/edu/lab/OrderKey.java @@ -0,0 +1,13 @@ +package edu.lab; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.EqualsAndHashCode; +@Getter +@RequiredArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public final class OrderKey { + @NonNull @EqualsAndHashCode.Include private final String orderId; + private int displayVisits; + public void visit() { displayVisits++; } +} diff --git a/curriculum/professional/lessons/lombok-values/starter/pom.xml b/curriculum/professional/lessons/lombok-values/starter/pom.xml new file mode 100644 index 0000000..2d6ad4c --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/starter/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmllombok-valuesorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.projectlomboklombokprovidedorg.apache.maven.pluginsmaven-compiler-pluginlombok.launch.AnnotationProcessorHider$AnnotationProcessor diff --git a/curriculum/professional/lessons/lombok-values/starter/src/main/java/edu/lab/OrderKey.java b/curriculum/professional/lessons/lombok-values/starter/src/main/java/edu/lab/OrderKey.java new file mode 100644 index 0000000..28075c7 --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/starter/src/main/java/edu/lab/OrderKey.java @@ -0,0 +1,12 @@ +package edu.lab; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +@Getter +@RequiredArgsConstructor +public final class OrderKey { + @NonNull private final String orderId; + private int displayVisits; + public void visit() { displayVisits++; } + // orderId만 사용하는 동등성 계약을 Lombok으로 선언하세요. +} diff --git a/curriculum/professional/lessons/mvc-rest/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/mvc-rest/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..b0cc489 --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,18 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +class ContractTest { + private final MockMvc mvc = standaloneSetup(new OrdersController()).build(); + @Test void existingResource() throws Exception { + mvc.perform(get("/orders/A-1")).andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(jsonPath("$.id").value("A-1")).andExpect(jsonPath("$.quantity").value(2)); + } + @Test void absentResource() throws Exception { mvc.perform(get("/orders/missing")).andExpect(status().isNotFound()); } + @Test void repeatedReadsDoNotConsumeStock() throws Exception { + for (int i = 0; i < 3; i++) mvc.perform(get("/orders/A-1")).andExpect(jsonPath("$.quantity").value(2)); + } +} diff --git a/curriculum/professional/lessons/mvc-rest/lesson.json b/curriculum/professional/lessons/mvc-rest/lesson.json new file mode 100644 index 0000000..d7ce2b4 --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/lesson.json @@ -0,0 +1,168 @@ +{ + "id": "mvc-rest", + "title": "MVC·REST의 상태 코드와 자원 계약", + "description": "주문 조회를 HTTP 자원으로 표현하고 성공·부재·읽기 요청의 상태 불변성을 분리합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "컨트롤러의 반환값과 HTTP 계약은 어디에서 만나는가?", + "기존 주문 조회 200", + "부재 주문 404", + "반복 조회 상태 보존" + ], + "theory": [ + { + "heading": "전송 계약과 업무 계약", + "text": "HTTP 메서드·상태 코드·표현 형식은 클라이언트가 관찰하는 계약입니다. 조회 성공은 200과 JSON, 주문 부재는 404로 표현합니다. GET은 업무 상태를 변경하지 않도록 설계합니다." + }, + { + "heading": "컨트롤러의 책임", + "text": "라우팅과 응답 변환은 어댑터의 책임입니다. 가격 계산·재고 예약 같은 업무 규칙까지 컨트롤러에 쌓으면 HTTP 없이 검증하기 어렵습니다. 이 예제의 고정 Map은 조회 계약을 좁혀 보여 주는 학습용 저장소입니다." + }, + { + "heading": "MockMvc의 경계", + "text": "MockMvc는 실제 서버 소켓 없이 Spring MVC 요청 처리를 검증합니다. JSON 직렬화와 매핑 테스트를 작성할 수 있지만 실제 네트워크·TLS·프록시·서블릿 컨테이너 설정의 종단 검증을 대신하지 않습니다." + } + ], + "example": { + "title": "OrdersController 기준 구현", + "code": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class OrdersController {\n public record Summary(String id, int quantity) {}\n private final Map orders = Map.of(\"A-1\", new Summary(\"A-1\", 2));\n @GetMapping(\"/{id}\")\n public ResponseEntity find(@PathVariable(\"id\") String id) {\n // BEGIN EXERCISE\n Summary order = orders.get(id);\n return order == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(order);\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 3개 JUnit 테스트 통과.\n검증 항목: 기존 주문 조회 200 · 부재 주문 404 · 반복 조회 상태 보존.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/OrdersController.java", + "language": "java", + "code": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class OrdersController {\n public record Summary(String id, int quantity) {}\n private final Map orders = Map.of(\"A-1\", new Summary(\"A-1\", 2));\n @GetMapping(\"/{id}\")\n public ResponseEntity find(@PathVariable(\"id\") String id) {\n // BEGIN EXERCISE\n Summary order = orders.get(id);\n return order == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(order);\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmvc-restorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-web\norg.springframework.bootspring-boot-starter-validation\norg.springframework.bootspring-boot-starter-testtest\n" + } + ] + }, + "quiz": [ + { + "id": "mvc-rest-q1", + "question": "없는 주문의 조회 응답을 결정하는 올바른 기준은?", + "options": [ + "항상 200과 빈 객체를 반환한다.", + "서버 오류이므로 항상 500이다.", + "조회가 실패하면 새 주문을 생성한다.", + "자원 부재를 404로 표현한다." + ], + "correctIndex": 3, + "explanation": "이 수업의 계약은 자원이 존재하는 성공과 존재하지 않는 상태를 구분합니다. 500은 서버 내부 실패의 의미와 혼동하게 합니다." + }, + { + "id": "mvc-rest-q2", + "question": "이 수업의 MockMvc 검사만으로 확인할 수 없는 것은?", + "options": [ + "요청 경로의 매핑", + "JSON 응답 필드", + "실제 TLS와 네트워크 프록시 설정", + "부재 주문의 상태 코드" + ], + "correctIndex": 2, + "explanation": "MockMvc는 모의 요청·응답으로 MVC 처리를 검사하며 실제 네트워크 구간을 거치지 않습니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "주문 조회를 HTTP 자원으로 표현하고 성공·부재·읽기 요청의 상태 불변성을 분리합니다.", + "tasks": [ + "GET /orders/A-1은 id A-1, quantity 2인 JSON과 200을 반환합니다.", + "존재하지 않는 ID는 빈 주문이나 null의 200 대신 404를 반환합니다.", + "반복 GET은 데이터를 변경하지 않습니다. 새 주문 생성은 이 수업 범위에 없습니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Spring MVC annotated controllers", + "url": "https://docs.spring.io/spring-framework/reference/6.2/web/webmvc/mvc-controller.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "MockMvc", + "url": "https://docs.spring.io/spring-framework/reference/6.2/testing/mockmvc.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Spring MVC", + "REST" + ], + "exerciseId": "mvc-rest-project", + "professionalExercise": { + "id": "mvc-rest-project", + "profileId": "professional-spring-web", + "mode": "project", + "instructions": [ + "GET /orders/A-1은 id A-1, quantity 2인 JSON과 200을 반환합니다.", + "존재하지 않는 ID는 빈 주문이나 null의 200 대신 404를 반환합니다.", + "반복 GET은 데이터를 변경하지 않습니다. 새 주문 생성은 이 수업 범위에 없습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/OrdersController.java" + ], + "template": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class OrdersController {\n public record Summary(String id, int quantity) {}\n private final Map orders = Map.of(\"A-1\", new Summary(\"A-1\", 2));\n @GetMapping(\"/{id}\")\n public ResponseEntity find(@PathVariable(\"id\") String id) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/OrdersController.java", + "templatePath": "lessons/mvc-rest/starter/src/main/java/edu/lab/OrdersController.java", + "referencePath": "lessons/mvc-rest/reference/src/main/java/edu/lab/OrdersController.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/mvc-rest/starter/pom.xml", + "referencePath": "lessons/mvc-rest/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/mvc-rest/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "기존 주문 조회 200", + "부재 주문 404", + "반복 조회 상태 보존" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "spring-di" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "cf818104cfb16da2cfc56a8a887429f7354bfbaf23b2edb5db949abe45065bdf", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/mvc-rest/reference/pom.xml b/curriculum/professional/lessons/mvc-rest/reference/pom.xml new file mode 100644 index 0000000..7279fa4 --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/reference/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmvc-restorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-testtest diff --git a/curriculum/professional/lessons/mvc-rest/reference/src/main/java/edu/lab/OrdersController.java b/curriculum/professional/lessons/mvc-rest/reference/src/main/java/edu/lab/OrdersController.java new file mode 100644 index 0000000..9516c79 --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/reference/src/main/java/edu/lab/OrdersController.java @@ -0,0 +1,17 @@ +package edu.lab; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/orders") +public class OrdersController { + public record Summary(String id, int quantity) {} + private final Map orders = Map.of("A-1", new Summary("A-1", 2)); + @GetMapping("/{id}") + public ResponseEntity find(@PathVariable("id") String id) { + // BEGIN EXERCISE + Summary order = orders.get(id); + return order == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(order); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/mvc-rest/starter/pom.xml b/curriculum/professional/lessons/mvc-rest/starter/pom.xml new file mode 100644 index 0000000..7279fa4 --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/starter/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmvc-restorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-testtest diff --git a/curriculum/professional/lessons/mvc-rest/starter/src/main/java/edu/lab/OrdersController.java b/curriculum/professional/lessons/mvc-rest/starter/src/main/java/edu/lab/OrdersController.java new file mode 100644 index 0000000..4abab1d --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/starter/src/main/java/edu/lab/OrdersController.java @@ -0,0 +1,14 @@ +package edu.lab; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/orders") +public class OrdersController { + public record Summary(String id, int quantity) {} + private final Map orders = Map.of("A-1", new Summary("A-1", 2)); + @GetMapping("/{id}") + public ResponseEntity find(@PathVariable("id") String id) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/mybatis-mapping/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/mybatis-mapping/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..0365f56 --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,32 @@ +package edu.lab; +import java.sql.Connection; +import java.util.UUID; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.*; +import org.apache.ibatis.session.*; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + SqlSessionFactory factory; + @BeforeEach void setup() throws Exception { + JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + try (Connection c = ds.getConnection(); var s = c.createStatement()) { + s.execute("create table orders(id varchar(60) primary key, sku varchar(40), quantity int)"); + s.execute("insert into orders values('A','BOOK',2),('B','PEN',3)"); + } + factory = MyBatisLab.factory(ds); + } + @Test void mapsOnlyMatchingRows() { + try (SqlSession session = factory.openSession()) { + var result = session.getMapper(MyBatisLab.Mapper.class).findBySku("BOOK"); + assertThat(result).hasSize(1); assertThat(result.get(0).getId()).isEqualTo("A"); + assertThat(result.get(0).getQuantity()).isEqualTo(2); + } + } + @Test void unknownAndSqlTextAreLiteralValues() { + try (SqlSession session = factory.openSession()) { + var mapper = session.getMapper(MyBatisLab.Mapper.class); + assertThat(mapper.findBySku("MISSING")).isEmpty(); + assertThat(mapper.findBySku("' OR '1'='1")).isEmpty(); + } + } +} diff --git a/curriculum/professional/lessons/mybatis-mapping/lesson.json b/curriculum/professional/lessons/mybatis-mapping/lesson.json new file mode 100644 index 0000000..6814906 --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/lesson.json @@ -0,0 +1,181 @@ +{ + "id": "mybatis-mapping", + "title": "MyBatis 파라미터 바인딩과 XML 매핑", + "description": "같은 SQL을 명시적으로 유지하면서 XML 매핑·값 바인딩·세션 수명을 연결합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "SQL을 직접 소유할 때 프레임워크가 맡는 책임은 무엇인가?", + "명시적 열·속성 매핑", + "정확한 SKU 조회", + "SQL 조각 입력을 값으로 취급" + ], + "theory": [ + { + "heading": "SQL 소유권과 매핑", + "text": "MyBatis는 SQL과 결과 객체 사이의 연결을 명시적으로 구성합니다. 쿼리 계획·인덱스·행 수·트랜잭션 책임은 사라지지 않습니다. resultMap은 열 이름과 Java 속성의 관계를 드러냅니다." + }, + { + "heading": "#{}와 ${}의 차이", + "text": "이 과제의 #{sku}는 PreparedStatement 값 바인딩을 사용합니다. 문자열 치환을 사용하는 ${}에 요청 값을 전달하면 SQL 구문이 바뀔 수 있습니다. 동적 정렬 필드가 필요하면 서버의 허용 목록으로 별도 선택합니다." + }, + { + "heading": "대안 학습 경로", + "text": "JDBC를 공통 선수로 삼고 MyBatis와 JPA를 비교합니다. SQL 중심 조회·복잡한 집계와 객체 상태 변경 중심 흐름은 서로 다른 비용이 있습니다. 두 기술을 연속 필수 단계로 취급하거나 한 도구가 다른 도구의 상위 버전이라고 설명하지 않습니다." + } + ], + "example": { + "title": "MyBatisLab 기준 구현", + "code": "package edu.lab;\nimport java.io.InputStream;\nimport java.util.List;\nimport javax.sql.DataSource;\nimport org.apache.ibatis.annotations.Param;\nimport org.apache.ibatis.builder.xml.XMLMapperBuilder;\nimport org.apache.ibatis.mapping.Environment;\nimport org.apache.ibatis.session.*;\nimport org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;\npublic final class MyBatisLab {\n public static class OrderRow {\n private String id; private String sku; private int quantity;\n public String getId() { return id; } public void setId(String id) { this.id = id; }\n public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; }\n public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity = quantity; }\n }\n public interface Mapper { List findBySku(@Param(\"sku\") String sku); }\n public static SqlSessionFactory factory(DataSource source) {\n Configuration config = new Configuration(new Environment(\"training\", new JdbcTransactionFactory(), source));\n try (InputStream xml = MyBatisLab.class.getResourceAsStream(\"/edu/lab/OrderMapper.xml\")) {\n if (xml == null) throw new IllegalStateException(\"mapper resource missing\");\n new XMLMapperBuilder(xml, config, \"edu/lab/OrderMapper.xml\", config.getSqlFragments()).parse();\n return new SqlSessionFactoryBuilder().build(config);\n } catch (java.io.IOException failure) { throw new IllegalStateException(failure); }\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 명시적 열·속성 매핑 · 정확한 SKU 조회 · SQL 조각 입력을 값으로 취급.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/MyBatisLab.java", + "language": "java", + "code": "package edu.lab;\nimport java.io.InputStream;\nimport java.util.List;\nimport javax.sql.DataSource;\nimport org.apache.ibatis.annotations.Param;\nimport org.apache.ibatis.builder.xml.XMLMapperBuilder;\nimport org.apache.ibatis.mapping.Environment;\nimport org.apache.ibatis.session.*;\nimport org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;\npublic final class MyBatisLab {\n public static class OrderRow {\n private String id; private String sku; private int quantity;\n public String getId() { return id; } public void setId(String id) { this.id = id; }\n public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; }\n public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity = quantity; }\n }\n public interface Mapper { List findBySku(@Param(\"sku\") String sku); }\n public static SqlSessionFactory factory(DataSource source) {\n Configuration config = new Configuration(new Environment(\"training\", new JdbcTransactionFactory(), source));\n try (InputStream xml = MyBatisLab.class.getResourceAsStream(\"/edu/lab/OrderMapper.xml\")) {\n if (xml == null) throw new IllegalStateException(\"mapper resource missing\");\n new XMLMapperBuilder(xml, config, \"edu/lab/OrderMapper.xml\", config.getSqlFragments()).parse();\n return new SqlSessionFactoryBuilder().build(config);\n } catch (java.io.IOException failure) { throw new IllegalStateException(failure); }\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmybatis-mappingorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.mybatismybatis3.5.19\ncom.h2databaseh2\n" + }, + { + "path": "src/main/resources/edu/lab/OrderMapper.xml", + "language": "xml", + "code": "\n\n\n \n \n \n \n\n" + } + ] + }, + "quiz": [ + { + "id": "mybatis-mapping-q1", + "question": "요청으로 받은 sku 값을 검색할 때 올바른 XML 표현은?", + "options": [ + "${sku}로 SQL 문자열에 직접 삽입한다.", + "sku가 문자열이면 작은따옴표만 붙이면 된다.", + "#{sku}로 값 바인딩을 사용한다.", + "입력 길이를 줄이면 어떤 치환도 안전하다." + ], + "correctIndex": 2, + "explanation": "#{}는 값을 SQL 구조와 분리해 바인딩합니다. 문자열 치환은 SQL 문법 자체를 바꿀 수 있어 요청 값을 직접 넣으면 안 됩니다." + }, + { + "id": "mybatis-mapping-q2", + "question": "MyBatis를 사용하면 자동으로 사라지는 책임은?", + "options": [ + "인덱스 선택 검토", + "트랜잭션 경계 설정", + "SQL의 행 수 검토", + "위 세 책임 중 어느 것도 자동으로 사라지지 않는다." + ], + "correctIndex": 3, + "explanation": "SQL 매핑 도구는 성능·정합성·트랜잭션 설계를 대신 결정하지 않습니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "같은 SQL을 명시적으로 유지하면서 XML 매핑·값 바인딩·세션 수명을 연결합니다.", + "tasks": [ + "XML namespace와 Mapper 인터페이스의 메서드를 연결하세요.", + "findBySku는 sku가 정확히 일치하는 행만 반환하며 #{sku}로 바인딩합니다.", + "알 수 없는 sku와 SQL 조각처럼 생긴 입력은 빈 목록을 반환합니다.", + "SqlSession은 호출자가 try-with-resources로 닫습니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "MyBatis Mapper XML", + "url": "https://mybatis.org/mybatis-3/sqlmap-xml.html", + "edition": "MyBatis 3", + "checkedAt": "2026-10-12" + }, + { + "title": "MyBatis Java API", + "url": "https://mybatis.org/mybatis-3/java-api.html", + "edition": "MyBatis 3", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "MyBatis", + "SQL" + ], + "exerciseId": "mybatis-mapping-project", + "professionalExercise": { + "id": "mybatis-mapping-project", + "profileId": "professional-mybatis", + "mode": "project", + "instructions": [ + "XML namespace와 Mapper 인터페이스의 메서드를 연결하세요.", + "findBySku는 sku가 정확히 일치하는 행만 반환하며 #{sku}로 바인딩합니다.", + "알 수 없는 sku와 SQL 조각처럼 생긴 입력은 빈 목록을 반환합니다.", + "SqlSession은 호출자가 try-with-resources로 닫습니다." + ], + "editableFiles": [ + "src/main/resources/edu/lab/OrderMapper.xml" + ], + "template": "package edu.lab;\nimport java.io.InputStream;\nimport java.util.List;\nimport javax.sql.DataSource;\nimport org.apache.ibatis.annotations.Param;\nimport org.apache.ibatis.builder.xml.XMLMapperBuilder;\nimport org.apache.ibatis.mapping.Environment;\nimport org.apache.ibatis.session.*;\nimport org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;\npublic final class MyBatisLab {\n public static class OrderRow {\n private String id; private String sku; private int quantity;\n public String getId() { return id; } public void setId(String id) { this.id = id; }\n public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; }\n public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity = quantity; }\n }\n public interface Mapper { List findBySku(@Param(\"sku\") String sku); }\n public static SqlSessionFactory factory(DataSource source) {\n Configuration config = new Configuration(new Environment(\"training\", new JdbcTransactionFactory(), source));\n try (InputStream xml = MyBatisLab.class.getResourceAsStream(\"/edu/lab/OrderMapper.xml\")) {\n if (xml == null) throw new IllegalStateException(\"mapper resource missing\");\n new XMLMapperBuilder(xml, config, \"edu/lab/OrderMapper.xml\", config.getSqlFragments()).parse();\n return new SqlSessionFactoryBuilder().build(config);\n } catch (java.io.IOException failure) { throw new IllegalStateException(failure); }\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/MyBatisLab.java", + "templatePath": "lessons/mybatis-mapping/starter/src/main/java/edu/lab/MyBatisLab.java", + "referencePath": "lessons/mybatis-mapping/reference/src/main/java/edu/lab/MyBatisLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/mybatis-mapping/starter/pom.xml", + "referencePath": "lessons/mybatis-mapping/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/edu/lab/OrderMapper.xml", + "templatePath": "lessons/mybatis-mapping/starter/src/main/resources/edu/lab/OrderMapper.xml", + "referencePath": "lessons/mybatis-mapping/reference/src/main/resources/edu/lab/OrderMapper.xml", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/mybatis-mapping/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "명시적 열·속성 매핑", + "정확한 SKU 조회", + "SQL 조각 입력을 값으로 취급" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "sql-jdbc" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "127b6b7a7bdcf465e0b3b55b32791f14bef3accc1f246aa163497b27102bb36c", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/mybatis-mapping/reference/pom.xml b/curriculum/professional/lessons/mybatis-mapping/reference/pom.xml new file mode 100644 index 0000000..e62cc4a --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmybatis-mappingorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.mybatismybatis3.5.19 +com.h2databaseh2 diff --git a/curriculum/professional/lessons/mybatis-mapping/reference/src/main/java/edu/lab/MyBatisLab.java b/curriculum/professional/lessons/mybatis-mapping/reference/src/main/java/edu/lab/MyBatisLab.java new file mode 100644 index 0000000..79c4b25 --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/reference/src/main/java/edu/lab/MyBatisLab.java @@ -0,0 +1,26 @@ +package edu.lab; +import java.io.InputStream; +import java.util.List; +import javax.sql.DataSource; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +public final class MyBatisLab { + public static class OrderRow { + private String id; private String sku; private int quantity; + public String getId() { return id; } public void setId(String id) { this.id = id; } + public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; } + public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity = quantity; } + } + public interface Mapper { List findBySku(@Param("sku") String sku); } + public static SqlSessionFactory factory(DataSource source) { + Configuration config = new Configuration(new Environment("training", new JdbcTransactionFactory(), source)); + try (InputStream xml = MyBatisLab.class.getResourceAsStream("/edu/lab/OrderMapper.xml")) { + if (xml == null) throw new IllegalStateException("mapper resource missing"); + new XMLMapperBuilder(xml, config, "edu/lab/OrderMapper.xml", config.getSqlFragments()).parse(); + return new SqlSessionFactoryBuilder().build(config); + } catch (java.io.IOException failure) { throw new IllegalStateException(failure); } + } +} diff --git a/curriculum/professional/lessons/mybatis-mapping/reference/src/main/resources/edu/lab/OrderMapper.xml b/curriculum/professional/lessons/mybatis-mapping/reference/src/main/resources/edu/lab/OrderMapper.xml new file mode 100644 index 0000000..65bed15 --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/reference/src/main/resources/edu/lab/OrderMapper.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/curriculum/professional/lessons/mybatis-mapping/starter/pom.xml b/curriculum/professional/lessons/mybatis-mapping/starter/pom.xml new file mode 100644 index 0000000..e62cc4a --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmybatis-mappingorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.mybatismybatis3.5.19 +com.h2databaseh2 diff --git a/curriculum/professional/lessons/mybatis-mapping/starter/src/main/java/edu/lab/MyBatisLab.java b/curriculum/professional/lessons/mybatis-mapping/starter/src/main/java/edu/lab/MyBatisLab.java new file mode 100644 index 0000000..79c4b25 --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/starter/src/main/java/edu/lab/MyBatisLab.java @@ -0,0 +1,26 @@ +package edu.lab; +import java.io.InputStream; +import java.util.List; +import javax.sql.DataSource; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +public final class MyBatisLab { + public static class OrderRow { + private String id; private String sku; private int quantity; + public String getId() { return id; } public void setId(String id) { this.id = id; } + public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; } + public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity = quantity; } + } + public interface Mapper { List findBySku(@Param("sku") String sku); } + public static SqlSessionFactory factory(DataSource source) { + Configuration config = new Configuration(new Environment("training", new JdbcTransactionFactory(), source)); + try (InputStream xml = MyBatisLab.class.getResourceAsStream("/edu/lab/OrderMapper.xml")) { + if (xml == null) throw new IllegalStateException("mapper resource missing"); + new XMLMapperBuilder(xml, config, "edu/lab/OrderMapper.xml", config.getSqlFragments()).parse(); + return new SqlSessionFactoryBuilder().build(config); + } catch (java.io.IOException failure) { throw new IllegalStateException(failure); } + } +} diff --git a/curriculum/professional/lessons/mybatis-mapping/starter/src/main/resources/edu/lab/OrderMapper.xml b/curriculum/professional/lessons/mybatis-mapping/starter/src/main/resources/edu/lab/OrderMapper.xml new file mode 100644 index 0000000..4f188de --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/starter/src/main/resources/edu/lab/OrderMapper.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/curriculum/professional/lessons/observability/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/observability/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..c620db5 --- /dev/null +++ b/curriculum/professional/lessons/observability/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,28 @@ +package edu.lab; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.*; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + static class Events implements ObservationHandler { + int stops, errors; + public boolean supportsContext(Observation.Context context) { return true; } + public void onStop(Observation.Context context) { stops++; } + public void onError(Observation.Context context) { errors++; } + } + @Test void recordsBothOutcomesAndPreservesFailure() { + var meters = new SimpleMeterRegistry(); + try { + var registry = ObservationRegistry.create(); var events = new Events(); registry.observationConfig().observationHandler(events); + var service = new OrderTelemetry(meters,registry); RuntimeException failure = new IllegalStateException("simulated"); + service.run("A",() -> {}); + assertThatThrownBy(() -> service.run("B",() -> { throw failure; })).isSameAs(failure); + assertThat(meters.get("orders.reserve.calls").tag("outcome","success").counter().count()).isEqualTo(1); + assertThat(meters.get("orders.reserve.calls").tag("outcome","failure").counter().count()).isEqualTo(1); + assertThat(meters.get("orders.reserve.duration").tag("outcome","success").timer().count()).isEqualTo(1); + assertThat(meters.get("orders.reserve.duration").tag("outcome","failure").timer().count()).isEqualTo(1); + assertThat(events.stops).isEqualTo(2); assertThat(events.errors).isEqualTo(1); + assertThat(meters.getMeters()).allSatisfy(meter -> assertThat(meter.getId().getTags()).allSatisfy(tag -> assertThat(tag.getKey()).isEqualTo("outcome"))); + } finally { meters.close(); } + } +} diff --git a/curriculum/professional/lessons/observability/lesson.json b/curriculum/professional/lessons/observability/lesson.json new file mode 100644 index 0000000..e39dab8 --- /dev/null +++ b/curriculum/professional/lessons/observability/lesson.json @@ -0,0 +1,179 @@ +{ + "id": "observability", + "title": "Micrometer 지표와 관측 실패 신호", + "description": "성공과 실패의 수·시간을 관측하고 지표 태그에 주문별 값을 넣는 비용을 확인합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "장애를 설명할 수 있는 신호를 어떤 비용으로 남길 것인가?", + "성공·실패 계수", + "성공·실패 타이머 기록", + "예외 보존과 Observation 종료", + "주문 ID 태그 제외" + ], + "theory": [ + { + "heading": "로그·지표·추적의 질문", + "text": "지표는 변화와 비율, 로그는 사건의 구체적 문맥, 추적은 요청의 경로를 설명하는 데 사용합니다. 이 예제는 Micrometer의 메모리 레지스트리와 Observation 콜백을 검사하며 원격 수집기·대시보드·분산 추적 배포를 구성하지 않습니다." + }, + { + "heading": "태그의 개수 비용", + "text": "orderId·이메일처럼 값의 수가 늘어나는 정보를 지표 태그에 넣으면 시계열 수가 계속 증가합니다. outcome처럼 제한된 집합을 태그로 사용하고 상세 식별자는 별도 접근 통제와 보존 정책이 있는 로그·추적에서 검토합니다." + }, + { + "heading": "실패도 끝까지 기록", + "text": "업무 예외를 삼키면 호출자의 재시도·트랜잭션 계약을 바꿉니다. finally에서 시간을 기록하고 예외를 원래대로 전파합니다. 관측 자체의 실패가 업무에 어떤 영향을 줄지도 운영 설계에서 따로 판단합니다." + } + ], + "example": { + "title": "OrderTelemetry 기준 구현", + "code": "package edu.lab;\nimport java.util.Objects;\nimport io.micrometer.core.instrument.*;\nimport io.micrometer.observation.*;\npublic final class OrderTelemetry {\n private final MeterRegistry meters; private final ObservationRegistry observations;\n public OrderTelemetry(MeterRegistry meters, ObservationRegistry observations) { this.meters=Objects.requireNonNull(meters); this.observations=Objects.requireNonNull(observations); }\n public void run(String orderId, Runnable work) {\n // BEGIN EXERCISE\n Objects.requireNonNull(orderId); Objects.requireNonNull(work);\n Timer.Sample sample = Timer.start(meters); String outcome=\"success\";\n Observation observation = Observation.start(\"orders.reserve\", observations);\n try (Observation.Scope ignored = observation.openScope()) { work.run(); }\n catch (RuntimeException | Error failure) { outcome=\"failure\"; observation.error(failure); throw failure; }\n finally {\n meters.counter(\"orders.reserve.calls\",\"outcome\",outcome).increment();\n sample.stop(Timer.builder(\"orders.reserve.duration\").tag(\"outcome\",outcome).register(meters));\n observation.stop();\n }\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 1개 JUnit 테스트 통과.\n검증 항목: 성공·실패 계수 · 성공·실패 타이머 기록 · 예외 보존과 Observation 종료 · 주문 ID 태그 제외.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/OrderTelemetry.java", + "language": "java", + "code": "package edu.lab;\nimport java.util.Objects;\nimport io.micrometer.core.instrument.*;\nimport io.micrometer.observation.*;\npublic final class OrderTelemetry {\n private final MeterRegistry meters; private final ObservationRegistry observations;\n public OrderTelemetry(MeterRegistry meters, ObservationRegistry observations) { this.meters=Objects.requireNonNull(meters); this.observations=Objects.requireNonNull(observations); }\n public void run(String orderId, Runnable work) {\n // BEGIN EXERCISE\n Objects.requireNonNull(orderId); Objects.requireNonNull(work);\n Timer.Sample sample = Timer.start(meters); String outcome=\"success\";\n Observation observation = Observation.start(\"orders.reserve\", observations);\n try (Observation.Scope ignored = observation.openScope()) { work.run(); }\n catch (RuntimeException | Error failure) { outcome=\"failure\"; observation.error(failure); throw failure; }\n finally {\n meters.counter(\"orders.reserve.calls\",\"outcome\",outcome).increment();\n sample.stop(Timer.builder(\"orders.reserve.duration\").tag(\"outcome\",outcome).register(meters));\n observation.stop();\n }\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlobservabilityorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\nio.micrometermicrometer-core\nio.micrometermicrometer-observation\n" + } + ] + }, + "quiz": [ + { + "id": "observability-q1", + "question": "orderId를 모든 지표의 태그로 넣었을 때 주된 비용은?", + "options": [ + "서로 다른 값만큼 시계열 수가 늘어 저장·조회 비용이 커질 수 있다.", + "지표 값이 항상 0이 된다.", + "Java 타입 검사가 실패한다.", + "트랜잭션 격리 수준이 자동 변경된다." + ], + "correctIndex": 0, + "explanation": "지표의 태그 조합은 서로 다른 시계열이 됩니다. 주문마다 새 값을 만들면 데이터 양과 운영 비용이 증가합니다." + }, + { + "id": "observability-q2", + "question": "관측을 추가한 뒤 업무 예외를 잡아 정상 반환하면 생기는 문제는?", + "options": [ + "호출자의 실패·롤백·재시도 계약을 바꿀 수 있다.", + "지표 이름만 바뀐다.", + "실패를 기록했으므로 항상 안전하다.", + "Observation이 자동으로 원래 예외를 다시 던진다." + ], + "correctIndex": 0, + "explanation": "관측은 원래 업무 계약을 유지하도록 설계해야 합니다. 예외를 숨기면 호출자가 성공으로 오해할 수 있습니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "성공과 실패의 수·시간을 관측하고 지표 태그에 주문별 값을 넣는 비용을 확인합니다.", + "tasks": [ + "성공과 실패 각각 calls Counter와 duration Timer를 outcome 태그로 기록합니다.", + "업무 예외는 바꾸거나 삼키지 않고 그대로 전달합니다.", + "Observation은 성공·실패 모두 종료하며 실패 원인을 error로 알립니다.", + "orderId를 지표 태그에 넣지 않습니다. 원격 exporter나 Actuator 운영 공개는 이 과제 범위가 아닙니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Micrometer Counters", + "url": "https://docs.micrometer.io/micrometer/reference/concepts/counters.html", + "edition": "Micrometer", + "checkedAt": "2026-10-12" + }, + { + "title": "Micrometer Timers", + "url": "https://docs.micrometer.io/micrometer/reference/concepts/timers.html", + "edition": "Micrometer", + "checkedAt": "2026-10-12" + }, + { + "title": "Micrometer Observation", + "url": "https://docs.micrometer.io/micrometer/reference/observation/introduction.html", + "edition": "Micrometer", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Observability", + "Micrometer" + ], + "exerciseId": "observability-project", + "professionalExercise": { + "id": "observability-project", + "profileId": "professional-observability", + "mode": "project", + "instructions": [ + "성공과 실패 각각 calls Counter와 duration Timer를 outcome 태그로 기록합니다.", + "업무 예외는 바꾸거나 삼키지 않고 그대로 전달합니다.", + "Observation은 성공·실패 모두 종료하며 실패 원인을 error로 알립니다.", + "orderId를 지표 태그에 넣지 않습니다. 원격 exporter나 Actuator 운영 공개는 이 과제 범위가 아닙니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/OrderTelemetry.java" + ], + "template": "package edu.lab;\nimport java.util.Objects;\nimport io.micrometer.core.instrument.*;\nimport io.micrometer.observation.*;\npublic final class OrderTelemetry {\n private final MeterRegistry meters; private final ObservationRegistry observations;\n public OrderTelemetry(MeterRegistry meters, ObservationRegistry observations) { this.meters=Objects.requireNonNull(meters); this.observations=Objects.requireNonNull(observations); }\n public void run(String orderId, Runnable work) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/OrderTelemetry.java", + "templatePath": "lessons/observability/starter/src/main/java/edu/lab/OrderTelemetry.java", + "referencePath": "lessons/observability/reference/src/main/java/edu/lab/OrderTelemetry.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/observability/starter/pom.xml", + "referencePath": "lessons/observability/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/observability/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "성공·실패 계수", + "성공·실패 타이머 기록", + "예외 보존과 Observation 종료", + "주문 ID 태그 제외" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "spring-di", + "test-collaborators" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 1, + "starterSatisfied": false, + "sourceFingerprint": "92e8fff2dd813d24d2a77243bf640196e2bbabcde8ec61f95343ce9e953a3c2e", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/observability/reference/pom.xml b/curriculum/professional/lessons/observability/reference/pom.xml new file mode 100644 index 0000000..dbd3cc4 --- /dev/null +++ b/curriculum/professional/lessons/observability/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlobservabilityorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +io.micrometermicrometer-core +io.micrometermicrometer-observation diff --git a/curriculum/professional/lessons/observability/reference/src/main/java/edu/lab/OrderTelemetry.java b/curriculum/professional/lessons/observability/reference/src/main/java/edu/lab/OrderTelemetry.java new file mode 100644 index 0000000..620a5ef --- /dev/null +++ b/curriculum/professional/lessons/observability/reference/src/main/java/edu/lab/OrderTelemetry.java @@ -0,0 +1,22 @@ +package edu.lab; +import java.util.Objects; +import io.micrometer.core.instrument.*; +import io.micrometer.observation.*; +public final class OrderTelemetry { + private final MeterRegistry meters; private final ObservationRegistry observations; + public OrderTelemetry(MeterRegistry meters, ObservationRegistry observations) { this.meters=Objects.requireNonNull(meters); this.observations=Objects.requireNonNull(observations); } + public void run(String orderId, Runnable work) { + // BEGIN EXERCISE + Objects.requireNonNull(orderId); Objects.requireNonNull(work); + Timer.Sample sample = Timer.start(meters); String outcome="success"; + Observation observation = Observation.start("orders.reserve", observations); + try (Observation.Scope ignored = observation.openScope()) { work.run(); } + catch (RuntimeException | Error failure) { outcome="failure"; observation.error(failure); throw failure; } + finally { + meters.counter("orders.reserve.calls","outcome",outcome).increment(); + sample.stop(Timer.builder("orders.reserve.duration").tag("outcome",outcome).register(meters)); + observation.stop(); + } + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/observability/starter/pom.xml b/curriculum/professional/lessons/observability/starter/pom.xml new file mode 100644 index 0000000..dbd3cc4 --- /dev/null +++ b/curriculum/professional/lessons/observability/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlobservabilityorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +io.micrometermicrometer-core +io.micrometermicrometer-observation diff --git a/curriculum/professional/lessons/observability/starter/src/main/java/edu/lab/OrderTelemetry.java b/curriculum/professional/lessons/observability/starter/src/main/java/edu/lab/OrderTelemetry.java new file mode 100644 index 0000000..a1b1369 --- /dev/null +++ b/curriculum/professional/lessons/observability/starter/src/main/java/edu/lab/OrderTelemetry.java @@ -0,0 +1,11 @@ +package edu.lab; +import java.util.Objects; +import io.micrometer.core.instrument.*; +import io.micrometer.observation.*; +public final class OrderTelemetry { + private final MeterRegistry meters; private final ObservationRegistry observations; + public OrderTelemetry(MeterRegistry meters, ObservationRegistry observations) { this.meters=Objects.requireNonNull(meters); this.observations=Objects.requireNonNull(observations); } + public void run(String orderId, Runnable work) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/oidc-identity/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/oidc-identity/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..515703e --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,29 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +@SpringBootTest(classes=IdentityLab.class) +@AutoConfigureMockMvc +class ContractTest { + @Autowired MockMvc mvc; + @Test void authenticationRequired() throws Exception { mvc.perform(get("/me")).andExpect(status().isUnauthorized()); } + @Test void trustedIssuerAndSubject() throws Exception { + mvc.perform(get("/me").with(oidcLogin().idToken(t -> t.claim("iss",IdentityLab.TRUSTED_ISSUER).claim("sub","subject-A")))) + .andExpect(status().isOk()).andExpect(jsonPath("$.issuer").value(IdentityLab.TRUSTED_ISSUER)).andExpect(jsonPath("$.subject").value("subject-A")); + } + @Test void rejectOtherIssuer() throws Exception { + mvc.perform(get("/me").with(oidcLogin().idToken(t -> t.claim("iss","https://other.invalid").claim("sub","subject-A")))) + .andExpect(status().isForbidden()); + } + @Test void sameEmailIsNotSameSubject() throws Exception { + for (String subject : new String[]{"subject-A","subject-B"}) { + mvc.perform(get("/me").with(oidcLogin().idToken(t -> t.claim("iss",IdentityLab.TRUSTED_ISSUER).claim("sub",subject).claim("email","shared@example.invalid")))) + .andExpect(jsonPath("$.subject").value(subject)); + } + } +} diff --git a/curriculum/professional/lessons/oidc-identity/lesson.json b/curriculum/professional/lessons/oidc-identity/lesson.json new file mode 100644 index 0000000..f3655e5 --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/lesson.json @@ -0,0 +1,173 @@ +{ + "id": "oidc-identity", + "title": "OAuth2·OIDC의 역할과 계정 식별 경계", + "description": "OAuth2 권한 위임과 OIDC 로그인 신원을 구분하고 issuer·subject로 계정의 경계를 고정합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "외부 로그인 사용자를 어떤 식별자로 내부 계정에 연결하는가?", + "미인증 401", + "신뢰한 issuer·subject 전달", + "비신뢰 issuer 403", + "이메일로 신원 병합하지 않음" + ], + "theory": [ + { + "heading": "OAuth2와 OIDC의 역할", + "text": "OAuth2는 클라이언트의 자원 접근 권한 위임을 다룹니다. OIDC는 그 위에 사용자 인증과 ID Token 의미를 정의합니다. access token을 사용자의 모든 신원을 증명하는 임의 증명서처럼 다루지 않습니다." + }, + { + "heading": "계정 식별의 안정성", + "text": "이 과제의 내부 식별자는 신뢰한 issuer와 subject의 조합입니다. 이메일은 변경되거나 공급자 사이에서 충돌할 수 있으므로 자동 계정 병합 키로 사용하지 않습니다. issuer 신뢰 목록과 계정 연결 정책은 서비스가 명시합니다." + }, + { + "heading": "테스트 프로필의 한계", + "text": "oidcLogin 테스트 도구는 이미 인증된 OIDC 주체를 요청에 제공합니다. 따라서 이 실습은 주체를 받아 계정을 구분하는 경계를 검증합니다. authorization redirect·state·nonce·PKCE·코드 교환·ID Token 서명 검증의 전체 프로토콜은 실행하지 않으며, 별도 로컬 IdP 통합 과제로 이어집니다." + } + ], + "example": { + "title": "IdentityLab 기준 구현", + "code": "package edu.lab;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.client.registration.*;\nimport org.springframework.security.oauth2.core.AuthorizationGrantType;\nimport org.springframework.security.oauth2.core.oidc.user.OidcUser;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.security.web.authentication.HttpStatusEntryPoint;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.server.ResponseStatusException;\n@SpringBootApplication\npublic class IdentityLab {\n public static final String TRUSTED_ISSUER = \"https://training-issuer.invalid\";\n public record Identity(String issuer, String subject) {}\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).oauth2Login(Customizer.withDefaults())\n .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));\n return http.build();\n }\n @Bean ClientRegistrationRepository clients() {\n ClientRegistration fixture = ClientRegistration.withRegistrationId(\"training\")\n .clientId(\"training-client\").clientSecret(\"training-only-not-real\")\n .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)\n .redirectUri(\"{baseUrl}/login/oauth2/code/{registrationId}\").scope(\"openid\")\n .authorizationUri(TRUSTED_ISSUER + \"/authorize\").tokenUri(TRUSTED_ISSUER + \"/token\")\n .jwkSetUri(TRUSTED_ISSUER + \"/jwks\").userInfoUri(TRUSTED_ISSUER + \"/userinfo\")\n .userNameAttributeName(\"sub\").issuerUri(TRUSTED_ISSUER).clientName(\"local fixture\").build();\n return new InMemoryClientRegistrationRepository(fixture);\n }\n @RestController public static class Me {\n @GetMapping(\"/me\") public Identity me(@AuthenticationPrincipal OidcUser principal) {\n // BEGIN EXERCISE\n if (principal.getIssuer() == null || !TRUSTED_ISSUER.equals(principal.getIssuer().toString()))\n throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"issuer not trusted\");\n String subject = principal.getSubject();\n if (subject == null || subject.isBlank()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"subject missing\");\n return new Identity(TRUSTED_ISSUER, subject);\n // END EXERCISE\n }\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 4개 JUnit 테스트 통과.\n검증 항목: 미인증 401 · 신뢰한 issuer·subject 전달 · 비신뢰 issuer 403 · 이메일로 신원 병합하지 않음.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/IdentityLab.java", + "language": "java", + "code": "package edu.lab;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.client.registration.*;\nimport org.springframework.security.oauth2.core.AuthorizationGrantType;\nimport org.springframework.security.oauth2.core.oidc.user.OidcUser;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.security.web.authentication.HttpStatusEntryPoint;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.server.ResponseStatusException;\n@SpringBootApplication\npublic class IdentityLab {\n public static final String TRUSTED_ISSUER = \"https://training-issuer.invalid\";\n public record Identity(String issuer, String subject) {}\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).oauth2Login(Customizer.withDefaults())\n .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));\n return http.build();\n }\n @Bean ClientRegistrationRepository clients() {\n ClientRegistration fixture = ClientRegistration.withRegistrationId(\"training\")\n .clientId(\"training-client\").clientSecret(\"training-only-not-real\")\n .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)\n .redirectUri(\"{baseUrl}/login/oauth2/code/{registrationId}\").scope(\"openid\")\n .authorizationUri(TRUSTED_ISSUER + \"/authorize\").tokenUri(TRUSTED_ISSUER + \"/token\")\n .jwkSetUri(TRUSTED_ISSUER + \"/jwks\").userInfoUri(TRUSTED_ISSUER + \"/userinfo\")\n .userNameAttributeName(\"sub\").issuerUri(TRUSTED_ISSUER).clientName(\"local fixture\").build();\n return new InMemoryClientRegistrationRepository(fixture);\n }\n @RestController public static class Me {\n @GetMapping(\"/me\") public Identity me(@AuthenticationPrincipal OidcUser principal) {\n // BEGIN EXERCISE\n if (principal.getIssuer() == null || !TRUSTED_ISSUER.equals(principal.getIssuer().toString()))\n throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"issuer not trusted\");\n String subject = principal.getSubject();\n if (subject == null || subject.isBlank()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"subject missing\");\n return new Identity(TRUSTED_ISSUER, subject);\n // END EXERCISE\n }\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloidc-identityorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-web\norg.springframework.bootspring-boot-starter-oauth2-client\norg.springframework.bootspring-boot-starter-testtest\norg.springframework.securityspring-security-testtest\n" + } + ] + }, + "quiz": [ + { + "id": "oidc-identity-q1", + "question": "이 과제의 계정 식별자로 적절한 것은?", + "options": [ + "신뢰한 issuer와 subject의 조합", + "표시 이름만", + "이메일만으로 공급자 구분 없이 병합", + "access token 문자열의 앞 5글자" + ], + "correctIndex": 0, + "explanation": "신뢰한 발급자의 subject가 주체 식별의 기준입니다. 표시 이름과 이메일은 변경·충돌 가능성이 있으므로 자동 계정 병합 근거로 부족합니다." + }, + { + "id": "oidc-identity-q2", + "question": "oidcLogin()으로 작성한 이 테스트가 직접 검증하는 범위는?", + "options": [ + "외부 IdP의 authorization code 교환 전체", + "실제 JWKS 키 교체", + "인증된 주체를 받은 애플리케이션의 신원 처리", + "브라우저 redirect와 state·nonce 전체" + ], + "correctIndex": 2, + "explanation": "테스트 주체 주입은 프로토콜 실행을 생략하므로 별도 IdP 통합 검증이 필요합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "OAuth2 권한 위임과 OIDC 로그인 신원을 구분하고 issuer·subject로 계정의 경계를 고정합니다.", + "tasks": [ + "GET /me는 인증된 OIDC 주체의 issuer와 subject를 반환합니다.", + "허용 issuer는 https://training-issuer.invalid 하나이며 다른 issuer는 403입니다.", + "같은 이메일이어도 다른 subject를 같은 계정으로 합치지 않습니다.", + ".invalid 공급자 URL과 training-only 값은 호출하지 않는 테스트 설정입니다. 실제 외부 계정이나 client secret을 입력하지 않습니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Spring Security OIDC testing", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/test/mockmvc/oauth2.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "OpenID Connect Core", + "url": "https://openid.net/specs/openid-connect-core-1_0.html", + "edition": "OpenID Connect 1.0", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "OAuth2", + "OIDC", + "Spring Security" + ], + "exerciseId": "oidc-identity-project", + "professionalExercise": { + "id": "oidc-identity-project", + "profileId": "professional-oidc-fixture", + "mode": "project", + "instructions": [ + "GET /me는 인증된 OIDC 주체의 issuer와 subject를 반환합니다.", + "허용 issuer는 https://training-issuer.invalid 하나이며 다른 issuer는 403입니다.", + "같은 이메일이어도 다른 subject를 같은 계정으로 합치지 않습니다.", + ".invalid 공급자 URL과 training-only 값은 호출하지 않는 테스트 설정입니다. 실제 외부 계정이나 client secret을 입력하지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/IdentityLab.java" + ], + "template": "package edu.lab;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.client.registration.*;\nimport org.springframework.security.oauth2.core.AuthorizationGrantType;\nimport org.springframework.security.oauth2.core.oidc.user.OidcUser;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.security.web.authentication.HttpStatusEntryPoint;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.server.ResponseStatusException;\n@SpringBootApplication\npublic class IdentityLab {\n public static final String TRUSTED_ISSUER = \"https://training-issuer.invalid\";\n public record Identity(String issuer, String subject) {}\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).oauth2Login(Customizer.withDefaults())\n .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));\n return http.build();\n }\n @Bean ClientRegistrationRepository clients() {\n ClientRegistration fixture = ClientRegistration.withRegistrationId(\"training\")\n .clientId(\"training-client\").clientSecret(\"training-only-not-real\")\n .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)\n .redirectUri(\"{baseUrl}/login/oauth2/code/{registrationId}\").scope(\"openid\")\n .authorizationUri(TRUSTED_ISSUER + \"/authorize\").tokenUri(TRUSTED_ISSUER + \"/token\")\n .jwkSetUri(TRUSTED_ISSUER + \"/jwks\").userInfoUri(TRUSTED_ISSUER + \"/userinfo\")\n .userNameAttributeName(\"sub\").issuerUri(TRUSTED_ISSUER).clientName(\"local fixture\").build();\n return new InMemoryClientRegistrationRepository(fixture);\n }\n @RestController public static class Me {\n @GetMapping(\"/me\") public Identity me(@AuthenticationPrincipal OidcUser principal) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/IdentityLab.java", + "templatePath": "lessons/oidc-identity/starter/src/main/java/edu/lab/IdentityLab.java", + "referencePath": "lessons/oidc-identity/reference/src/main/java/edu/lab/IdentityLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/oidc-identity/starter/pom.xml", + "referencePath": "lessons/oidc-identity/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/oidc-identity/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "미인증 401", + "신뢰한 issuer·subject 전달", + "비신뢰 issuer 403", + "이메일로 신원 병합하지 않음" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "security-csrf" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "efb7ff61b59c5dc1f7d5aa5c28fb1d966c164b72dc6c9af2dda2b586d2335cd0", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/oidc-identity/reference/pom.xml b/curriculum/professional/lessons/oidc-identity/reference/pom.xml new file mode 100644 index 0000000..56f89e4 --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/reference/pom.xml @@ -0,0 +1,6 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloidc-identityorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-oauth2-client +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest diff --git a/curriculum/professional/lessons/oidc-identity/reference/src/main/java/edu/lab/IdentityLab.java b/curriculum/professional/lessons/oidc-identity/reference/src/main/java/edu/lab/IdentityLab.java new file mode 100644 index 0000000..3eb4892 --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/reference/src/main/java/edu/lab/IdentityLab.java @@ -0,0 +1,45 @@ +package edu.lab; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.client.registration.*; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +@SpringBootApplication +public class IdentityLab { + public static final String TRUSTED_ISSUER = "https://training-issuer.invalid"; + public record Identity(String issuer, String subject) {} + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).oauth2Login(Customizer.withDefaults()) + .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); + return http.build(); + } + @Bean ClientRegistrationRepository clients() { + ClientRegistration fixture = ClientRegistration.withRegistrationId("training") + .clientId("training-client").clientSecret("training-only-not-real") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}").scope("openid") + .authorizationUri(TRUSTED_ISSUER + "/authorize").tokenUri(TRUSTED_ISSUER + "/token") + .jwkSetUri(TRUSTED_ISSUER + "/jwks").userInfoUri(TRUSTED_ISSUER + "/userinfo") + .userNameAttributeName("sub").issuerUri(TRUSTED_ISSUER).clientName("local fixture").build(); + return new InMemoryClientRegistrationRepository(fixture); + } + @RestController public static class Me { + @GetMapping("/me") public Identity me(@AuthenticationPrincipal OidcUser principal) { + // BEGIN EXERCISE + if (principal.getIssuer() == null || !TRUSTED_ISSUER.equals(principal.getIssuer().toString())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "issuer not trusted"); + String subject = principal.getSubject(); + if (subject == null || subject.isBlank()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "subject missing"); + return new Identity(TRUSTED_ISSUER, subject); + // END EXERCISE + } + } +} diff --git a/curriculum/professional/lessons/oidc-identity/starter/pom.xml b/curriculum/professional/lessons/oidc-identity/starter/pom.xml new file mode 100644 index 0000000..56f89e4 --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/starter/pom.xml @@ -0,0 +1,6 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloidc-identityorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-oauth2-client +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest diff --git a/curriculum/professional/lessons/oidc-identity/starter/src/main/java/edu/lab/IdentityLab.java b/curriculum/professional/lessons/oidc-identity/starter/src/main/java/edu/lab/IdentityLab.java new file mode 100644 index 0000000..6624870 --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/starter/src/main/java/edu/lab/IdentityLab.java @@ -0,0 +1,39 @@ +package edu.lab; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.client.registration.*; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +@SpringBootApplication +public class IdentityLab { + public static final String TRUSTED_ISSUER = "https://training-issuer.invalid"; + public record Identity(String issuer, String subject) {} + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).oauth2Login(Customizer.withDefaults()) + .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); + return http.build(); + } + @Bean ClientRegistrationRepository clients() { + ClientRegistration fixture = ClientRegistration.withRegistrationId("training") + .clientId("training-client").clientSecret("training-only-not-real") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}").scope("openid") + .authorizationUri(TRUSTED_ISSUER + "/authorize").tokenUri(TRUSTED_ISSUER + "/token") + .jwkSetUri(TRUSTED_ISSUER + "/jwks").userInfoUri(TRUSTED_ISSUER + "/userinfo") + .userNameAttributeName("sub").issuerUri(TRUSTED_ISSUER).clientName("local fixture").build(); + return new InMemoryClientRegistrationRepository(fixture); + } + @RestController public static class Me { + @GetMapping("/me") public Identity me(@AuthenticationPrincipal OidcUser principal) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/optimistic-cancel/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..b49380f --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,31 @@ +package edu.lab; +import jakarta.persistence.*; +import org.junit.jupiter.api.*; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + EntityManagerFactory factory; + @BeforeEach void setup() { factory = CancellationLab.open(); var em = factory.createEntityManager(); em.getTransaction().begin(); em.persist(new CancellableOrder("A",2)); em.getTransaction().commit(); em.close(); } + @AfterEach void close() { factory.close(); } + @Test void cancellationRestoresOnlyOnceInOneObject() { + CancellableOrder order = new CancellableOrder("B",3); + assertThat(order.cancelAndReturnQuantity()).isEqualTo(3); assertThat(order.cancelled()).isTrue(); + assertThat(order.cancelAndReturnQuantity()).isZero(); + } + @Test void staleContextCannotOverwrite() { + var first = factory.createEntityManager(); var stale = factory.createEntityManager(); + try { + first.getTransaction().begin(); stale.getTransaction().begin(); + var one = first.find(CancellableOrder.class,"A"); var two = stale.find(CancellableOrder.class,"A"); + one.cancelAndReturnQuantity(); two.cancelAndReturnQuantity(); + first.getTransaction().commit(); + assertThatThrownBy(() -> stale.getTransaction().commit()).isInstanceOf(RollbackException.class); + var verify = factory.createEntityManager(); + try { assertThat(verify.find(CancellableOrder.class,"A").cancelled()).isTrue(); } + finally { verify.close(); } + } finally { + if (first.getTransaction().isActive()) first.getTransaction().rollback(); + if (stale.getTransaction().isActive()) stale.getTransaction().rollback(); + first.close(); stale.close(); + } + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/lesson.json b/curriculum/professional/lessons/optimistic-cancel/lesson.json new file mode 100644 index 0000000..ea3f290 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/lesson.json @@ -0,0 +1,194 @@ +{ + "id": "optimistic-cancel", + "title": "낙관적 잠금과 중복 취소의 의미", + "description": "취소 상태 전이와 낙관적 잠금 충돌을 분리하여 중복 복원과 덮어쓰기를 막는 근거를 확인합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "같은 상태를 읽은 두 작업이 서로의 결과를 덮어쓰지 않으려면?", + "취소 상태 전이", + "반복 취소 복원량 0", + "오래된 버전 커밋 거절" + ], + "theory": [ + { + "heading": "중복 호출의 업무 의미", + "text": "취소는 한 번 RESERVED에서 CANCELLED로 전이합니다. 이미 취소된 주문의 두 번째 호출은 복원할 수량 0을 반환합니다. 메서드 내부의 상태 검사만으로 여러 DB 트랜잭션의 경쟁까지 해결되지는 않습니다." + }, + { + "heading": "버전 비교로 충돌 발견", + "text": "@Version은 이전 버전을 조건으로 UPDATE하는 낙관적 잠금에 사용됩니다. 두 영속성 컨텍스트가 같은 버전을 읽고 서로 변경하면 먼저 저장한 결과를 뒤의 커밋이 덮어쓰는 대신 충돌을 알릴 수 있습니다. 충돌 처리와 재시도는 호출자 책임입니다." + }, + { + "heading": "외부 효과와 트랜잭션", + "text": "복원 수량을 계산한 즉시 외부 API를 호출하면 나중의 커밋 실패와 불일치할 수 있습니다. 이 과제는 엔티티와 DB 버전 충돌만 검사합니다. 실제 재고 복원은 같은 DB 트랜잭션이나 outbox 등 별도 경계 설계가 필요합니다." + } + ], + "example": { + "title": "CancellationLab 기준 구현", + "code": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class CancellationLab {\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"cancellations\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 취소 상태 전이 · 반복 취소 복원량 0 · 오래된 버전 커밋 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/CancellationLab.java", + "language": "java", + "code": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class CancellationLab {\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"cancellations\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloptimistic-cancelorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.hibernate.ormhibernate-core\njakarta.persistencejakarta.persistence-api\ncom.h2databaseh2\n" + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "language": "xml", + "code": "\n\n \n org.hibernate.jpa.HibernatePersistenceProvideredu.lab.CancellableOrder\n true\n \n \n \n \n \n \n \n \n\n" + }, + { + "path": "src/main/java/edu/lab/CancellableOrder.java", + "language": "java", + "code": "package edu.lab;\nimport jakarta.persistence.*;\n@Entity\n@Table(name=\"cancellable_orders\")\npublic class CancellableOrder {\n @Id private String id;\n @Version private long version;\n private int quantity;\n private boolean cancelled;\n protected CancellableOrder() {}\n public CancellableOrder(String id, int quantity) {\n if (id == null || id.isBlank() || quantity <= 0) throw new IllegalArgumentException(\"invalid order\");\n this.id = id; this.quantity = quantity;\n }\n public long version() { return version; }\n public boolean cancelled() { return cancelled; }\n public int cancelAndReturnQuantity() {\n // BEGIN EXERCISE\n if (cancelled) return 0;\n cancelled = true;\n return quantity;\n // END EXERCISE\n }\n}\n" + } + ] + }, + "quiz": [ + { + "id": "optimistic-cancel-q1", + "question": "@Version 충돌이 발생한 뒤 올바른 설명은?", + "options": [ + "항상 무제한으로 재시도한다.", + "충돌한 객체의 값을 강제로 저장하면 안전하다.", + "충돌을 호출자가 처리하고 업무 의미에 맞는 재조회·재시도를 검토한다.", + "버전 필드를 직접 0으로 바꾸면 해결된다." + ], + "correctIndex": 2, + "explanation": "낙관적 잠금은 충돌 발견 수단입니다. 새 상태에서 명령이 여전히 유효한지 확인하고 제한된 재시도·거절 정책을 결정해야 합니다." + }, + { + "id": "optimistic-cancel-q2", + "question": "cancelAndReturnQuantity가 2를 반환했다는 사실만으로 보장되지 않는 것은?", + "options": [ + "해당 객체가 취소 상태로 바뀜", + "같은 객체의 다음 취소가 0을 반환함", + "외부 재고 시스템에 2개가 원자적으로 복원됨", + "해당 메서드가 계산한 복원 후보 수량" + ], + "correctIndex": 2, + "explanation": "객체 상태 변화와 외부 시스템의 커밋은 서로 다른 경계입니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "취소 상태 전이와 낙관적 잠금 충돌을 분리하여 중복 복원과 덮어쓰기를 막는 근거를 확인합니다.", + "tasks": [ + "첫 cancelAndReturnQuantity 호출은 주문 수량을 반환하고 cancelled=true로 바꿉니다.", + "이미 취소된 객체에서는 0을 반환합니다.", + "@Version으로 stale 영속성 컨텍스트의 두 번째 커밋이 실패해야 합니다.", + "이 예제의 반환 수량은 재고 DB나 결제 API에 실제로 적용되지 않습니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Hibernate optimistic locking", + "url": "https://docs.hibernate.org/orm/6.6/userguide/html_single/#locking-optimistic", + "edition": "Hibernate ORM 6.6", + "checkedAt": "2026-10-12" + }, + { + "title": "Jakarta Persistence", + "url": "https://jakarta.ee/specifications/persistence/3.1/", + "edition": "Jakarta Persistence 3.1", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "JPA", + "Transaction", + "Concurrency" + ], + "exerciseId": "optimistic-cancel-project", + "professionalExercise": { + "id": "optimistic-cancel-project", + "profileId": "professional-jpa", + "mode": "project", + "instructions": [ + "첫 cancelAndReturnQuantity 호출은 주문 수량을 반환하고 cancelled=true로 바꿉니다.", + "이미 취소된 객체에서는 0을 반환합니다.", + "@Version으로 stale 영속성 컨텍스트의 두 번째 커밋이 실패해야 합니다.", + "이 예제의 반환 수량은 재고 DB나 결제 API에 실제로 적용되지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/CancellableOrder.java" + ], + "template": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class CancellationLab {\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"cancellations\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/CancellationLab.java", + "templatePath": "lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellationLab.java", + "referencePath": "lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellationLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/optimistic-cancel/starter/pom.xml", + "referencePath": "lessons/optimistic-cancel/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "templatePath": "lessons/optimistic-cancel/starter/src/main/resources/META-INF/persistence.xml", + "referencePath": "lessons/optimistic-cancel/reference/src/main/resources/META-INF/persistence.xml", + "editable": false + }, + { + "path": "src/main/java/edu/lab/CancellableOrder.java", + "templatePath": "lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellableOrder.java", + "referencePath": "lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellableOrder.java", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/optimistic-cancel/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "취소 상태 전이", + "반복 취소 복원량 0", + "오래된 버전 커밋 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "jpa-state", + "tx-proxy" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "e5e66d061683ca073cec3b47255908664a8be1efbe2f2223808aa499c2b23ec0", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/reference/pom.xml b/curriculum/professional/lessons/optimistic-cancel/reference/pom.xml new file mode 100644 index 0000000..92432b8 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/reference/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloptimistic-cancelorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.hibernate.ormhibernate-core +jakarta.persistencejakarta.persistence-api +com.h2databaseh2 diff --git a/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellableOrder.java b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellableOrder.java new file mode 100644 index 0000000..b1b18f3 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellableOrder.java @@ -0,0 +1,24 @@ +package edu.lab; +import jakarta.persistence.*; +@Entity +@Table(name="cancellable_orders") +public class CancellableOrder { + @Id private String id; + @Version private long version; + private int quantity; + private boolean cancelled; + protected CancellableOrder() {} + public CancellableOrder(String id, int quantity) { + if (id == null || id.isBlank() || quantity <= 0) throw new IllegalArgumentException("invalid order"); + this.id = id; this.quantity = quantity; + } + public long version() { return version; } + public boolean cancelled() { return cancelled; } + public int cancelAndReturnQuantity() { + // BEGIN EXERCISE + if (cancelled) return 0; + cancelled = true; + return quantity; + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellationLab.java b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellationLab.java new file mode 100644 index 0000000..bf08aa9 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellationLab.java @@ -0,0 +1,10 @@ +package edu.lab; +import jakarta.persistence.*; +import java.util.Map; +import java.util.UUID; +public final class CancellationLab { + public static EntityManagerFactory open() { + return Persistence.createEntityManagerFactory("cancellations", Map.of( + "jakarta.persistence.jdbc.url", "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1")); + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/reference/src/main/resources/META-INF/persistence.xml b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..f01e792 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,14 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvideredu.lab.CancellableOrder + true + + + + + + + + + diff --git a/curriculum/professional/lessons/optimistic-cancel/starter/pom.xml b/curriculum/professional/lessons/optimistic-cancel/starter/pom.xml new file mode 100644 index 0000000..92432b8 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/starter/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloptimistic-cancelorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.hibernate.ormhibernate-core +jakarta.persistencejakarta.persistence-api +com.h2databaseh2 diff --git a/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellableOrder.java b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellableOrder.java new file mode 100644 index 0000000..b632c84 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellableOrder.java @@ -0,0 +1,20 @@ +package edu.lab; +import jakarta.persistence.*; +@Entity +@Table(name="cancellable_orders") +public class CancellableOrder { + @Id private String id; + @Version private long version; + private int quantity; + private boolean cancelled; + protected CancellableOrder() {} + public CancellableOrder(String id, int quantity) { + if (id == null || id.isBlank() || quantity <= 0) throw new IllegalArgumentException("invalid order"); + this.id = id; this.quantity = quantity; + } + public long version() { return version; } + public boolean cancelled() { return cancelled; } + public int cancelAndReturnQuantity() { + throw new UnsupportedOperationException("한 번만 전이하도록 구현하세요"); + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellationLab.java b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellationLab.java new file mode 100644 index 0000000..bf08aa9 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellationLab.java @@ -0,0 +1,10 @@ +package edu.lab; +import jakarta.persistence.*; +import java.util.Map; +import java.util.UUID; +public final class CancellationLab { + public static EntityManagerFactory open() { + return Persistence.createEntityManagerFactory("cancellations", Map.of( + "jakarta.persistence.jdbc.url", "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1")); + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/starter/src/main/resources/META-INF/persistence.xml b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..f01e792 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,14 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvideredu.lab.CancellableOrder + true + + + + + + + + + diff --git a/curriculum/professional/lessons/orders-capstone/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/orders-capstone/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..d9b11fa --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,66 @@ +package edu.lab; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.assertj.core.api.Assertions.*; +@SpringBootTest(classes=CapstoneLab.class) +@AutoConfigureMockMvc +class ContractTest { + @Autowired MockMvc mvc; @Autowired JdbcTemplate jdbc; + @BeforeEach void reset() { jdbc.update("delete from orders"); jdbc.update("update inventory set stock=5 where sku='BOOK'"); } + int stock() { return jdbc.queryForObject("select stock from inventory where sku='BOOK'",Integer.class); } + ResultActions reserve(String buyer,String id,int quantity) throws Exception { + return mvc.perform(post("/orders").with(httpBasic(buyer,"training")).with(csrf()).contentType("application/json") + .content("{\"requestId\":\""+id+"\",\"sku\":\"BOOK\",\"quantity\":"+quantity+"}")); + } + ResultActions cancel(String buyer,String id) throws Exception { + return mvc.perform(post("/orders/"+id+"/cancel").with(httpBasic(buyer,"training")).with(csrf())); + } + @Test void sameRequestDoesNotConsumeTwice() throws Exception { + reserve("alice","A",2).andExpect(status().isOk()).andExpect(jsonPath("$.status").value("RESERVED")); + reserve("alice","A",2).andExpect(status().isOk()); assertThat(stock()).isEqualTo(3); + assertThat(jdbc.queryForObject("select count(*) from orders",Integer.class)).isEqualTo(1); + reserve("alice","A",3).andExpect(status().isConflict()); assertThat(stock()).isEqualTo(3); + } + @Test void insufficientStockLeavesNoOrder() throws Exception { + reserve("alice","A",6).andExpect(status().isConflict()); assertThat(stock()).isEqualTo(5); + assertThat(jdbc.queryForObject("select count(*) from orders",Integer.class)).isZero(); + } + @Test void ownershipAndRepeatedCancellation() throws Exception { + reserve("alice","A",2).andExpect(status().isOk()); + cancel("bob","A").andExpect(status().isForbidden()); assertThat(stock()).isEqualTo(3); + cancel("alice","A").andExpect(status().isOk()).andExpect(jsonPath("$.status").value("CANCELLED")); + cancel("alice","A").andExpect(status().isOk()); assertThat(stock()).isEqualTo(5); + reserve("alice","A",2).andExpect(jsonPath("$.status").value("CANCELLED")); assertThat(stock()).isEqualTo(5); + } + @Test void rejectsRoleAndCsrfWithoutMutation() throws Exception { + String body="{\"requestId\":\"guarded\",\"sku\":\"BOOK\",\"quantity\":1}"; + mvc.perform(post("/orders").with(user("viewer").roles("VIEWER")).with(csrf()).contentType("application/json").content(body)) + .andExpect(status().isForbidden()); + assertThat(stock()).isEqualTo(5); + assertThat(jdbc.queryForObject("select count(*) from orders",Integer.class)).isZero(); + mvc.perform(post("/orders").with(httpBasic("alice","training")).contentType("application/json").content(body)) + .andExpect(status().isForbidden()); + assertThat(stock()).isEqualTo(5); + assertThat(jdbc.queryForObject("select count(*) from orders",Integer.class)).isZero(); + } + @Test void requestIdIsASafeSinglePathSegment() throws Exception { + for (String id : new String[]{"A/B","A?B","A%2FB","한글"}) reserve("alice",id,1).andExpect(status().isBadRequest()); + assertThat(stock()).isEqualTo(5); + assertThat(jdbc.queryForObject("select count(*) from orders",Integer.class)).isZero(); + reserve("alice","A_1-2",1).andExpect(status().isOk()); + cancel("alice","A_1-2").andExpect(status().isOk()); assertThat(stock()).isEqualTo(5); + } + @Test void inputAuthenticationAndAbsence() throws Exception { + reserve("alice","A",0).andExpect(status().isBadRequest()); assertThat(stock()).isEqualTo(5); + mvc.perform(post("/orders").with(csrf()).contentType("application/json").content("{\"requestId\":\"A\",\"sku\":\"BOOK\",\"quantity\":1}")) + .andExpect(status().isUnauthorized()); + cancel("alice","missing").andExpect(status().isNotFound()); + } +} diff --git a/curriculum/professional/lessons/orders-capstone/lesson.json b/curriculum/professional/lessons/orders-capstone/lesson.json new file mode 100644 index 0000000..15eb323 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/lesson.json @@ -0,0 +1,221 @@ +{ + "id": "orders-capstone", + "title": "주문·예약·취소 통합 계약", + "description": "HTTP 입력·역할·객체 소유권·재고 트랜잭션·중복 요청·취소를 하나의 주문 흐름으로 연결합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "부분적으로 옳은 기능들을 합쳤을 때도 주문 불변식이 유지되는가?", + "입력·미인증 거절", + "동일 요청 멱등 처리", + "payload 충돌과 재고 부족 롤백", + "다른 사용자 취소 거절", + "반복 취소 재고 한 번 복원" + ], + "theory": [ + { + "heading": "계약을 먼저 연결한다", + "text": "요청 어댑터는 입력과 인증 주체를 받고 서비스는 상태 전이와 DB 경계를 결정합니다. DB는 재고 음수 금지와 요청 ID 유일성을 보조합니다. 계층 수를 늘리는 것 자체가 목표가 아니라 어느 실패를 누가 처리하는지 설명하는 것이 목표입니다." + }, + { + "heading": "반복 요청과 경쟁", + "text": "같은 요청 ID·사용자·SKU·수량의 순차 재시도는 기존 주문을 반환합니다. 같은 ID의 다른 내용은 충돌입니다. 동시 신규 요청의 유일 키 충돌은 409로 돌려주고 같은 요청으로 다시 조회하도록 합니다. 단일 DB의 조건부 UPDATE와 트랜잭션을 쓰지만 이 테스트만으로 모든 고부하 경쟁을 검증했다고 주장하지 않습니다." + }, + { + "heading": "취소와 경계의 증거", + "text": "취소 상태를 조건부로 바꾼 트랜잭션만 재고를 복원합니다. 권한 검사 실패·재고 부족·입력 실패는 부분 변경을 남기지 않아야 합니다. 실제 결제 취소와 이벤트 발행은 포함하지 않으며 추가 시 외부 시스템과의 실패 경계를 다시 설계합니다." + }, + { + "heading": "SOLID와 추상화의 비용", + "text": "컨트롤러·서비스·SQL의 책임을 구분하되 모든 클래스에 인터페이스를 추가하지 않습니다. 구현 대체나 외부 협력 테스트가 필요한 지점에 추상화를 도입하고 SQL의 원자적 조건을 무의미한 객체 계층으로 숨기지 않습니다. 정답 코드는 한 가지 설계안이며 대안은 동일 행동 기준으로 비교합니다." + } + ], + "example": { + "title": "CapstoneLab 기준 구현", + "code": "package edu.lab;\nimport java.util.*;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.*;\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.dao.DuplicateKeyException;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.jdbc.core.*;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.Authentication;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class CapstoneLab {\n public static void main(String[] args) { SpringApplication.run(CapstoneLab.class,args); }\n public record Create(@NotBlank @Size(max=60) @Pattern(regexp=\"[A-Za-z0-9_-]{1,60}\") String requestId, @NotBlank @Pattern(regexp=\"BOOK\") String sku, @Min(1) int quantity) {}\n public record OrderView(String id, String sku, int quantity, String status) {}\n private record Stored(String id, String buyer, String sku, int quantity, String status) {\n OrderView view() { return new OrderView(id,sku,quantity,status); }\n }\n public static class Failure extends RuntimeException {\n final int status;\n Failure(int status,String message) { super(message); this.status=status; }\n }\n @Service public static class Orders {\n private final JdbcTemplate jdbc;\n private static final RowMapper ROW = (r,n) -> new Stored(r.getString(\"id\"),r.getString(\"buyer\"),r.getString(\"sku\"),r.getInt(\"quantity\"),r.getString(\"status\"));\n public Orders(JdbcTemplate jdbc) { this.jdbc=jdbc; }\n private List find(String id) { return jdbc.query(\"select id,buyer,sku,quantity,status from orders where id=?\",ROW,id); }\n private Stored require(String id) { var rows=find(id); if(rows.isEmpty()) throw new Failure(404,\"order not found\"); return rows.get(0); }\n private void own(Stored order,String buyer) { if(!order.buyer().equals(buyer)) throw new Failure(403,\"not order owner\"); }\n @Transactional public OrderView reserve(String buyer,Create request) {\n // BEGIN EXERCISE\n if (buyer==null || buyer.isBlank() || request.requestId()==null || !request.requestId().matches(\"[A-Za-z0-9_-]{1,60}\")\n || !\"BOOK\".equals(request.sku()) || request.quantity()<=0) throw new Failure(400,\"invalid order\");\n var existing=find(request.requestId());\n if(!existing.isEmpty()) {\n Stored order=existing.get(0); own(order,buyer);\n if(!order.sku().equals(request.sku()) || order.quantity()!=request.quantity()) throw new Failure(409,\"request payload conflicts\");\n return order.view();\n }\n int changed=jdbc.update(\"update inventory set stock=stock-? where sku=? and stock>=?\",request.quantity(),request.sku(),request.quantity());\n if(changed!=1) throw new Failure(409,\"insufficient stock\");\n jdbc.update(\"insert into orders(id,buyer,sku,quantity,status) values(?,?,?,?,?)\",request.requestId(),buyer,request.sku(),request.quantity(),\"RESERVED\");\n return new OrderView(request.requestId(),request.sku(),request.quantity(),\"RESERVED\");\n // END EXERCISE\n }\n @Transactional public OrderView cancel(String buyer,String id) {\n // BEGIN EXERCISE\n Stored order=require(id); own(order,buyer);\n int changed=jdbc.update(\"update orders set status='CANCELLED' where id=? and status='RESERVED'\",id);\n if(changed==1) jdbc.update(\"update inventory set stock=stock+? where sku=?\",order.quantity(),order.sku());\n return require(id).view();\n // END EXERCISE\n }\n }\n @RestController @RequestMapping(\"/orders\")\n public static class Controller {\n private final Orders orders;\n public Controller(Orders orders) { this.orders=orders; }\n @PostMapping public OrderView create(@Valid @RequestBody Create request,Authentication auth) { return orders.reserve(auth.getName(),request); }\n @PostMapping(\"/{id}/cancel\") public OrderView cancel(@PathVariable(\"id\") String id,Authentication auth) { return orders.cancel(auth.getName(),id); }\n @ExceptionHandler(Failure.class) public ResponseEntity> failure(Failure failure) {\n return ResponseEntity.status(failure.status).body(Map.of(\"error\",failure.getMessage()));\n }\n @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> duplicate() {\n return ResponseEntity.status(409).body(Map.of(\"error\",\"concurrent request conflict; retry the same request\"));\n }\n }\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.requestMatchers(\"/orders\",\"/orders/**\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"alice\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"bob\").password(\"{noop}training\").roles(\"BUYER\").build());\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 6개 JUnit 테스트 통과.\n검증 항목: 입력·미인증 거절 · 동일 요청 멱등 처리 · payload 충돌과 재고 부족 롤백 · 다른 사용자 취소 거절 · 반복 취소 재고 한 번 복원.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/CapstoneLab.java", + "language": "java", + "code": "package edu.lab;\nimport java.util.*;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.*;\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.dao.DuplicateKeyException;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.jdbc.core.*;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.Authentication;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class CapstoneLab {\n public static void main(String[] args) { SpringApplication.run(CapstoneLab.class,args); }\n public record Create(@NotBlank @Size(max=60) @Pattern(regexp=\"[A-Za-z0-9_-]{1,60}\") String requestId, @NotBlank @Pattern(regexp=\"BOOK\") String sku, @Min(1) int quantity) {}\n public record OrderView(String id, String sku, int quantity, String status) {}\n private record Stored(String id, String buyer, String sku, int quantity, String status) {\n OrderView view() { return new OrderView(id,sku,quantity,status); }\n }\n public static class Failure extends RuntimeException {\n final int status;\n Failure(int status,String message) { super(message); this.status=status; }\n }\n @Service public static class Orders {\n private final JdbcTemplate jdbc;\n private static final RowMapper ROW = (r,n) -> new Stored(r.getString(\"id\"),r.getString(\"buyer\"),r.getString(\"sku\"),r.getInt(\"quantity\"),r.getString(\"status\"));\n public Orders(JdbcTemplate jdbc) { this.jdbc=jdbc; }\n private List find(String id) { return jdbc.query(\"select id,buyer,sku,quantity,status from orders where id=?\",ROW,id); }\n private Stored require(String id) { var rows=find(id); if(rows.isEmpty()) throw new Failure(404,\"order not found\"); return rows.get(0); }\n private void own(Stored order,String buyer) { if(!order.buyer().equals(buyer)) throw new Failure(403,\"not order owner\"); }\n @Transactional public OrderView reserve(String buyer,Create request) {\n // BEGIN EXERCISE\n if (buyer==null || buyer.isBlank() || request.requestId()==null || !request.requestId().matches(\"[A-Za-z0-9_-]{1,60}\")\n || !\"BOOK\".equals(request.sku()) || request.quantity()<=0) throw new Failure(400,\"invalid order\");\n var existing=find(request.requestId());\n if(!existing.isEmpty()) {\n Stored order=existing.get(0); own(order,buyer);\n if(!order.sku().equals(request.sku()) || order.quantity()!=request.quantity()) throw new Failure(409,\"request payload conflicts\");\n return order.view();\n }\n int changed=jdbc.update(\"update inventory set stock=stock-? where sku=? and stock>=?\",request.quantity(),request.sku(),request.quantity());\n if(changed!=1) throw new Failure(409,\"insufficient stock\");\n jdbc.update(\"insert into orders(id,buyer,sku,quantity,status) values(?,?,?,?,?)\",request.requestId(),buyer,request.sku(),request.quantity(),\"RESERVED\");\n return new OrderView(request.requestId(),request.sku(),request.quantity(),\"RESERVED\");\n // END EXERCISE\n }\n @Transactional public OrderView cancel(String buyer,String id) {\n // BEGIN EXERCISE\n Stored order=require(id); own(order,buyer);\n int changed=jdbc.update(\"update orders set status='CANCELLED' where id=? and status='RESERVED'\",id);\n if(changed==1) jdbc.update(\"update inventory set stock=stock+? where sku=?\",order.quantity(),order.sku());\n return require(id).view();\n // END EXERCISE\n }\n }\n @RestController @RequestMapping(\"/orders\")\n public static class Controller {\n private final Orders orders;\n public Controller(Orders orders) { this.orders=orders; }\n @PostMapping public OrderView create(@Valid @RequestBody Create request,Authentication auth) { return orders.reserve(auth.getName(),request); }\n @PostMapping(\"/{id}/cancel\") public OrderView cancel(@PathVariable(\"id\") String id,Authentication auth) { return orders.cancel(auth.getName(),id); }\n @ExceptionHandler(Failure.class) public ResponseEntity> failure(Failure failure) {\n return ResponseEntity.status(failure.status).body(Map.of(\"error\",failure.getMessage()));\n }\n @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> duplicate() {\n return ResponseEntity.status(409).body(Map.of(\"error\",\"concurrent request conflict; retry the same request\"));\n }\n }\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.requestMatchers(\"/orders\",\"/orders/**\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"alice\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"bob\").password(\"{noop}training\").roles(\"BUYER\").build());\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlorders-capstoneorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-web\norg.springframework.bootspring-boot-starter-validation\norg.springframework.bootspring-boot-starter-security\norg.springframework.bootspring-boot-starter-jdbc\ncom.h2databaseh2\norg.springframework.bootspring-boot-starter-testtest\norg.springframework.securityspring-security-testtest\norg.flywaydbflyway-core\n" + }, + { + "path": "src/main/resources/application.properties", + "language": "text", + "code": "spring.datasource.url=jdbc:h2:mem:orders-capstone;DB_CLOSE_DELAY=-1\nspring.datasource.username=sa\nspring.datasource.password=\nspring.sql.init.mode=never\nspring.main.banner-mode=off\nserver.address=127.0.0.1\n" + }, + { + "path": "src/main/resources/db/migration/V1__orders.sql", + "language": "sql", + "code": "CREATE TABLE inventory(sku VARCHAR(30) PRIMARY KEY, stock INT NOT NULL CHECK(stock >= 0));\nCREATE TABLE orders(id VARCHAR(60) PRIMARY KEY, buyer VARCHAR(60) NOT NULL, sku VARCHAR(30) NOT NULL, quantity INT NOT NULL CHECK(quantity > 0), status VARCHAR(20) NOT NULL CHECK(status IN ('RESERVED','CANCELLED')));\nINSERT INTO inventory(sku,stock) VALUES('BOOK',5);\n" + } + ] + }, + "quiz": [ + { + "id": "orders-capstone-q1", + "question": "같은 requestId로 수량이 다른 요청이 도착했을 때 이 과제의 정책은?", + "options": [ + "기존 요청으로 간주해 성공이라고 답한다.", + "기존 주문 수량을 조용히 덮어쓴다.", + "원래 요청과 의미가 다르므로 409로 거절한다.", + "재고가 있으면 같은 ID의 주문을 하나 더 만든다." + ], + "correctIndex": 2, + "explanation": "멱등성은 같은 의미의 요청을 반복할 때의 계약입니다. 같은 키의 다른 내용은 업무 충돌로 명시해야 합니다." + }, + { + "id": "orders-capstone-q2", + "question": "두 취소 요청이 경쟁할 때 재고를 복원할 작업을 정하는 기준은?", + "options": [ + "처음 주문을 조회한 요청", + "HTTP 연결이 먼저 열린 요청", + "RESERVED에서 CANCELLED로 조건부 UPDATE한 행 수가 1인 트랜잭션", + "더 높은 역할 이름을 가진 사용자" + ], + "correctIndex": 2, + "explanation": "상태 전이의 실제 성공을 DB 영향 행 수로 판단하고 재고 복원을 같은 트랜잭션에 둡니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "HTTP 입력·역할·객체 소유권·재고 트랜잭션·중복 요청·취소를 하나의 주문 흐름으로 연결합니다.", + "tasks": [ + "POST /orders는 requestId·sku=BOOK·양수 quantity를 받고 200과 주문 상태를 반환합니다. requestId는 영문 대소문자·숫자·_·-만 사용한 1~60자이며 취소 URL의 단일 경로 요소로 사용합니다.", + "동일 사용자·동일 payload의 같은 requestId 재시도는 재고를 다시 차감하지 않습니다. 다른 payload는 409입니다.", + "재고 부족은 409이며 주문 기록과 재고 모두 바뀌지 않습니다.", + "POST /orders/{id}/cancel은 주문 소유자만 수행할 수 있고 반복 취소는 재고를 한 번만 복원합니다.", + "인증·BUYER 역할·CSRF가 필요합니다. Flyway는 실행별 학습 H2 스키마를 준비합니다.", + "동시 생성에서 같은 requestId가 충돌하면 409 후 동일 요청 재시도 정책을 사용합니다. 결제·메일·외부 이벤트는 이 프로젝트에 연결하지 않습니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Spring transaction semantics", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/transaction/declarative/annotations.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring MVC validation", + "url": "https://docs.spring.io/spring-framework/reference/6.2/web/webmvc/mvc-controller/ann-validation.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring Security authorization", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/authorization/authorize-http-requests.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "Flyway Java API", + "url": "https://documentation.red-gate.com/flyway/reference/usage/api-java", + "edition": "Flyway", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Spring Boot", + "Transaction", + "Security", + "SQL" + ], + "exerciseId": "orders-capstone-project", + "professionalExercise": { + "id": "orders-capstone-project", + "profileId": "professional-capstone", + "mode": "project", + "instructions": [ + "POST /orders는 requestId·sku=BOOK·양수 quantity를 받고 200과 주문 상태를 반환합니다. requestId는 영문 대소문자·숫자·_·-만 사용한 1~60자이며 취소 URL의 단일 경로 요소로 사용합니다.", + "동일 사용자·동일 payload의 같은 requestId 재시도는 재고를 다시 차감하지 않습니다. 다른 payload는 409입니다.", + "재고 부족은 409이며 주문 기록과 재고 모두 바뀌지 않습니다.", + "POST /orders/{id}/cancel은 주문 소유자만 수행할 수 있고 반복 취소는 재고를 한 번만 복원합니다.", + "인증·BUYER 역할·CSRF가 필요합니다. Flyway는 실행별 학습 H2 스키마를 준비합니다.", + "동시 생성에서 같은 requestId가 충돌하면 409 후 동일 요청 재시도 정책을 사용합니다. 결제·메일·외부 이벤트는 이 프로젝트에 연결하지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/CapstoneLab.java" + ], + "template": "package edu.lab;\nimport java.util.*;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.*;\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.dao.DuplicateKeyException;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.jdbc.core.*;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.Authentication;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class CapstoneLab {\n public static void main(String[] args) { SpringApplication.run(CapstoneLab.class,args); }\n public record Create(@NotBlank @Size(max=60) @Pattern(regexp=\"[A-Za-z0-9_-]{1,60}\") String requestId, @NotBlank @Pattern(regexp=\"BOOK\") String sku, @Min(1) int quantity) {}\n public record OrderView(String id, String sku, int quantity, String status) {}\n private record Stored(String id, String buyer, String sku, int quantity, String status) {\n OrderView view() { return new OrderView(id,sku,quantity,status); }\n }\n public static class Failure extends RuntimeException {\n final int status;\n Failure(int status,String message) { super(message); this.status=status; }\n }\n @Service public static class Orders {\n private final JdbcTemplate jdbc;\n private static final RowMapper ROW = (r,n) -> new Stored(r.getString(\"id\"),r.getString(\"buyer\"),r.getString(\"sku\"),r.getInt(\"quantity\"),r.getString(\"status\"));\n public Orders(JdbcTemplate jdbc) { this.jdbc=jdbc; }\n private List find(String id) { return jdbc.query(\"select id,buyer,sku,quantity,status from orders where id=?\",ROW,id); }\n private Stored require(String id) { var rows=find(id); if(rows.isEmpty()) throw new Failure(404,\"order not found\"); return rows.get(0); }\n private void own(Stored order,String buyer) { if(!order.buyer().equals(buyer)) throw new Failure(403,\"not order owner\"); }\n @Transactional public OrderView reserve(String buyer,Create request) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n @Transactional public OrderView cancel(String buyer,String id) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n }\n @RestController @RequestMapping(\"/orders\")\n public static class Controller {\n private final Orders orders;\n public Controller(Orders orders) { this.orders=orders; }\n @PostMapping public OrderView create(@Valid @RequestBody Create request,Authentication auth) { return orders.reserve(auth.getName(),request); }\n @PostMapping(\"/{id}/cancel\") public OrderView cancel(@PathVariable(\"id\") String id,Authentication auth) { return orders.cancel(auth.getName(),id); }\n @ExceptionHandler(Failure.class) public ResponseEntity> failure(Failure failure) {\n return ResponseEntity.status(failure.status).body(Map.of(\"error\",failure.getMessage()));\n }\n @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> duplicate() {\n return ResponseEntity.status(409).body(Map.of(\"error\",\"concurrent request conflict; retry the same request\"));\n }\n }\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.requestMatchers(\"/orders\",\"/orders/**\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"alice\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"bob\").password(\"{noop}training\").roles(\"BUYER\").build());\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/CapstoneLab.java", + "templatePath": "lessons/orders-capstone/starter/src/main/java/edu/lab/CapstoneLab.java", + "referencePath": "lessons/orders-capstone/reference/src/main/java/edu/lab/CapstoneLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/orders-capstone/starter/pom.xml", + "referencePath": "lessons/orders-capstone/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/application.properties", + "templatePath": "lessons/orders-capstone/starter/src/main/resources/application.properties", + "referencePath": "lessons/orders-capstone/reference/src/main/resources/application.properties", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V1__orders.sql", + "templatePath": "lessons/orders-capstone/starter/src/main/resources/db/migration/V1__orders.sql", + "referencePath": "lessons/orders-capstone/reference/src/main/resources/db/migration/V1__orders.sql", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/orders-capstone/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "입력·미인증 거절", + "동일 요청 멱등 처리", + "payload 충돌과 재고 부족 롤백", + "다른 사용자 취소 거절", + "반복 취소 재고 한 번 복원" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "json-validation", + "tx-proxy", + "security-csrf", + "flyway-migrations" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 6, + "starterSatisfied": false, + "sourceFingerprint": "da15b7d861d2b0be337ec2cb9e5a67f8f72e089b444043a84e821298bf159431", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/orders-capstone/reference/pom.xml b/curriculum/professional/lessons/orders-capstone/reference/pom.xml new file mode 100644 index 0000000..f3a9de5 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/reference/pom.xml @@ -0,0 +1,10 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlorders-capstoneorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-security +org.springframework.bootspring-boot-starter-jdbc +com.h2databaseh2 +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest +org.flywaydbflyway-core diff --git a/curriculum/professional/lessons/orders-capstone/reference/src/main/java/edu/lab/CapstoneLab.java b/curriculum/professional/lessons/orders-capstone/reference/src/main/java/edu/lab/CapstoneLab.java new file mode 100644 index 0000000..6abdc78 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/reference/src/main/java/edu/lab/CapstoneLab.java @@ -0,0 +1,87 @@ +package edu.lab; +import java.util.*; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.*; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +@SpringBootApplication +public class CapstoneLab { + public static void main(String[] args) { SpringApplication.run(CapstoneLab.class,args); } + public record Create(@NotBlank @Size(max=60) @Pattern(regexp="[A-Za-z0-9_-]{1,60}") String requestId, @NotBlank @Pattern(regexp="BOOK") String sku, @Min(1) int quantity) {} + public record OrderView(String id, String sku, int quantity, String status) {} + private record Stored(String id, String buyer, String sku, int quantity, String status) { + OrderView view() { return new OrderView(id,sku,quantity,status); } + } + public static class Failure extends RuntimeException { + final int status; + Failure(int status,String message) { super(message); this.status=status; } + } + @Service public static class Orders { + private final JdbcTemplate jdbc; + private static final RowMapper ROW = (r,n) -> new Stored(r.getString("id"),r.getString("buyer"),r.getString("sku"),r.getInt("quantity"),r.getString("status")); + public Orders(JdbcTemplate jdbc) { this.jdbc=jdbc; } + private List find(String id) { return jdbc.query("select id,buyer,sku,quantity,status from orders where id=?",ROW,id); } + private Stored require(String id) { var rows=find(id); if(rows.isEmpty()) throw new Failure(404,"order not found"); return rows.get(0); } + private void own(Stored order,String buyer) { if(!order.buyer().equals(buyer)) throw new Failure(403,"not order owner"); } + @Transactional public OrderView reserve(String buyer,Create request) { + // BEGIN EXERCISE + if (buyer==null || buyer.isBlank() || request.requestId()==null || !request.requestId().matches("[A-Za-z0-9_-]{1,60}") + || !"BOOK".equals(request.sku()) || request.quantity()<=0) throw new Failure(400,"invalid order"); + var existing=find(request.requestId()); + if(!existing.isEmpty()) { + Stored order=existing.get(0); own(order,buyer); + if(!order.sku().equals(request.sku()) || order.quantity()!=request.quantity()) throw new Failure(409,"request payload conflicts"); + return order.view(); + } + int changed=jdbc.update("update inventory set stock=stock-? where sku=? and stock>=?",request.quantity(),request.sku(),request.quantity()); + if(changed!=1) throw new Failure(409,"insufficient stock"); + jdbc.update("insert into orders(id,buyer,sku,quantity,status) values(?,?,?,?,?)",request.requestId(),buyer,request.sku(),request.quantity(),"RESERVED"); + return new OrderView(request.requestId(),request.sku(),request.quantity(),"RESERVED"); + // END EXERCISE + } + @Transactional public OrderView cancel(String buyer,String id) { + // BEGIN EXERCISE + Stored order=require(id); own(order,buyer); + int changed=jdbc.update("update orders set status='CANCELLED' where id=? and status='RESERVED'",id); + if(changed==1) jdbc.update("update inventory set stock=stock+? where sku=?",order.quantity(),order.sku()); + return require(id).view(); + // END EXERCISE + } + } + @RestController @RequestMapping("/orders") + public static class Controller { + private final Orders orders; + public Controller(Orders orders) { this.orders=orders; } + @PostMapping public OrderView create(@Valid @RequestBody Create request,Authentication auth) { return orders.reserve(auth.getName(),request); } + @PostMapping("/{id}/cancel") public OrderView cancel(@PathVariable("id") String id,Authentication auth) { return orders.cancel(auth.getName(),id); } + @ExceptionHandler(Failure.class) public ResponseEntity> failure(Failure failure) { + return ResponseEntity.status(failure.status).body(Map.of("error",failure.getMessage())); + } + @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> duplicate() { + return ResponseEntity.status(409).body(Map.of("error","concurrent request conflict; retry the same request")); + } + } + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(a -> a.requestMatchers("/orders","/orders/**").hasRole("BUYER").anyRequest().authenticated()) + .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults()); + return http.build(); + } + @Bean UserDetailsService users() { + return new InMemoryUserDetailsManager( + User.withUsername("alice").password("{noop}training").roles("BUYER").build(), + User.withUsername("bob").password("{noop}training").roles("BUYER").build()); + } +} diff --git a/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/application.properties b/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/application.properties new file mode 100644 index 0000000..41b0e92 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/application.properties @@ -0,0 +1,6 @@ +spring.datasource.url=jdbc:h2:mem:orders-capstone;DB_CLOSE_DELAY=-1 +spring.datasource.username=sa +spring.datasource.password= +spring.sql.init.mode=never +spring.main.banner-mode=off +server.address=127.0.0.1 diff --git a/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/db/migration/V1__orders.sql b/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/db/migration/V1__orders.sql new file mode 100644 index 0000000..2edd720 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/db/migration/V1__orders.sql @@ -0,0 +1,3 @@ +CREATE TABLE inventory(sku VARCHAR(30) PRIMARY KEY, stock INT NOT NULL CHECK(stock >= 0)); +CREATE TABLE orders(id VARCHAR(60) PRIMARY KEY, buyer VARCHAR(60) NOT NULL, sku VARCHAR(30) NOT NULL, quantity INT NOT NULL CHECK(quantity > 0), status VARCHAR(20) NOT NULL CHECK(status IN ('RESERVED','CANCELLED'))); +INSERT INTO inventory(sku,stock) VALUES('BOOK',5); diff --git a/curriculum/professional/lessons/orders-capstone/starter/pom.xml b/curriculum/professional/lessons/orders-capstone/starter/pom.xml new file mode 100644 index 0000000..f3a9de5 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/starter/pom.xml @@ -0,0 +1,10 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlorders-capstoneorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-security +org.springframework.bootspring-boot-starter-jdbc +com.h2databaseh2 +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest +org.flywaydbflyway-core diff --git a/curriculum/professional/lessons/orders-capstone/starter/src/main/java/edu/lab/CapstoneLab.java b/curriculum/professional/lessons/orders-capstone/starter/src/main/java/edu/lab/CapstoneLab.java new file mode 100644 index 0000000..6a60266 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/starter/src/main/java/edu/lab/CapstoneLab.java @@ -0,0 +1,69 @@ +package edu.lab; +import java.util.*; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.*; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +@SpringBootApplication +public class CapstoneLab { + public static void main(String[] args) { SpringApplication.run(CapstoneLab.class,args); } + public record Create(@NotBlank @Size(max=60) @Pattern(regexp="[A-Za-z0-9_-]{1,60}") String requestId, @NotBlank @Pattern(regexp="BOOK") String sku, @Min(1) int quantity) {} + public record OrderView(String id, String sku, int quantity, String status) {} + private record Stored(String id, String buyer, String sku, int quantity, String status) { + OrderView view() { return new OrderView(id,sku,quantity,status); } + } + public static class Failure extends RuntimeException { + final int status; + Failure(int status,String message) { super(message); this.status=status; } + } + @Service public static class Orders { + private final JdbcTemplate jdbc; + private static final RowMapper ROW = (r,n) -> new Stored(r.getString("id"),r.getString("buyer"),r.getString("sku"),r.getInt("quantity"),r.getString("status")); + public Orders(JdbcTemplate jdbc) { this.jdbc=jdbc; } + private List find(String id) { return jdbc.query("select id,buyer,sku,quantity,status from orders where id=?",ROW,id); } + private Stored require(String id) { var rows=find(id); if(rows.isEmpty()) throw new Failure(404,"order not found"); return rows.get(0); } + private void own(Stored order,String buyer) { if(!order.buyer().equals(buyer)) throw new Failure(403,"not order owner"); } + @Transactional public OrderView reserve(String buyer,Create request) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + @Transactional public OrderView cancel(String buyer,String id) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + } + @RestController @RequestMapping("/orders") + public static class Controller { + private final Orders orders; + public Controller(Orders orders) { this.orders=orders; } + @PostMapping public OrderView create(@Valid @RequestBody Create request,Authentication auth) { return orders.reserve(auth.getName(),request); } + @PostMapping("/{id}/cancel") public OrderView cancel(@PathVariable("id") String id,Authentication auth) { return orders.cancel(auth.getName(),id); } + @ExceptionHandler(Failure.class) public ResponseEntity> failure(Failure failure) { + return ResponseEntity.status(failure.status).body(Map.of("error",failure.getMessage())); + } + @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> duplicate() { + return ResponseEntity.status(409).body(Map.of("error","concurrent request conflict; retry the same request")); + } + } + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(a -> a.requestMatchers("/orders","/orders/**").hasRole("BUYER").anyRequest().authenticated()) + .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults()); + return http.build(); + } + @Bean UserDetailsService users() { + return new InMemoryUserDetailsManager( + User.withUsername("alice").password("{noop}training").roles("BUYER").build(), + User.withUsername("bob").password("{noop}training").roles("BUYER").build()); + } +} diff --git a/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/application.properties b/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/application.properties new file mode 100644 index 0000000..41b0e92 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/application.properties @@ -0,0 +1,6 @@ +spring.datasource.url=jdbc:h2:mem:orders-capstone;DB_CLOSE_DELAY=-1 +spring.datasource.username=sa +spring.datasource.password= +spring.sql.init.mode=never +spring.main.banner-mode=off +server.address=127.0.0.1 diff --git a/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/db/migration/V1__orders.sql b/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/db/migration/V1__orders.sql new file mode 100644 index 0000000..2edd720 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/db/migration/V1__orders.sql @@ -0,0 +1,3 @@ +CREATE TABLE inventory(sku VARCHAR(30) PRIMARY KEY, stock INT NOT NULL CHECK(stock >= 0)); +CREATE TABLE orders(id VARCHAR(60) PRIMARY KEY, buyer VARCHAR(60) NOT NULL, sku VARCHAR(30) NOT NULL, quantity INT NOT NULL CHECK(quantity > 0), status VARCHAR(20) NOT NULL CHECK(status IN ('RESERVED','CANCELLED'))); +INSERT INTO inventory(sku,stock) VALUES('BOOK',5); diff --git a/curriculum/professional/lessons/redis-cache/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/redis-cache/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..dd88b81 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,37 @@ +package edu.lab; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.*; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.*; +import org.springframework.data.redis.core.StringRedisTemplate; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + LettuceConnectionFactory factory; StringRedisTemplate redis; StockCache cache; + @BeforeEach void connect() { + String host = System.getenv("JAVA_EDU_REDIS_HOST"), portText = System.getenv("JAVA_EDU_REDIS_PORT"); + if (host == null || portText == null) throw new IllegalStateException("학습 전용 Redis 환경이 준비되지 않았습니다."); + if (!Set.of("127.0.0.1","localhost","::1").contains(host)) throw new IllegalStateException("loopback Redis only"); + int port = Integer.parseInt(portText); if (port < 1 || port > 65535) throw new IllegalStateException("invalid port"); + var client = LettuceClientConfiguration.builder().commandTimeout(Duration.ofSeconds(5)).shutdownTimeout(Duration.ofMillis(100)).build(); + factory = new LettuceConnectionFactory(new RedisStandaloneConfiguration(host,port), client); + factory.afterPropertiesSet(); factory.start(); redis = new StringRedisTemplate(factory); + cache = new StockCache(redis,"java-edu:" + UUID.randomUUID() + ":"); + } + @AfterEach void cleanup() { + try { if (redis != null && cache != null) redis.delete(List.of(cache.key("BOOK"),cache.key("PEN"))); } + finally { if (factory != null) factory.destroy(); } + } + @Test void storesWithExpiryAndInvalidatesOnlyOwnSku() { + cache.put("BOOK",5,Duration.ofSeconds(30)); cache.put("PEN",2,Duration.ofSeconds(30)); + assertThat(cache.get("BOOK").getAsInt()).isEqualTo(5); + assertThat(redis.getExpire(cache.key("BOOK"),TimeUnit.MILLISECONDS)).isBetween(1L,30000L); + cache.invalidate("BOOK"); assertThat(cache.get("BOOK").isEmpty()).isTrue(); assertThat(cache.get("PEN").getAsInt()).isEqualTo(2); + } + @Test void rejectsInvalidValueAndTtl() { + assertThatThrownBy(() -> cache.put("BOOK",-1,Duration.ofSeconds(30))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> cache.put("BOOK",1,Duration.ZERO)).isInstanceOf(IllegalArgumentException.class); + assertThat(cache.get("BOOK").isEmpty()).isTrue(); + } +} diff --git a/curriculum/professional/lessons/redis-cache/lesson.json b/curriculum/professional/lessons/redis-cache/lesson.json new file mode 100644 index 0000000..0938f89 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/lesson.json @@ -0,0 +1,179 @@ +{ + "id": "redis-cache", + "title": "Redis TTL·무효화·키 소유권", + "description": "캐시의 TTL과 무효화 범위를 명시하고 Redis 데이터와 원본 DB의 일관성을 구분합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "캐시 값이 있다는 사실과 현재 재고가 정확하다는 사실은 같은가?", + "문자열 수량 저장·조회", + "양수 TTL 설정", + "지정 키만 무효화", + "잘못된 캐시 입력 거절" + ], + "theory": [ + { + "heading": "원본과 복제된 조회 값", + "text": "이 단원의 Redis 값은 조회를 돕는 캐시입니다. 판매 가능 수량의 최종 판단은 트랜잭션으로 보호한 DB에 둡니다. TTL은 오래된 값이 남는 시간을 제한하는 수단이며 모든 동시 요청의 일관성을 자동 보장하지 않습니다." + }, + { + "heading": "쓰기와 무효화의 순서", + "text": "DB 변경 뒤 캐시를 지우는 방식에도 장애·재조회 경쟁이 남을 수 있습니다. 요구하는 최신성과 장애 허용 범위를 정하고 무효화 재시도나 이벤트 전달을 설계합니다. 이 실습은 키별 put/get/invalidate 계약만 자동 검사합니다." + }, + { + "heading": "키 소유권과 환경", + "text": "각 실행은 java-edu: 아래 UUID 접두사를 사용하고 자신의 키만 지웁니다. FLUSHALL·FLUSHDB를 사용하지 않습니다. StringRedisTemplate으로 문자열 형식을 고정하며 학습 전용 loopback Redis가 준비되지 않으면 통합 검사를 수행한 것으로 표시하지 않습니다." + } + ], + "example": { + "title": "StockCache 기준 구현", + "code": "package edu.lab;\nimport java.time.Duration;\nimport java.util.OptionalInt;\nimport org.springframework.data.redis.core.StringRedisTemplate;\npublic final class StockCache {\n private final StringRedisTemplate redis; private final String prefix;\n public StockCache(StringRedisTemplate redis, String prefix) {\n if (prefix == null || !prefix.startsWith(\"java-edu:\") || !prefix.endsWith(\":\")) throw new IllegalArgumentException(\"training prefix\");\n this.redis = java.util.Objects.requireNonNull(redis); this.prefix = prefix;\n }\n public String key(String sku) { if (sku == null || sku.isBlank()) throw new IllegalArgumentException(\"sku\"); return prefix + sku; }\n public void put(String sku, int quantity, Duration ttl) {\n // BEGIN EXERCISE\n if (quantity < 0 || ttl == null || ttl.isZero() || ttl.isNegative()) throw new IllegalArgumentException(\"cache value or ttl\");\n redis.opsForValue().set(key(sku), Integer.toString(quantity), ttl);\n // END EXERCISE\n }\n public OptionalInt get(String sku) {\n // BEGIN EXERCISE\n String value = redis.opsForValue().get(key(sku));\n return value == null ? OptionalInt.empty() : OptionalInt.of(Integer.parseInt(value));\n // END EXERCISE\n }\n public void invalidate(String sku) {\n // BEGIN EXERCISE\n redis.delete(key(sku));\n // END EXERCISE\n }\n}\n", + "output": "예상 검증 항목: 문자열 수량 저장·조회 · 양수 TTL 설정 · 지정 키만 무효화 · 잘못된 캐시 입력 거절. 아직 실행하지 않았습니다.", + "explanation": "기준 구현과 테스트 소스는 공개 학습 자료입니다. 실제 실행·의존성 해석·설치형 통합 검증 결과는 별도 증거로 표시합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/StockCache.java", + "language": "java", + "code": "package edu.lab;\nimport java.time.Duration;\nimport java.util.OptionalInt;\nimport org.springframework.data.redis.core.StringRedisTemplate;\npublic final class StockCache {\n private final StringRedisTemplate redis; private final String prefix;\n public StockCache(StringRedisTemplate redis, String prefix) {\n if (prefix == null || !prefix.startsWith(\"java-edu:\") || !prefix.endsWith(\":\")) throw new IllegalArgumentException(\"training prefix\");\n this.redis = java.util.Objects.requireNonNull(redis); this.prefix = prefix;\n }\n public String key(String sku) { if (sku == null || sku.isBlank()) throw new IllegalArgumentException(\"sku\"); return prefix + sku; }\n public void put(String sku, int quantity, Duration ttl) {\n // BEGIN EXERCISE\n if (quantity < 0 || ttl == null || ttl.isZero() || ttl.isNegative()) throw new IllegalArgumentException(\"cache value or ttl\");\n redis.opsForValue().set(key(sku), Integer.toString(quantity), ttl);\n // END EXERCISE\n }\n public OptionalInt get(String sku) {\n // BEGIN EXERCISE\n String value = redis.opsForValue().get(key(sku));\n return value == null ? OptionalInt.empty() : OptionalInt.of(Integer.parseInt(value));\n // END EXERCISE\n }\n public void invalidate(String sku) {\n // BEGIN EXERCISE\n redis.delete(key(sku));\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlredis-cacheorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-data-redis\n" + }, + { + "path": "FIXTURE.md", + "language": "markdown", + "code": "이 단원은 전용 로컬 Redis가 필요합니다. 예시 연결은 127.0.0.1:16379이며 설치형에서 별도 자원 지원을 확인해야 합니다.\nJAVA_EDU_REDIS_HOST와 JAVA_EDU_REDIS_PORT를 둘 다 명시합니다. 허용 호스트는 127.0.0.1, localhost, ::1뿐입니다.\n공유 업무 Redis를 연결하지 마세요. 테스트는 java-edu:: 접두사의 BOOK/PEN 두 키만 만들고 지웁니다. FLUSHALL/FLUSHDB는 사용하지 않습니다.\nDocker 이미지 준비·실행·네트워크 격리는 이 소스만으로 검증되지 않았습니다. 자원 미준비 오류를 단원 통과나 건너뛴 성공으로 처리하지 않습니다.\n" + } + ] + }, + "quiz": [ + { + "id": "redis-cache-q1", + "question": "TTL을 설정한 재고 캐시에 대한 올바른 설명은?", + "options": [ + "TTL이 있으면 DB 트랜잭션은 필요 없다.", + "TTL이 있으면 중복 주문은 불가능하다.", + "만료를 제어하지만 DB와 캐시의 모든 동시 변경을 원자화하지는 않는다.", + "캐시 조회 값은 언제나 최신 DB 값과 같다." + ], + "correctIndex": 2, + "explanation": "TTL은 만료 정책입니다. DB와 Redis 두 저장소 사이의 쓰기 순서와 동시성 문제는 별도 계약으로 남습니다." + }, + { + "id": "redis-cache-q2", + "question": "학습용 키를 정리할 때 적절한 방식은?", + "options": [ + "모든 Redis 키를 FLUSHALL로 지운다.", + "현재 실행이 생성한 UUID 접두사의 키만 지운다.", + "다른 애플리케이션의 키도 같은 DB면 지운다.", + "정리를 하지 않으면 항상 안전하다." + ], + "correctIndex": 1, + "explanation": "공유 저장소 전체를 지우지 않고 실행별 소유권을 제한해야 다른 데이터에 영향을 주지 않습니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "캐시의 TTL과 무효화 범위를 명시하고 Redis 데이터와 원본 DB의 일관성을 구분합니다.", + "tasks": [ + "put은 0 이상 수량과 양수 TTL만 허용하며 문자열로 저장합니다.", + "get은 부재이면 OptionalInt.empty, 존재하면 수량을 반환합니다.", + "invalidate는 지정 SKU만 지우고 다른 키를 보존합니다.", + "전용 로컬 Redis와 JAVA_EDU_REDIS_HOST/JAVA_EDU_REDIS_PORT가 필요합니다. 자원이 없으면 단원 통합 검사는 미실행 상태입니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Spring Data Redis templates", + "url": "https://docs.spring.io/spring-data/redis/reference/3.5/redis/template.html", + "edition": "Spring Data Redis 3.5", + "checkedAt": "2026-10-12" + }, + { + "title": "Redis EXPIRE", + "url": "https://redis.io/docs/latest/commands/expire/", + "edition": "Redis command reference", + "checkedAt": "2026-10-12" + }, + { + "title": "Redis Docker installation", + "url": "https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/docker/", + "edition": "Redis official guide", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Redis", + "Cache" + ], + "exerciseId": "redis-cache-project", + "professionalExercise": { + "id": "redis-cache-project", + "profileId": "professional-redis", + "mode": "project", + "instructions": [ + "put은 0 이상 수량과 양수 TTL만 허용하며 문자열로 저장합니다.", + "get은 부재이면 OptionalInt.empty, 존재하면 수량을 반환합니다.", + "invalidate는 지정 SKU만 지우고 다른 키를 보존합니다.", + "전용 로컬 Redis와 JAVA_EDU_REDIS_HOST/JAVA_EDU_REDIS_PORT가 필요합니다. 자원이 없으면 단원 통합 검사는 미실행 상태입니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/StockCache.java" + ], + "template": "package edu.lab;\nimport java.time.Duration;\nimport java.util.OptionalInt;\nimport org.springframework.data.redis.core.StringRedisTemplate;\npublic final class StockCache {\n private final StringRedisTemplate redis; private final String prefix;\n public StockCache(StringRedisTemplate redis, String prefix) {\n if (prefix == null || !prefix.startsWith(\"java-edu:\") || !prefix.endsWith(\":\")) throw new IllegalArgumentException(\"training prefix\");\n this.redis = java.util.Objects.requireNonNull(redis); this.prefix = prefix;\n }\n public String key(String sku) { if (sku == null || sku.isBlank()) throw new IllegalArgumentException(\"sku\"); return prefix + sku; }\n public void put(String sku, int quantity, Duration ttl) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n public OptionalInt get(String sku) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n public void invalidate(String sku) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/StockCache.java", + "templatePath": "lessons/redis-cache/starter/src/main/java/edu/lab/StockCache.java", + "referencePath": "lessons/redis-cache/reference/src/main/java/edu/lab/StockCache.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/redis-cache/starter/pom.xml", + "referencePath": "lessons/redis-cache/reference/pom.xml", + "editable": false + }, + { + "path": "FIXTURE.md", + "templatePath": "lessons/redis-cache/starter/FIXTURE.md", + "referencePath": "lessons/redis-cache/reference/FIXTURE.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/redis-cache/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "문자열 수량 저장·조회", + "양수 TTL 설정", + "지정 키만 무효화", + "잘못된 캐시 입력 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": false, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "source-prepared", + "executionVerified": false, + "prerequisites": [ + "tx-proxy" + ] +} diff --git a/curriculum/professional/lessons/redis-cache/reference/FIXTURE.md b/curriculum/professional/lessons/redis-cache/reference/FIXTURE.md new file mode 100644 index 0000000..c497039 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/reference/FIXTURE.md @@ -0,0 +1,4 @@ +이 단원은 전용 로컬 Redis가 필요합니다. 예시 연결은 127.0.0.1:16379이며 설치형에서 별도 자원 지원을 확인해야 합니다. +JAVA_EDU_REDIS_HOST와 JAVA_EDU_REDIS_PORT를 둘 다 명시합니다. 허용 호스트는 127.0.0.1, localhost, ::1뿐입니다. +공유 업무 Redis를 연결하지 마세요. 테스트는 java-edu:: 접두사의 BOOK/PEN 두 키만 만들고 지웁니다. FLUSHALL/FLUSHDB는 사용하지 않습니다. +Docker 이미지 준비·실행·네트워크 격리는 이 소스만으로 검증되지 않았습니다. 자원 미준비 오류를 단원 통과나 건너뛴 성공으로 처리하지 않습니다. diff --git a/curriculum/professional/lessons/redis-cache/reference/pom.xml b/curriculum/professional/lessons/redis-cache/reference/pom.xml new file mode 100644 index 0000000..0fc3bdc --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/reference/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlredis-cacheorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-data-redis diff --git a/curriculum/professional/lessons/redis-cache/reference/src/main/java/edu/lab/StockCache.java b/curriculum/professional/lessons/redis-cache/reference/src/main/java/edu/lab/StockCache.java new file mode 100644 index 0000000..af67899 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/reference/src/main/java/edu/lab/StockCache.java @@ -0,0 +1,29 @@ +package edu.lab; +import java.time.Duration; +import java.util.OptionalInt; +import org.springframework.data.redis.core.StringRedisTemplate; +public final class StockCache { + private final StringRedisTemplate redis; private final String prefix; + public StockCache(StringRedisTemplate redis, String prefix) { + if (prefix == null || !prefix.startsWith("java-edu:") || !prefix.endsWith(":")) throw new IllegalArgumentException("training prefix"); + this.redis = java.util.Objects.requireNonNull(redis); this.prefix = prefix; + } + public String key(String sku) { if (sku == null || sku.isBlank()) throw new IllegalArgumentException("sku"); return prefix + sku; } + public void put(String sku, int quantity, Duration ttl) { + // BEGIN EXERCISE + if (quantity < 0 || ttl == null || ttl.isZero() || ttl.isNegative()) throw new IllegalArgumentException("cache value or ttl"); + redis.opsForValue().set(key(sku), Integer.toString(quantity), ttl); + // END EXERCISE + } + public OptionalInt get(String sku) { + // BEGIN EXERCISE + String value = redis.opsForValue().get(key(sku)); + return value == null ? OptionalInt.empty() : OptionalInt.of(Integer.parseInt(value)); + // END EXERCISE + } + public void invalidate(String sku) { + // BEGIN EXERCISE + redis.delete(key(sku)); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/redis-cache/starter/FIXTURE.md b/curriculum/professional/lessons/redis-cache/starter/FIXTURE.md new file mode 100644 index 0000000..c497039 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/starter/FIXTURE.md @@ -0,0 +1,4 @@ +이 단원은 전용 로컬 Redis가 필요합니다. 예시 연결은 127.0.0.1:16379이며 설치형에서 별도 자원 지원을 확인해야 합니다. +JAVA_EDU_REDIS_HOST와 JAVA_EDU_REDIS_PORT를 둘 다 명시합니다. 허용 호스트는 127.0.0.1, localhost, ::1뿐입니다. +공유 업무 Redis를 연결하지 마세요. 테스트는 java-edu:: 접두사의 BOOK/PEN 두 키만 만들고 지웁니다. FLUSHALL/FLUSHDB는 사용하지 않습니다. +Docker 이미지 준비·실행·네트워크 격리는 이 소스만으로 검증되지 않았습니다. 자원 미준비 오류를 단원 통과나 건너뛴 성공으로 처리하지 않습니다. diff --git a/curriculum/professional/lessons/redis-cache/starter/pom.xml b/curriculum/professional/lessons/redis-cache/starter/pom.xml new file mode 100644 index 0000000..0fc3bdc --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/starter/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlredis-cacheorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-data-redis diff --git a/curriculum/professional/lessons/redis-cache/starter/src/main/java/edu/lab/StockCache.java b/curriculum/professional/lessons/redis-cache/starter/src/main/java/edu/lab/StockCache.java new file mode 100644 index 0000000..0317009 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/starter/src/main/java/edu/lab/StockCache.java @@ -0,0 +1,21 @@ +package edu.lab; +import java.time.Duration; +import java.util.OptionalInt; +import org.springframework.data.redis.core.StringRedisTemplate; +public final class StockCache { + private final StringRedisTemplate redis; private final String prefix; + public StockCache(StringRedisTemplate redis, String prefix) { + if (prefix == null || !prefix.startsWith("java-edu:") || !prefix.endsWith(":")) throw new IllegalArgumentException("training prefix"); + this.redis = java.util.Objects.requireNonNull(redis); this.prefix = prefix; + } + public String key(String sku) { if (sku == null || sku.isBlank()) throw new IllegalArgumentException("sku"); return prefix + sku; } + public void put(String sku, int quantity, Duration ttl) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + public OptionalInt get(String sku) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + public void invalidate(String sku) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/security-csrf/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/security-csrf/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..576948c --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,18 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +@SpringBootTest(classes=SecurityLab.class) +@AutoConfigureMockMvc +class ContractTest { + @Autowired MockMvc mvc; + @Test void unauthenticated() throws Exception { mvc.perform(post("/orders").with(csrf())).andExpect(status().isUnauthorized()); } + @Test void wrongRole() throws Exception { mvc.perform(post("/orders").with(httpBasic("viewer","training")).with(csrf())).andExpect(status().isForbidden()); } + @Test void csrfRequired() throws Exception { mvc.perform(post("/orders").with(httpBasic("buyer","training"))).andExpect(status().isForbidden()); } + @Test void authorized() throws Exception { mvc.perform(post("/orders").with(httpBasic("buyer","training")).with(csrf())).andExpect(status().isOk()); } +} diff --git a/curriculum/professional/lessons/security-csrf/lesson.json b/curriculum/professional/lessons/security-csrf/lesson.json new file mode 100644 index 0000000..319c0a4 --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/lesson.json @@ -0,0 +1,171 @@ +{ + "id": "security-csrf", + "title": "Spring Security의 인증·권한·CSRF", + "description": "인증 여부, 역할 권한, 브라우저 요청의 CSRF 방어를 서로 다른 실패 조건으로 검사합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "로그인했다는 사실만으로 모든 상태 변경을 허용할 수 있는가?", + "미인증 401", + "역할 부족 403", + "CSRF 누락 403", + "허용된 요청 200" + ], + "theory": [ + { + "heading": "인증·권한·객체 소유권", + "text": "인증은 요청 주체를 확인하고 권한은 주체가 허용된 행동을 수행할 수 있는지 판단합니다. BUYER 역할이 있어도 다른 사용자의 주문을 취소할 권한까지 생기지는 않습니다. 이 단원은 URL 역할 권한을, 통합 과제는 객체 소유권을 검증합니다." + }, + { + "heading": "CSRF와 인증 전송 방식", + "text": "브라우저가 자동으로 보내는 인증 정보가 있는 상태 변경 요청은 CSRF 위협을 검토해야 합니다. REST라는 이름만으로 CSRF를 끄지 않습니다. 이 예제는 HTTP Basic 테스트 계정과 기본 CSRF 보호를 함께 사용하며 실제 서비스의 HTTPS·계정 저장 정책은 별도입니다." + }, + { + "heading": "실패 순서의 관찰", + "text": "보안 필터는 컨트롤러보다 먼저 요청을 거절할 수 있습니다. 인증 테스트에 유효한 CSRF 토큰을 주고, CSRF 테스트에는 인증된 사용자를 주면 두 원인을 분리할 수 있습니다. 모든 실패가 하나의 상태 코드가 되리라고 가정하지 않습니다." + } + ], + "example": { + "title": "SecurityLab 기준 구현", + "code": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class SecurityLab {\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n // BEGIN EXERCISE\n http.authorizeHttpRequests(auth -> auth.requestMatchers(\"/orders\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n // END EXERCISE\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"buyer\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"viewer\").password(\"{noop}training\").roles(\"VIEWER\").build());\n }\n @RestController public static class Orders {\n @PostMapping(\"/orders\") public Map create() { return Map.of(\"status\",\"accepted\"); }\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 4개 JUnit 테스트 통과.\n검증 항목: 미인증 401 · 역할 부족 403 · CSRF 누락 403 · 허용된 요청 200.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/SecurityLab.java", + "language": "java", + "code": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class SecurityLab {\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n // BEGIN EXERCISE\n http.authorizeHttpRequests(auth -> auth.requestMatchers(\"/orders\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n // END EXERCISE\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"buyer\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"viewer\").password(\"{noop}training\").roles(\"VIEWER\").build());\n }\n @RestController public static class Orders {\n @PostMapping(\"/orders\") public Map create() { return Map.of(\"status\",\"accepted\"); }\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsecurity-csrforg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-web\norg.springframework.bootspring-boot-starter-security\norg.springframework.bootspring-boot-starter-testtest\norg.springframework.securityspring-security-testtest\n" + } + ] + }, + "quiz": [ + { + "id": "security-csrf-q1", + "question": "인증과 CSRF를 독립적으로 검사하려면?", + "options": [ + "둘 다 항상 끄고 컨트롤러만 검사한다.", + "로그인 성공만 확인하면 CSRF도 검증된다.", + "모든 오류 응답을 200으로 통일한다.", + "인증 검사는 유효한 CSRF와 함께, CSRF 검사는 인증된 요청으로 수행한다." + ], + "correctIndex": 3, + "explanation": "여러 방어가 동시에 실패하면 먼저 동작한 필터만 관찰할 수 있습니다. 나머지 전제는 만족시켜 검사 대상을 분리합니다." + }, + { + "id": "security-csrf-q2", + "question": "BUYER 역할이 있는 사용자가 다른 사람의 주문을 취소할 때 필요한 판단은?", + "options": [ + "역할만 맞으면 모든 주문을 허용한다.", + "주문의 소유권이나 위임 권한을 추가로 검사한다.", + "CSRF 토큰이 있으면 소유권 검사는 생략한다.", + "URL에 주문 ID가 있으면 권한이 있다고 본다." + ], + "correctIndex": 1, + "explanation": "역할 기반 권한과 특정 자원에 대한 권한은 서로 다른 계약입니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "인증 여부, 역할 권한, 브라우저 요청의 CSRF 방어를 서로 다른 실패 조건으로 검사합니다.", + "tasks": [ + "POST /orders는 BUYER 역할만 허용하고 CSRF 토큰도 요구합니다.", + "유효한 CSRF와 미인증 요청은 401, 인증했지만 CSRF가 없으면 403입니다.", + "VIEWER는 CSRF가 유효해도 403, BUYER와 유효 CSRF는 200입니다.", + "training 비밀번호는 이 로컬 테스트의 가상 계정 값이며 실제 자격증명을 사용하지 않습니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Spring Security CSRF", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/exploits/csrf.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "In-memory authentication", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/authentication/passwords/in-memory.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Spring Security" + ], + "exerciseId": "security-csrf-project", + "professionalExercise": { + "id": "security-csrf-project", + "profileId": "professional-security", + "mode": "project", + "instructions": [ + "POST /orders는 BUYER 역할만 허용하고 CSRF 토큰도 요구합니다.", + "유효한 CSRF와 미인증 요청은 401, 인증했지만 CSRF가 없으면 403입니다.", + "VIEWER는 CSRF가 유효해도 403, BUYER와 유효 CSRF는 200입니다.", + "training 비밀번호는 이 로컬 테스트의 가상 계정 값이며 실제 자격증명을 사용하지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/SecurityLab.java" + ], + "template": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class SecurityLab {\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"buyer\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"viewer\").password(\"{noop}training\").roles(\"VIEWER\").build());\n }\n @RestController public static class Orders {\n @PostMapping(\"/orders\") public Map create() { return Map.of(\"status\",\"accepted\"); }\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/SecurityLab.java", + "templatePath": "lessons/security-csrf/starter/src/main/java/edu/lab/SecurityLab.java", + "referencePath": "lessons/security-csrf/reference/src/main/java/edu/lab/SecurityLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/security-csrf/starter/pom.xml", + "referencePath": "lessons/security-csrf/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/security-csrf/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "미인증 401", + "역할 부족 403", + "CSRF 누락 403", + "허용된 요청 200" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "json-validation" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "cad4dad0bbd2cd397625790a2880f107777c9222fcaf45fa9ef39664f99e47a4", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/security-csrf/reference/pom.xml b/curriculum/professional/lessons/security-csrf/reference/pom.xml new file mode 100644 index 0000000..76715b6 --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/reference/pom.xml @@ -0,0 +1,6 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsecurity-csrforg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-security +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest diff --git a/curriculum/professional/lessons/security-csrf/reference/src/main/java/edu/lab/SecurityLab.java b/curriculum/professional/lessons/security-csrf/reference/src/main/java/edu/lab/SecurityLab.java new file mode 100644 index 0000000..19f16f4 --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/reference/src/main/java/edu/lab/SecurityLab.java @@ -0,0 +1,28 @@ +package edu.lab; +import java.util.Map; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.bind.annotation.*; +@SpringBootApplication +public class SecurityLab { + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + // BEGIN EXERCISE + http.authorizeHttpRequests(auth -> auth.requestMatchers("/orders").hasRole("BUYER").anyRequest().authenticated()) + .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults()); + return http.build(); + // END EXERCISE + } + @Bean UserDetailsService users() { + return new InMemoryUserDetailsManager( + User.withUsername("buyer").password("{noop}training").roles("BUYER").build(), + User.withUsername("viewer").password("{noop}training").roles("VIEWER").build()); + } + @RestController public static class Orders { + @PostMapping("/orders") public Map create() { return Map.of("status","accepted"); } + } +} diff --git a/curriculum/professional/lessons/security-csrf/starter/pom.xml b/curriculum/professional/lessons/security-csrf/starter/pom.xml new file mode 100644 index 0000000..76715b6 --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/starter/pom.xml @@ -0,0 +1,6 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsecurity-csrforg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-security +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest diff --git a/curriculum/professional/lessons/security-csrf/starter/src/main/java/edu/lab/SecurityLab.java b/curriculum/professional/lessons/security-csrf/starter/src/main/java/edu/lab/SecurityLab.java new file mode 100644 index 0000000..577a233 --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/starter/src/main/java/edu/lab/SecurityLab.java @@ -0,0 +1,24 @@ +package edu.lab; +import java.util.Map; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.bind.annotation.*; +@SpringBootApplication +public class SecurityLab { + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + throw new UnsupportedOperationException("실습 구현 필요"); + } + @Bean UserDetailsService users() { + return new InMemoryUserDetailsManager( + User.withUsername("buyer").password("{noop}training").roles("BUYER").build(), + User.withUsername("viewer").password("{noop}training").roles("VIEWER").build()); + } + @RestController public static class Orders { + @PostMapping("/orders") public Map create() { return Map.of("status","accepted"); } + } +} diff --git a/curriculum/professional/lessons/spring-aop/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/spring-aop/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..192e845 --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,7 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void externalCallCrossesProxy() { var trace=new AopLab.TraceAspect(); AopLab.proxied(trace).reserve(); assertThat(trace.calls()).isEqualTo(1); } + @Test void selfInvocationDoesNotCrossProxy() { var trace=new AopLab.TraceAspect(); AopLab.proxied(trace).reserveTwice(); assertThat(trace.calls()).isZero(); } +} diff --git a/curriculum/professional/lessons/spring-aop/lesson.json b/curriculum/professional/lessons/spring-aop/lesson.json new file mode 100644 index 0000000..356cb36 --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/lesson.json @@ -0,0 +1,159 @@ +{ + "id": "spring-aop", + "title": "프록시·AOP 호출 경로의 반례", + "description": "프록시를 경유하는 호출과 객체 내부 호출을 실행 모델로 구분합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "같은 메서드라도 호출 경로가 다르면 advice가 적용되는가?", + "외부 호출 advice", + "자기 호출 우회 반례" + ], + "theory": [ + { + "heading": "프록시가 가로채는 경계", + "text": "Spring AOP는 프록시 기반 호출 경계에서 동작합니다. 외부 호출과 대상 객체 내부의 this 호출은 같은 경로가 아닙니다." + }, + { + "heading": "어노테이션보다 호출 경로", + "text": "트랜잭션이나 관측 기능을 적용할 때 표시만 보고 판단하지 말고 생성된 빈·프록시·호출자를 확인합니다. 필요하면 역할을 별도 협력 객체로 나눕니다. 강제 self 주입을 모든 문제의 기본 해법으로 사용하지 않습니다." + } + ], + "example": { + "title": "AopLab 기준 구현", + "code": "package edu.lab;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.aspectj.lang.annotation.Before;\nimport org.springframework.aop.aspectj.annotation.AspectJProxyFactory;\npublic final class AopLab {\n public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } }\n @Aspect public static class TraceAspect {\n private int calls;\n @Before(\"execution(* reserve(..))\") public void beforeReserve() { calls++; }\n public int calls() { return calls; }\n }\n public static Orders proxied(TraceAspect trace) {\n // BEGIN EXERCISE\n AspectJProxyFactory factory=new AspectJProxyFactory(new Orders());\n factory.addAspect(trace);\n return factory.getProxy();\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 외부 호출 advice · 자기 호출 우회 반례.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/AopLab.java", + "language": "java", + "code": "package edu.lab;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.aspectj.lang.annotation.Before;\nimport org.springframework.aop.aspectj.annotation.AspectJProxyFactory;\npublic final class AopLab {\n public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } }\n @Aspect public static class TraceAspect {\n private int calls;\n @Before(\"execution(* reserve(..))\") public void beforeReserve() { calls++; }\n public int calls() { return calls; }\n }\n public static Orders proxied(TraceAspect trace) {\n // BEGIN EXERCISE\n AspectJProxyFactory factory=new AspectJProxyFactory(new Orders());\n factory.addAspect(trace);\n return factory.getProxy();\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-aoporg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframeworkspring-context\norg.aspectjaspectjweaver\n" + } + ] + }, + "quiz": [ + { + "id": "spring-aop-q1", + "question": "reserveTwice가 내부에서 this.reserve를 두 번 호출할 때 advice 횟수가 0인 이유는?", + "options": [ + "두 번 호출하면 advice가 자동 취소되기 때문이다.", + "메서드 이름에 Twice가 있으면 AOP가 제외하기 때문이다.", + "같은 대상의 자기 호출이 프록시를 다시 거치지 않기 때문이다.", + "모든 메서드가 final이기 때문이다." + ], + "correctIndex": 2, + "explanation": "기본 Spring AOP 프록시는 프록시를 거친 진입을 가로챕니다. 대상 객체 내부의 this 호출은 그 진입 지점을 우회합니다." + }, + { + "id": "spring-aop-q2", + "question": "이 예제의 프록시 동작을 구분하는 적절한 검사는?", + "options": [ + "프록시 생성에 예외가 없는지만 검사한다.", + "외부 reserve 호출과 내부 자기 호출을 각각 실행하여 advice 횟수를 비교한다.", + "대상 객체의 클래스 이름만 검사한다.", + "로그 출력 순서를 수동으로 기억한다." + ], + "correctIndex": 1, + "explanation": "프록시 생성과 실제 조언 적용은 다른 사실입니다. 호출 경로에 따라 달라지는 관찰 결과를 별도로 검증합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "프록시를 경유하는 호출과 객체 내부 호출을 실행 모델로 구분합니다.", + "tasks": [ + "proxied(TraceAspect)를 AspectJProxyFactory로 조립하세요.", + "외부 reserve 호출은 advice를 한 번 거칩니다. reserveTwice의 내부 reserve 호출들은 이 프록시 advice를 거치지 않는 반례를 확인하세요.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Spring AOP proxying", + "url": "https://docs.spring.io/spring-framework/reference/6.2/core/aop/proxying.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "AspectJProxyFactory", + "url": "https://docs.spring.io/spring-framework/docs/6.2.x/javadoc-api/org/springframework/aop/aspectj/annotation/AspectJProxyFactory.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "spring-aop-project", + "professionalExercise": { + "id": "spring-aop-project", + "profileId": "professional-spring-aop", + "mode": "project", + "instructions": [ + "proxied(TraceAspect)를 AspectJProxyFactory로 조립하세요.", + "외부 reserve 호출은 advice를 한 번 거칩니다. reserveTwice의 내부 reserve 호출들은 이 프록시 advice를 거치지 않는 반례를 확인하세요." + ], + "editableFiles": [ + "src/main/java/edu/lab/AopLab.java" + ], + "template": "package edu.lab;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.aspectj.lang.annotation.Before;\nimport org.springframework.aop.aspectj.annotation.AspectJProxyFactory;\npublic final class AopLab {\n public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } }\n @Aspect public static class TraceAspect {\n private int calls;\n @Before(\"execution(* reserve(..))\") public void beforeReserve() { calls++; }\n public int calls() { return calls; }\n }\n public static Orders proxied(TraceAspect trace) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/AopLab.java", + "templatePath": "lessons/spring-aop/starter/src/main/java/edu/lab/AopLab.java", + "referencePath": "lessons/spring-aop/reference/src/main/java/edu/lab/AopLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/spring-aop/starter/pom.xml", + "referencePath": "lessons/spring-aop/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/spring-aop/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "외부 호출 advice", + "자기 호출 우회 반례" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "spring-di" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "203bd280bfe19ad5cbaef8117c8ab0d00b95d07fe7f1a1f2f5988a187b82bad9", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/spring-aop/reference/pom.xml b/curriculum/professional/lessons/spring-aop/reference/pom.xml new file mode 100644 index 0000000..d013602 --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-aoporg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context +org.aspectjaspectjweaver diff --git a/curriculum/professional/lessons/spring-aop/reference/src/main/java/edu/lab/AopLab.java b/curriculum/professional/lessons/spring-aop/reference/src/main/java/edu/lab/AopLab.java new file mode 100644 index 0000000..a762811 --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/reference/src/main/java/edu/lab/AopLab.java @@ -0,0 +1,19 @@ +package edu.lab; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; +public final class AopLab { + public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } } + @Aspect public static class TraceAspect { + private int calls; + @Before("execution(* reserve(..))") public void beforeReserve() { calls++; } + public int calls() { return calls; } + } + public static Orders proxied(TraceAspect trace) { + // BEGIN EXERCISE + AspectJProxyFactory factory=new AspectJProxyFactory(new Orders()); + factory.addAspect(trace); + return factory.getProxy(); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/spring-aop/starter/pom.xml b/curriculum/professional/lessons/spring-aop/starter/pom.xml new file mode 100644 index 0000000..d013602 --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-aoporg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context +org.aspectjaspectjweaver diff --git a/curriculum/professional/lessons/spring-aop/starter/src/main/java/edu/lab/AopLab.java b/curriculum/professional/lessons/spring-aop/starter/src/main/java/edu/lab/AopLab.java new file mode 100644 index 0000000..9a88b1d --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/starter/src/main/java/edu/lab/AopLab.java @@ -0,0 +1,15 @@ +package edu.lab; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; +public final class AopLab { + public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } } + @Aspect public static class TraceAspect { + private int calls; + @Before("execution(* reserve(..))") public void beforeReserve() { calls++; } + public int calls() { return calls; } + } + public static Orders proxied(TraceAspect trace) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/spring-di/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/spring-di/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..c6b8178 --- /dev/null +++ b/curriculum/professional/lessons/spring-di/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,8 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void assemblesAndSharesDefaultBean() { try(var context=new AnnotationConfigApplicationContext(DiLab.Config.class)){ var service=context.getBean(DiLab.OrderService.class); assertThat(service.total(29999)).isEqualTo(32999); assertThat(service.total(30000)).isEqualTo(30000); assertThat(service).isSameAs(context.getBean(DiLab.OrderService.class)); } } + @Test void domainChecksRemainInService() { assertThatIllegalArgumentException().isThrownBy(()->new DiLab.OrderService(total->0).total(-1)); } +} diff --git a/curriculum/professional/lessons/spring-di/lesson.json b/curriculum/professional/lessons/spring-di/lesson.json new file mode 100644 index 0000000..fbab735 --- /dev/null +++ b/curriculum/professional/lessons/spring-di/lesson.json @@ -0,0 +1,161 @@ +{ + "id": "spring-di", + "title": "Spring IoC·DI와 객체 수명", + "description": "Spring의 빈 조립과 서비스의 업무 책임을 분리합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "컨테이너가 대신하는 일과 도메인이 책임질 일은 무엇인가?", + "컨테이너 조립", + "정책 계약", + "기본 singleton 확인" + ], + "theory": [ + { + "heading": "IoC와 생성자 주입", + "text": "컨테이너가 객체 생성·조립을 관리해도 업무 불변식은 서비스와 도메인이 책임집니다. 생성자 주입은 필수 협력을 드러내고 대역을 넣어 테스트하기 쉽게 합니다." + }, + { + "heading": "스코프와 가변 상태", + "text": "기본 singleton 빈은 컨테이너 내에서 공유됩니다. singleton이라는 사실이 필드 변경의 스레드 안전성을 보장하지 않습니다. 사용자별 요청 상태를 무심코 서비스 필드에 저장하지 않습니다." + } + ], + "example": { + "title": "DiLab 기준 구현", + "code": "package edu.lab;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\npublic final class DiLab {\n public interface PricePolicy { int fee(int subtotal); }\n public static final class OrderService {\n private final PricePolicy policy;\n public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); }\n public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); }\n }\n @Configuration\n public static class Config {\n @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; }\n @Bean public OrderService orders(PricePolicy policy) {\n // BEGIN EXERCISE\n return new OrderService(policy);\n // END EXERCISE\n }\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 컨테이너 조립 · 정책 계약 · 기본 singleton 확인.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/DiLab.java", + "language": "java", + "code": "package edu.lab;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\npublic final class DiLab {\n public interface PricePolicy { int fee(int subtotal); }\n public static final class OrderService {\n private final PricePolicy policy;\n public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); }\n public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); }\n }\n @Configuration\n public static class Config {\n @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; }\n @Bean public OrderService orders(PricePolicy policy) {\n // BEGIN EXERCISE\n return new OrderService(policy);\n // END EXERCISE\n }\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-diorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframeworkspring-context\n" + } + ] + }, + "quiz": [ + { + "id": "spring-di-q1", + "question": "생성자 주입의 실질적 장점은?", + "options": [ + "의존 객체가 자동으로 스레드 안전해진다.", + "객체가 필요한 협력을 생성 시점에 드러내고 테스트에서 대체하기 쉽다.", + "모든 의존성에 인터페이스가 자동 생성된다.", + "순환 의존성이 모두 올바른 구조로 바뀐다." + ], + "correctIndex": 1, + "explanation": "생성자는 필수 협력 관계를 명확히 하지만 협력 객체의 동시성·수명·설계 적합성까지 자동으로 해결하지는 않습니다." + }, + { + "id": "spring-di-q2", + "question": "Spring의 기본 singleton scope에 대한 설명으로 맞는 것은?", + "options": [ + "전체 JVM의 모든 컨테이너가 항상 같은 인스턴스를 공유한다.", + "해당 컨테이너의 빈 정의별 공유 인스턴스이며 스레드 안전성은 별도다.", + "요청마다 항상 새 객체를 만든다.", + "필드가 자동으로 불변이 된다." + ], + "correctIndex": 1, + "explanation": "스코프는 인스턴스 수명과 공유 범위입니다. 공유되는 가변 상태의 동시성은 개발자가 설계해야 합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "Spring의 빈 조립과 서비스의 업무 책임을 분리합니다.", + "tasks": [ + "Config의 @Bean 메서드로 PricePolicy와 OrderService를 조립하세요.", + "PricePolicy는 소계 30000 이상 배송비 0, 미만 3000입니다. OrderService는 생성자로 정책을 받습니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Spring IoC container", + "url": "https://docs.spring.io/spring-framework/reference/6.2/core/beans.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring bean scopes", + "url": "https://docs.spring.io/spring-framework/reference/6.2/core/beans/factory-scopes.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "spring-di-project", + "professionalExercise": { + "id": "spring-di-project", + "profileId": "professional-spring-core", + "mode": "project", + "instructions": [ + "Config의 @Bean 메서드로 PricePolicy와 OrderService를 조립하세요.", + "PricePolicy는 소계 30000 이상 배송비 0, 미만 3000입니다. OrderService는 생성자로 정책을 받습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/DiLab.java" + ], + "template": "package edu.lab;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\npublic final class DiLab {\n public interface PricePolicy { int fee(int subtotal); }\n public static final class OrderService {\n private final PricePolicy policy;\n public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); }\n public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); }\n }\n @Configuration\n public static class Config {\n @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; }\n @Bean public OrderService orders(PricePolicy policy) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/DiLab.java", + "templatePath": "lessons/spring-di/starter/src/main/java/edu/lab/DiLab.java", + "referencePath": "lessons/spring-di/reference/src/main/java/edu/lab/DiLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/spring-di/starter/pom.xml", + "referencePath": "lessons/spring-di/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/spring-di/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "컨테이너 조립", + "정책 계약", + "기본 singleton 확인" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "test-collaborators" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "092bd10703609e7a73d032f092ed2ace7c53fdf37819c64a4c41504b156ed844", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/spring-di/reference/pom.xml b/curriculum/professional/lessons/spring-di/reference/pom.xml new file mode 100644 index 0000000..8e33048 --- /dev/null +++ b/curriculum/professional/lessons/spring-di/reference/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-diorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context diff --git a/curriculum/professional/lessons/spring-di/reference/src/main/java/edu/lab/DiLab.java b/curriculum/professional/lessons/spring-di/reference/src/main/java/edu/lab/DiLab.java new file mode 100644 index 0000000..1ff1dae --- /dev/null +++ b/curriculum/professional/lessons/spring-di/reference/src/main/java/edu/lab/DiLab.java @@ -0,0 +1,20 @@ +package edu.lab; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +public final class DiLab { + public interface PricePolicy { int fee(int subtotal); } + public static final class OrderService { + private final PricePolicy policy; + public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); } + public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); } + } + @Configuration + public static class Config { + @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; } + @Bean public OrderService orders(PricePolicy policy) { + // BEGIN EXERCISE + return new OrderService(policy); + // END EXERCISE + } + } +} diff --git a/curriculum/professional/lessons/spring-di/starter/pom.xml b/curriculum/professional/lessons/spring-di/starter/pom.xml new file mode 100644 index 0000000..8e33048 --- /dev/null +++ b/curriculum/professional/lessons/spring-di/starter/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-diorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context diff --git a/curriculum/professional/lessons/spring-di/starter/src/main/java/edu/lab/DiLab.java b/curriculum/professional/lessons/spring-di/starter/src/main/java/edu/lab/DiLab.java new file mode 100644 index 0000000..0e1f9df --- /dev/null +++ b/curriculum/professional/lessons/spring-di/starter/src/main/java/edu/lab/DiLab.java @@ -0,0 +1,18 @@ +package edu.lab; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +public final class DiLab { + public interface PricePolicy { int fee(int subtotal); } + public static final class OrderService { + private final PricePolicy policy; + public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); } + public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); } + } + @Configuration + public static class Config { + @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; } + @Bean public OrderService orders(PricePolicy policy) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + } +} diff --git a/curriculum/professional/lessons/sql-jdbc/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/sql-jdbc/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..e129355 --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,21 @@ +package edu.lab; +import java.util.UUID; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + JdbcReservations service; JdbcTemplate jdbc; + @BeforeEach void setup() { + JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + jdbc.execute("create table inventory(sku varchar(30) primary key, stock int not null check(stock >= 0))"); + jdbc.execute("create table orders(id varchar(60) primary key, quantity int not null)"); + jdbc.update("insert into inventory values('BOOK', 5)"); service = new JdbcReservations(ds); + } + int stock() { return jdbc.queryForObject("select stock from inventory where sku='BOOK'", Integer.class); } + @Test void commitsTogether() throws Exception { assertThat(service.reserve("A", 2)).isTrue(); assertThat(stock()).isEqualTo(3); assertThat(jdbc.queryForObject("select count(*) from orders", Integer.class)).isEqualTo(1); } + @Test void insufficientPreservesState() throws Exception { assertThat(service.reserve("A", 6)).isFalse(); assertThat(stock()).isEqualTo(5); } + @Test void duplicateRollsBackStock() throws Exception { service.reserve("A", 2); assertThatThrownBy(() -> service.reserve("A", 1)).isInstanceOf(java.sql.SQLException.class); assertThat(stock()).isEqualTo(3); } + @Test void invalidInput() { assertThatThrownBy(() -> service.reserve("A", 0)).isInstanceOf(IllegalArgumentException.class); assertThat(stock()).isEqualTo(5); } +} diff --git a/curriculum/professional/lessons/sql-jdbc/lesson.json b/curriculum/professional/lessons/sql-jdbc/lesson.json new file mode 100644 index 0000000..49c5bd9 --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/lesson.json @@ -0,0 +1,180 @@ +{ + "id": "sql-jdbc", + "title": "SQL·JDBC로 재고 변경과 롤백", + "description": "재고 감소와 예약 기록을 같은 DB 트랜잭션으로 묶고 SQL 실패 후 상태를 확인합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "두 개의 SQL 중 하나가 실패하면 어떤 상태가 남아야 하는가?", + "성공 시 두 변경 커밋", + "재고 부족 상태 보존", + "중복 키 실패 재고 롤백", + "잘못된 입력 거절" + ], + "theory": [ + { + "heading": "불변식은 DB 변경 조건에 남긴다", + "text": "재고를 읽고 자바에서 비교한 뒤 갱신하면 두 요청 사이에 값이 바뀔 수 있습니다. stock >= quantity 조건을 UPDATE에 포함하고 영향 행 수로 성공을 판단합니다. 이 예제는 단일 DB 안의 원자성을 다루며 분산 예약 시스템을 보장하지 않습니다." + }, + { + "heading": "자원 수명과 실패 소유권", + "text": "Connection·PreparedStatement는 try-with-resources로 닫습니다. 자동 커밋을 끈 연결에서 감소와 INSERT를 수행하고 성공 시 커밋합니다. SQL 예외 시 롤백을 시도하되 롤백 실패는 원래 예외의 suppressed로 보존합니다." + }, + { + "heading": "바인딩과 값의 의미", + "text": "PreparedStatement의 ?에는 값을 바인딩합니다. 테이블 이름이나 정렬 방향 같은 SQL 문법을 값 파라미터로 바꿀 수 없습니다. 허용 목록과 별도 쿼리 선택이 필요한 경계를 구분합니다." + } + ], + "example": { + "title": "JdbcReservations 기준 구현", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\nimport java.util.Objects;\npublic final class JdbcReservations {\n private final DataSource dataSource;\n public JdbcReservations(DataSource dataSource) { this.dataSource = Objects.requireNonNull(dataSource); }\n public boolean reserve(String orderId, int quantity) throws SQLException {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || quantity <= 0) throw new IllegalArgumentException(\"invalid reservation\");\n try (Connection connection = dataSource.getConnection()) {\n connection.setAutoCommit(false);\n try {\n int changed;\n try (PreparedStatement update = connection.prepareStatement(\"update inventory set stock = stock - ? where sku = 'BOOK' and stock >= ?\")) {\n update.setInt(1, quantity); update.setInt(2, quantity); changed = update.executeUpdate();\n }\n if (changed == 0) { connection.rollback(); return false; }\n try (PreparedStatement insert = connection.prepareStatement(\"insert into orders(id, quantity) values (?, ?)\")) {\n insert.setString(1, orderId); insert.setInt(2, quantity); insert.executeUpdate();\n }\n connection.commit(); return true;\n } catch (SQLException | RuntimeException failure) {\n try { connection.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); }\n throw failure;\n }\n }\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 4개 JUnit 테스트 통과.\n검증 항목: 성공 시 두 변경 커밋 · 재고 부족 상태 보존 · 중복 키 실패 재고 롤백 · 잘못된 입력 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/JdbcReservations.java", + "language": "java", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\nimport java.util.Objects;\npublic final class JdbcReservations {\n private final DataSource dataSource;\n public JdbcReservations(DataSource dataSource) { this.dataSource = Objects.requireNonNull(dataSource); }\n public boolean reserve(String orderId, int quantity) throws SQLException {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || quantity <= 0) throw new IllegalArgumentException(\"invalid reservation\");\n try (Connection connection = dataSource.getConnection()) {\n connection.setAutoCommit(false);\n try {\n int changed;\n try (PreparedStatement update = connection.prepareStatement(\"update inventory set stock = stock - ? where sku = 'BOOK' and stock >= ?\")) {\n update.setInt(1, quantity); update.setInt(2, quantity); changed = update.executeUpdate();\n }\n if (changed == 0) { connection.rollback(); return false; }\n try (PreparedStatement insert = connection.prepareStatement(\"insert into orders(id, quantity) values (?, ?)\")) {\n insert.setString(1, orderId); insert.setInt(2, quantity); insert.executeUpdate();\n }\n connection.commit(); return true;\n } catch (SQLException | RuntimeException failure) {\n try { connection.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); }\n throw failure;\n }\n }\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsql-jdbcorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframeworkspring-jdbc\ncom.h2databaseh2\n" + } + ] + }, + "quiz": [ + { + "id": "sql-jdbc-q1", + "question": "재고 UPDATE 뒤 예약 INSERT가 실패했다. 과제의 올바른 동작은?", + "options": [ + "재고는 줄인 채 false만 반환한다.", + "같은 연결의 트랜잭션을 롤백해 재고도 복원한다.", + "INSERT만 재시도하면 모든 중복이 해결된다.", + "예외를 무시하고 커밋한다." + ], + "correctIndex": 1, + "explanation": "두 변경은 하나의 업무 상태 전이입니다. 같은 연결의 트랜잭션을 롤백해야 예약 기록 없는 재고 감소를 남기지 않습니다." + }, + { + "id": "sql-jdbc-q2", + "question": "PreparedStatement의 ?로 직접 바인딩할 수 있는 것은?", + "options": [ + "테이블 이름", + "ORDER BY의 ASC 키워드", + "검색할 orderId 값", + "SQL 전체 문장" + ], + "correctIndex": 2, + "explanation": "파라미터는 값의 위치를 표현합니다. SQL 식별자와 문법은 허용된 쿼리 구성으로 따로 제어해야 합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "재고 감소와 예약 기록을 같은 DB 트랜잭션으로 묶고 SQL 실패 후 상태를 확인합니다.", + "tasks": [ + "reserve(orderId, quantity)의 quantity는 양수이고 orderId는 비어 있지 않습니다.", + "재고 부족이면 false, 성공이면 true이며 한 예약 행과 감소된 재고가 함께 커밋됩니다.", + "중복 orderId로 INSERT가 실패하면 재고 감소도 롤백되어야 합니다.", + "orders와 inventory는 테스트가 생성한 전용 H2 DB만 사용합니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "JDBC Connection", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.sql/java/sql/Connection.html", + "edition": "Java SE 17", + "checkedAt": "2026-10-12" + }, + { + "title": "JDBC PreparedStatement", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.sql/java/sql/PreparedStatement.html", + "edition": "Java SE 17", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring JDBC core", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/jdbc/core.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "SQL", + "JDBC", + "Transaction" + ], + "exerciseId": "sql-jdbc-project", + "professionalExercise": { + "id": "sql-jdbc-project", + "profileId": "professional-jdbc", + "mode": "project", + "instructions": [ + "reserve(orderId, quantity)의 quantity는 양수이고 orderId는 비어 있지 않습니다.", + "재고 부족이면 false, 성공이면 true이며 한 예약 행과 감소된 재고가 함께 커밋됩니다.", + "중복 orderId로 INSERT가 실패하면 재고 감소도 롤백되어야 합니다.", + "orders와 inventory는 테스트가 생성한 전용 H2 DB만 사용합니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/JdbcReservations.java" + ], + "template": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\nimport java.util.Objects;\npublic final class JdbcReservations {\n private final DataSource dataSource;\n public JdbcReservations(DataSource dataSource) { this.dataSource = Objects.requireNonNull(dataSource); }\n public boolean reserve(String orderId, int quantity) throws SQLException {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/JdbcReservations.java", + "templatePath": "lessons/sql-jdbc/starter/src/main/java/edu/lab/JdbcReservations.java", + "referencePath": "lessons/sql-jdbc/reference/src/main/java/edu/lab/JdbcReservations.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/sql-jdbc/starter/pom.xml", + "referencePath": "lessons/sql-jdbc/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/sql-jdbc/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "성공 시 두 변경 커밋", + "재고 부족 상태 보존", + "중복 키 실패 재고 롤백", + "잘못된 입력 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "test-contracts", + "core:exceptions" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "408fed487770c066b90e1e7c078dc734c57e66818a2bfeb14f65d66372dc02ba", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/sql-jdbc/reference/pom.xml b/curriculum/professional/lessons/sql-jdbc/reference/pom.xml new file mode 100644 index 0000000..b0d1329 --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsql-jdbcorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-jdbc +com.h2databaseh2 diff --git a/curriculum/professional/lessons/sql-jdbc/reference/src/main/java/edu/lab/JdbcReservations.java b/curriculum/professional/lessons/sql-jdbc/reference/src/main/java/edu/lab/JdbcReservations.java new file mode 100644 index 0000000..37877d6 --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/reference/src/main/java/edu/lab/JdbcReservations.java @@ -0,0 +1,30 @@ +package edu.lab; +import javax.sql.DataSource; +import java.sql.*; +import java.util.Objects; +public final class JdbcReservations { + private final DataSource dataSource; + public JdbcReservations(DataSource dataSource) { this.dataSource = Objects.requireNonNull(dataSource); } + public boolean reserve(String orderId, int quantity) throws SQLException { + // BEGIN EXERCISE + if (orderId == null || orderId.isBlank() || quantity <= 0) throw new IllegalArgumentException("invalid reservation"); + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + try { + int changed; + try (PreparedStatement update = connection.prepareStatement("update inventory set stock = stock - ? where sku = 'BOOK' and stock >= ?")) { + update.setInt(1, quantity); update.setInt(2, quantity); changed = update.executeUpdate(); + } + if (changed == 0) { connection.rollback(); return false; } + try (PreparedStatement insert = connection.prepareStatement("insert into orders(id, quantity) values (?, ?)")) { + insert.setString(1, orderId); insert.setInt(2, quantity); insert.executeUpdate(); + } + connection.commit(); return true; + } catch (SQLException | RuntimeException failure) { + try { connection.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); } + throw failure; + } + } + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/sql-jdbc/starter/pom.xml b/curriculum/professional/lessons/sql-jdbc/starter/pom.xml new file mode 100644 index 0000000..b0d1329 --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsql-jdbcorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-jdbc +com.h2databaseh2 diff --git a/curriculum/professional/lessons/sql-jdbc/starter/src/main/java/edu/lab/JdbcReservations.java b/curriculum/professional/lessons/sql-jdbc/starter/src/main/java/edu/lab/JdbcReservations.java new file mode 100644 index 0000000..a7f6c66 --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/starter/src/main/java/edu/lab/JdbcReservations.java @@ -0,0 +1,11 @@ +package edu.lab; +import javax.sql.DataSource; +import java.sql.*; +import java.util.Objects; +public final class JdbcReservations { + private final DataSource dataSource; + public JdbcReservations(DataSource dataSource) { this.dataSource = Objects.requireNonNull(dataSource); } + public boolean reserve(String orderId, int quantity) throws SQLException { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/test-collaborators/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/test-collaborators/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..4463e05 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,8 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class ContractTest { + @Test void oneCallAndInvalidInput() { Checkout.PaymentPort port=mock(Checkout.PaymentPort.class); Checkout c=new Checkout(port); c.submit("o-1",500); verify(port).charge("o-1",500); verifyNoMoreInteractions(port); assertThatIllegalArgumentException().isThrownBy(()->c.submit("",500)); verifyNoMoreInteractions(port); } + @Test void propagatesFailure() { Checkout.PaymentPort port=mock(Checkout.PaymentPort.class); IllegalStateException failure=new IllegalStateException("declined"); doThrow(failure).when(port).charge("o-1",500); assertThatThrownBy(()->new Checkout(port).submit("o-1",500)).isSameAs(failure); } +} diff --git a/curriculum/professional/lessons/test-collaborators/grader/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/curriculum/professional/lessons/test-collaborators/grader/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/grader/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/curriculum/professional/lessons/test-collaborators/lesson.json b/curriculum/professional/lessons/test-collaborators/lesson.json new file mode 100644 index 0000000..d494e6e --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/lesson.json @@ -0,0 +1,168 @@ +{ + "id": "test-collaborators", + "title": "Mockito로 외부 협력의 실패를 통제", + "description": "외부 결제 협력의 호출 횟수와 실패 전파를 제어합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "대역으로 무엇을 검증하며 무엇은 검증하지 못하는가?", + "결제 한 번 호출", + "잘못된 입력에서 미호출", + "협력 오류 전파" + ], + "theory": [ + { + "heading": "대역의 범위", + "text": "Mockito는 테스트에서 협력 객체의 응답과 실패를 통제합니다. 실제 네트워크·결제 시스템의 계약을 검증한 것으로 확대하지 않습니다." + }, + { + "heading": "행동 중심 검증", + "text": "유효 입력에서 한 번 호출, 잘못된 입력에서 호출하지 않음, 협력 실패의 전파처럼 의미 있는 경계를 검증합니다. private 메서드 호출 순서와 같은 내부 구현 세부에 테스트를 결합하지 않습니다." + } + ], + "example": { + "title": "Checkout 기준 구현", + "code": "package edu.lab;\npublic final class Checkout {\n public interface PaymentPort { void charge(String orderId, int cents); }\n private final PaymentPort payments;\n public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); }\n public void submit(String orderId, int cents) {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || cents <= 0) throw new IllegalArgumentException();\n payments.charge(orderId, cents);\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 결제 한 번 호출 · 잘못된 입력에서 미호출 · 협력 오류 전파.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/Checkout.java", + "language": "java", + "code": "package edu.lab;\npublic final class Checkout {\n public interface PaymentPort { void charge(String orderId, int cents); }\n private final PaymentPort payments;\n public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); }\n public void submit(String orderId, int cents) {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || cents <= 0) throw new IllegalArgumentException();\n payments.charge(orderId, cents);\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-collaboratorsorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.mockitomockito-coretest\n" + } + ] + }, + "quiz": [ + { + "id": "test-collaborators-q1", + "question": "외부 결제 협력자를 Mockito로 대체할 때 이 과제의 핵심 검증은?", + "options": [ + "서비스의 모든 private 메서드를 mock한다.", + "호출 횟수만 맞으면 잘못된 금액 전달은 허용한다.", + "외부 포트의 예외는 항상 성공으로 바꾼다.", + "유효 입력에서 한 번 호출하고 잘못된 입력에서는 호출하지 않는다." + ], + "correctIndex": 3, + "explanation": "협력의 입력과 호출 횟수는 외부 효과의 계약입니다. 내부 구현 전체를 mock하면 실제 서비스의 조합 오류를 놓칠 수 있습니다." + }, + { + "id": "test-collaborators-q2", + "question": "결제 포트가 실패했다고 자동으로 무제한 재시도하면 왜 위험한가?", + "options": [ + "첫 요청이 실제 처리됐는지 모르면 중복 결제가 생길 수 있다.", + "Mockito가 숫자를 비교하지 못한다.", + "인터페이스가 있으면 예외를 던질 수 없다.", + "생성자 주입을 사용하면 재시도가 금지된다." + ], + "correctIndex": 0, + "explanation": "외부 효과의 확정 여부와 멱등 키·재시도 정책을 함께 정해야 하며, 단순 반복 호출은 안전을 보장하지 않습니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "외부 결제 협력의 호출 횟수와 실패 전파를 제어합니다.", + "tasks": [ + "Checkout.submit(String orderId, int cents)를 완성하세요.", + "orderId는 비어 있지 않고 cents는 양수여야 합니다. 잘못된 입력은 외부 호출 전에 거절합니다.", + "PaymentPort.charge를 한 번 호출하고 실패를 삼키지 않습니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Mockito documentation", + "url": "https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html", + "edition": "Mockito 5; BOM resolved version", + "checkedAt": "2026-10-12" + }, + { + "title": "JUnit user guide", + "url": "https://docs.junit.org/5.11.4/user-guide/", + "edition": "JUnit 5", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "test-collaborators-project", + "professionalExercise": { + "id": "test-collaborators-project", + "profileId": "professional-mockito", + "mode": "project", + "instructions": [ + "Checkout.submit(String orderId, int cents)를 완성하세요.", + "orderId는 비어 있지 않고 cents는 양수여야 합니다. 잘못된 입력은 외부 호출 전에 거절합니다.", + "PaymentPort.charge를 한 번 호출하고 실패를 삼키지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/Checkout.java" + ], + "template": "package edu.lab;\npublic final class Checkout {\n public interface PaymentPort { void charge(String orderId, int cents); }\n private final PaymentPort payments;\n public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); }\n public void submit(String orderId, int cents) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/Checkout.java", + "templatePath": "lessons/test-collaborators/starter/src/main/java/edu/lab/Checkout.java", + "referencePath": "lessons/test-collaborators/reference/src/main/java/edu/lab/Checkout.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/test-collaborators/starter/pom.xml", + "referencePath": "lessons/test-collaborators/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/test-collaborators/grader/src/test/java/edu/lab/ContractTest.java" + }, + { + "path": "src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker", + "sourcePath": "lessons/test-collaborators/grader/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker" + } + ], + "behaviorCriteria": [ + "결제 한 번 호출", + "잘못된 입력에서 미호출", + "협력 오류 전파" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "test-contracts", + "core:polymorphism" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "9b36daa6c1fdc7557702c6825819f8b92f1384bb562f8b95e8b9f79368bab733", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/test-collaborators/reference/pom.xml b/curriculum/professional/lessons/test-collaborators/reference/pom.xml new file mode 100644 index 0000000..43ed171 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/reference/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-collaboratorsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.mockitomockito-coretest diff --git a/curriculum/professional/lessons/test-collaborators/reference/src/main/java/edu/lab/Checkout.java b/curriculum/professional/lessons/test-collaborators/reference/src/main/java/edu/lab/Checkout.java new file mode 100644 index 0000000..23dd761 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/reference/src/main/java/edu/lab/Checkout.java @@ -0,0 +1,12 @@ +package edu.lab; +public final class Checkout { + public interface PaymentPort { void charge(String orderId, int cents); } + private final PaymentPort payments; + public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); } + public void submit(String orderId, int cents) { + // BEGIN EXERCISE + if (orderId == null || orderId.isBlank() || cents <= 0) throw new IllegalArgumentException(); + payments.charge(orderId, cents); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/test-collaborators/starter/pom.xml b/curriculum/professional/lessons/test-collaborators/starter/pom.xml new file mode 100644 index 0000000..43ed171 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/starter/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-collaboratorsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.mockitomockito-coretest diff --git a/curriculum/professional/lessons/test-collaborators/starter/src/main/java/edu/lab/Checkout.java b/curriculum/professional/lessons/test-collaborators/starter/src/main/java/edu/lab/Checkout.java new file mode 100644 index 0000000..cd34029 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/starter/src/main/java/edu/lab/Checkout.java @@ -0,0 +1,9 @@ +package edu.lab; +public final class Checkout { + public interface PaymentPort { void charge(String orderId, int cents); } + private final PaymentPort payments; + public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); } + public void submit(String orderId, int cents) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/test-contracts/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/test-contracts/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..6ac12ce --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,7 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void preservesStateOnFailure() { Reservation r=new Reservation(3); assertThat(r.reserve(4)).isFalse(); assertThat(r.reserve(0)).isFalse(); assertThat(r.reserve(-1)).isFalse(); assertThat(r.remaining()).isEqualTo(3); } + @Test void reservesAllAndRejectsNegativeInitial() { Reservation r=new Reservation(3); assertThat(r.reserve(3)).isTrue(); assertThat(r.remaining()).isZero(); assertThat(r.reserve(1)).isFalse(); assertThatIllegalArgumentException().isThrownBy(()->new Reservation(-1)); } +} diff --git a/curriculum/professional/lessons/test-contracts/lesson.json b/curriculum/professional/lessons/test-contracts/lesson.json new file mode 100644 index 0000000..93240fe --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/lesson.json @@ -0,0 +1,161 @@ +{ + "id": "test-contracts", + "title": "JUnit·AssertJ로 경계와 상태 전이 검증", + "description": "상태 전이와 실패 후 보존할 상태를 JUnit·AssertJ로 검사합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "실패한 명령 뒤 객체의 상태는 무엇이어야 하는가?", + "전량 예약", + "실패 후 상태 보존", + "생성 불변식" + ], + "theory": [ + { + "heading": "기대값은 업무 계약에서", + "text": "테스트 기대값을 구현과 같은 수식으로 만들면 같은 결함을 공유할 수 있습니다. 예약 전후 수량과 실패 시 상태를 구체적인 값으로 확인합니다." + }, + { + "heading": "경계 검사는 상태 전이를 포함", + "text": "0·음수·전체 재고·초과 수량을 선택하고 성공 여부뿐 아니라 이후 상태도 검증합니다. 테스트 통과는 선택한 사례의 증거이며 전체 상태 공간의 증명은 아닙니다." + } + ], + "example": { + "title": "Reservation 기준 구현", + "code": "package edu.lab;\npublic final class Reservation {\n private int stock;\n public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; }\n public boolean reserve(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0 || quantity > stock) return false;\n stock -= quantity; return true;\n // END EXERCISE\n }\n public int remaining() { return stock; }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 전량 예약 · 실패 후 상태 보존 · 생성 불변식.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/Reservation.java", + "language": "java", + "code": "package edu.lab;\npublic final class Reservation {\n private int stock;\n public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; }\n public boolean reserve(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0 || quantity > stock) return false;\n stock -= quantity; return true;\n // END EXERCISE\n }\n public int remaining() { return stock; }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-contractsorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\n" + } + ] + }, + "quiz": [ + { + "id": "test-contracts-q1", + "question": "재고 부족 예약을 거절하는 테스트에 함께 필요한 검사는?", + "options": [ + "반환값만 false면 내부 재고는 확인하지 않는다.", + "예외가 없으면 항상 성공으로 본다.", + "실패 반환뿐 아니라 남은 재고가 이전 값과 같은지 확인한다.", + "private 필드를 모두 public으로 바꾼다." + ], + "correctIndex": 2, + "explanation": "거절 응답과 상태 보존은 독립적인 계약입니다. false를 반환하면서 재고를 줄이는 결함은 반환값 검사만으로 찾지 못합니다." + }, + { + "id": "test-contracts-q2", + "question": "테스트가 내부 메서드 호출 순서에만 강하게 묶일 때 생길 수 있는 비용은?", + "options": [ + "동일한 외부 동작을 유지한 리팩터링도 불필요하게 테스트를 깨뜨릴 수 있다.", + "입력 경계 검사가 자동으로 늘어난다.", + "테스트 실행 JVM이 자동 고정된다.", + "DB 트랜잭션 격리 수준이 올라간다." + ], + "correctIndex": 0, + "explanation": "외부에서 관찰하는 반환·상태·필수 협력 계약을 중심으로 검사하면 내부 구조 변경에 더 유연합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "상태 전이와 실패 후 보존할 상태를 JUnit·AssertJ로 검사합니다.", + "tasks": [ + "Reservation(int stock), boolean reserve(int quantity), int remaining() 계약을 완성하세요.", + "초기 음수는 거절합니다. 0 이하 또는 초과 예약은 false이고 상태를 보존합니다.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "JUnit user guide", + "url": "https://docs.junit.org/5.11.4/user-guide/", + "edition": "JUnit 5", + "checkedAt": "2026-10-12" + }, + { + "title": "AssertJ Core documentation", + "url": "https://assertj.github.io/doc/", + "edition": "AssertJ Core", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "test-contracts-project", + "professionalExercise": { + "id": "test-contracts-project", + "profileId": "professional-junit", + "mode": "project", + "instructions": [ + "Reservation(int stock), boolean reserve(int quantity), int remaining() 계약을 완성하세요.", + "초기 음수는 거절합니다. 0 이하 또는 초과 예약은 false이고 상태를 보존합니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/Reservation.java" + ], + "template": "package edu.lab;\npublic final class Reservation {\n private int stock;\n public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; }\n public boolean reserve(int quantity) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n public int remaining() { return stock; }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/Reservation.java", + "templatePath": "lessons/test-contracts/starter/src/main/java/edu/lab/Reservation.java", + "referencePath": "lessons/test-contracts/reference/src/main/java/edu/lab/Reservation.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/test-contracts/starter/pom.xml", + "referencePath": "lessons/test-contracts/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/test-contracts/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "전량 예약", + "실패 후 상태 보존", + "생성 불변식" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "build-maven" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "cbdeec57521987b40feed67ef6c3af7d4964f6b8804b51c5abae9447dc147028", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/test-contracts/reference/pom.xml b/curriculum/professional/lessons/test-contracts/reference/pom.xml new file mode 100644 index 0000000..89ab8ef --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/reference/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-contractsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/test-contracts/reference/src/main/java/edu/lab/Reservation.java b/curriculum/professional/lessons/test-contracts/reference/src/main/java/edu/lab/Reservation.java new file mode 100644 index 0000000..35fb05b --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/reference/src/main/java/edu/lab/Reservation.java @@ -0,0 +1,12 @@ +package edu.lab; +public final class Reservation { + private int stock; + public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; } + public boolean reserve(int quantity) { + // BEGIN EXERCISE + if (quantity <= 0 || quantity > stock) return false; + stock -= quantity; return true; + // END EXERCISE + } + public int remaining() { return stock; } +} diff --git a/curriculum/professional/lessons/test-contracts/starter/pom.xml b/curriculum/professional/lessons/test-contracts/starter/pom.xml new file mode 100644 index 0000000..89ab8ef --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/starter/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-contractsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/test-contracts/starter/src/main/java/edu/lab/Reservation.java b/curriculum/professional/lessons/test-contracts/starter/src/main/java/edu/lab/Reservation.java new file mode 100644 index 0000000..38d1418 --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/starter/src/main/java/edu/lab/Reservation.java @@ -0,0 +1,9 @@ +package edu.lab; +public final class Reservation { + private int stock; + public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; } + public boolean reserve(int quantity) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + public int remaining() { return stock; } +} diff --git a/curriculum/professional/lessons/tx-proxy/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/tx-proxy/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..4493d12 --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,21 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void externalCallRollsBack() { + try (var context = new AnnotationConfigApplicationContext(TransactionLab.Config.class)) { + var orders = context.getBean(TransactionLab.Orders.class); + assertThatThrownBy(orders::reserveThenFail).isInstanceOf(IllegalStateException.class); + assertThat(context.getBean(JdbcTemplate.class).queryForObject("select count(*) from audit_events",Integer.class)).isZero(); + } + } + @Test void selfInvocationDemonstratesMissingBoundary() { + try (var context = new AnnotationConfigApplicationContext(TransactionLab.Config.class)) { + var orders = context.getBean(TransactionLab.Orders.class); + assertThatThrownBy(orders::bypassProxy).isInstanceOf(IllegalStateException.class); + assertThat(context.getBean(JdbcTemplate.class).queryForObject("select count(*) from audit_events",Integer.class)).isEqualTo(1); + } + } +} diff --git a/curriculum/professional/lessons/tx-proxy/lesson.json b/curriculum/professional/lessons/tx-proxy/lesson.json new file mode 100644 index 0000000..8a3ff34 --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/lesson.json @@ -0,0 +1,172 @@ +{ + "id": "tx-proxy", + "title": "Spring 트랜잭션 프록시와 롤백 경계", + "description": "@Transactional 표기와 실제 프록시 진입을 구분하고 self-invocation의 실패 상태를 관찰합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "트랜잭션 어노테이션이 있어도 롤백되지 않는 경로는 무엇인가?", + "외부 프록시 호출 롤백", + "자기 호출 반례 재현", + "호출 경로 설명" + ], + "theory": [ + { + "heading": "프록시가 여는 경계", + "text": "기본 프록시 모드에서는 외부에서 프록시를 통과한 호출에 트랜잭션 조언이 적용됩니다. 같은 객체의 this 호출은 프록시를 다시 통과하지 않습니다. 어노테이션 존재만으로 모든 호출 경로가 같은 트랜잭션을 얻지는 않습니다." + }, + { + "heading": "예외와 롤백 정책", + "text": "기본 선언적 트랜잭션은 RuntimeException과 Error에 롤백합니다. checked 예외나 예외를 잡아 정상 반환하는 흐름에는 별도 정책을 검토해야 합니다. 예외 타입, 경계 밖으로의 전파, DB 변경 시점을 함께 확인합니다." + }, + { + "heading": "실패 예제의 목적", + "text": "이 과제는 안전한 외부 호출과 안전하지 않은 자기 호출을 둘 다 재현합니다. self-invocation에서 남은 행은 모범 동작이 아니라 수정해야 할 반례입니다. 해결은 역할 분리 후 다른 빈을 호출하거나 명시적 트랜잭션 경계를 사용하는 설계와 비교합니다." + } + ], + "example": { + "title": "TransactionLab 기준 구현", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.util.UUID;\nimport org.h2.jdbcx.JdbcDataSource;\nimport org.springframework.context.annotation.*;\nimport org.springframework.jdbc.core.JdbcTemplate;\nimport org.springframework.jdbc.datasource.DataSourceTransactionManager;\nimport org.springframework.transaction.PlatformTransactionManager;\nimport org.springframework.transaction.annotation.*;\npublic final class TransactionLab {\n public static class Orders {\n private final JdbcTemplate jdbc;\n public Orders(JdbcTemplate jdbc) { this.jdbc = jdbc; }\n @Transactional\n public void reserveThenFail() {\n // BEGIN EXERCISE\n jdbc.update(\"insert into audit_events(id) values('reserved')\");\n throw new IllegalStateException(\"simulated downstream failure\");\n // END EXERCISE\n }\n public void bypassProxy() { this.reserveThenFail(); }\n }\n @Configuration\n @EnableTransactionManagement(proxyTargetClass=true)\n public static class Config {\n @Bean public DataSource dataSource() {\n JdbcDataSource ds = new JdbcDataSource(); ds.setURL(\"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"); return ds;\n }\n @Bean public JdbcTemplate jdbc(DataSource ds) { JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute(\"create table audit_events(id varchar(60) primary key)\"); return jdbc; }\n @Bean public PlatformTransactionManager transactionManager(DataSource ds) { return new DataSourceTransactionManager(ds); }\n @Bean public Orders orders(JdbcTemplate jdbc) { return new Orders(jdbc); }\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 외부 프록시 호출 롤백 · 자기 호출 반례 재현 · 호출 경로 설명.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/TransactionLab.java", + "language": "java", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.util.UUID;\nimport org.h2.jdbcx.JdbcDataSource;\nimport org.springframework.context.annotation.*;\nimport org.springframework.jdbc.core.JdbcTemplate;\nimport org.springframework.jdbc.datasource.DataSourceTransactionManager;\nimport org.springframework.transaction.PlatformTransactionManager;\nimport org.springframework.transaction.annotation.*;\npublic final class TransactionLab {\n public static class Orders {\n private final JdbcTemplate jdbc;\n public Orders(JdbcTemplate jdbc) { this.jdbc = jdbc; }\n @Transactional\n public void reserveThenFail() {\n // BEGIN EXERCISE\n jdbc.update(\"insert into audit_events(id) values('reserved')\");\n throw new IllegalStateException(\"simulated downstream failure\");\n // END EXERCISE\n }\n public void bypassProxy() { this.reserveThenFail(); }\n }\n @Configuration\n @EnableTransactionManagement(proxyTargetClass=true)\n public static class Config {\n @Bean public DataSource dataSource() {\n JdbcDataSource ds = new JdbcDataSource(); ds.setURL(\"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"); return ds;\n }\n @Bean public JdbcTemplate jdbc(DataSource ds) { JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute(\"create table audit_events(id varchar(60) primary key)\"); return jdbc; }\n @Bean public PlatformTransactionManager transactionManager(DataSource ds) { return new DataSourceTransactionManager(ds); }\n @Bean public Orders orders(JdbcTemplate jdbc) { return new Orders(jdbc); }\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltx-proxyorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframeworkspring-context\norg.springframeworkspring-jdbc\ncom.h2databaseh2\n" + } + ] + }, + "quiz": [ + { + "id": "tx-proxy-q1", + "question": "기본 프록시 모드에서 같은 객체의 this 호출에 대한 설명은?", + "options": [ + "모든 this 호출은 자동으로 프록시 호출로 바뀐다.", + "프록시를 다시 지나지 않아 내부 메서드의 조언이 새로 적용되지 않는다.", + "어노테이션이 있으면 객체 생성 방식은 무관하다.", + "private 메서드에도 항상 같은 트랜잭션이 새로 생성된다." + ], + "correctIndex": 1, + "explanation": "어노테이션은 적용 조건이 필요한 메타데이터입니다. 기본 프록시 모드의 자기 호출은 조언을 다시 통과하지 않습니다." + }, + { + "id": "tx-proxy-q2", + "question": "기본 롤백 규칙에서 checked 예외가 경계 밖으로 전달된다면?", + "options": [ + "항상 rollback된다.", + "항상 JVM이 종료된다.", + "rollbackFor 등 정책을 확인해야 하며 기본 규칙은 RuntimeException·Error와 다르다.", + "예외 타입은 롤백과 무관하다." + ], + "correctIndex": 2, + "explanation": "checked 예외의 업무 의미를 기준으로 롤백 정책을 명시하고 테스트해야 합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "@Transactional 표기와 실제 프록시 진입을 구분하고 self-invocation의 실패 상태를 관찰합니다.", + "tasks": [ + "외부 프록시를 통해 reserveThenFail을 호출하면 예외 뒤 audit_events 행이 0개입니다.", + "bypassProxy는 의도적 반례로 this.reserveThenFail을 호출합니다. 별도 트랜잭션이 없어서 행 1개가 남는 차이를 설명하세요.", + "트랜잭션 빈은 Spring 컨테이너에서 가져오고 직접 new로 대체하지 않습니다.", + "학습 완료 설명에서는 업무 서비스에서 자기 호출 문제를 제거할 대안을 제시하세요.", + "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + ], + "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + }, + "sources": [ + { + "title": "Spring @Transactional semantics", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/transaction/declarative/annotations.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring declarative rollback", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/transaction/declarative/rolling-back.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Spring", + "Transaction", + "AOP" + ], + "exerciseId": "tx-proxy-project", + "professionalExercise": { + "id": "tx-proxy-project", + "profileId": "professional-spring-tx", + "mode": "project", + "instructions": [ + "외부 프록시를 통해 reserveThenFail을 호출하면 예외 뒤 audit_events 행이 0개입니다.", + "bypassProxy는 의도적 반례로 this.reserveThenFail을 호출합니다. 별도 트랜잭션이 없어서 행 1개가 남는 차이를 설명하세요.", + "트랜잭션 빈은 Spring 컨테이너에서 가져오고 직접 new로 대체하지 않습니다.", + "학습 완료 설명에서는 업무 서비스에서 자기 호출 문제를 제거할 대안을 제시하세요." + ], + "editableFiles": [ + "src/main/java/edu/lab/TransactionLab.java" + ], + "template": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.util.UUID;\nimport org.h2.jdbcx.JdbcDataSource;\nimport org.springframework.context.annotation.*;\nimport org.springframework.jdbc.core.JdbcTemplate;\nimport org.springframework.jdbc.datasource.DataSourceTransactionManager;\nimport org.springframework.transaction.PlatformTransactionManager;\nimport org.springframework.transaction.annotation.*;\npublic final class TransactionLab {\n public static class Orders {\n private final JdbcTemplate jdbc;\n public Orders(JdbcTemplate jdbc) { this.jdbc = jdbc; }\n @Transactional\n public void reserveThenFail() {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n public void bypassProxy() { this.reserveThenFail(); }\n }\n @Configuration\n @EnableTransactionManagement(proxyTargetClass=true)\n public static class Config {\n @Bean public DataSource dataSource() {\n JdbcDataSource ds = new JdbcDataSource(); ds.setURL(\"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"); return ds;\n }\n @Bean public JdbcTemplate jdbc(DataSource ds) { JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute(\"create table audit_events(id varchar(60) primary key)\"); return jdbc; }\n @Bean public PlatformTransactionManager transactionManager(DataSource ds) { return new DataSourceTransactionManager(ds); }\n @Bean public Orders orders(JdbcTemplate jdbc) { return new Orders(jdbc); }\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/TransactionLab.java", + "templatePath": "lessons/tx-proxy/starter/src/main/java/edu/lab/TransactionLab.java", + "referencePath": "lessons/tx-proxy/reference/src/main/java/edu/lab/TransactionLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/tx-proxy/starter/pom.xml", + "referencePath": "lessons/tx-proxy/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/tx-proxy/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "외부 프록시 호출 롤백", + "자기 호출 반례 재현", + "호출 경로 설명" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "spring-aop", + "sql-jdbc" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "bec5b6f575b6d3e3a8875aa5cd5db2b14f6e5e84f99e76bc0a8c435e0339a0ab", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/tx-proxy/reference/pom.xml b/curriculum/professional/lessons/tx-proxy/reference/pom.xml new file mode 100644 index 0000000..5b32865 --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/reference/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltx-proxyorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context +org.springframeworkspring-jdbc +com.h2databaseh2 diff --git a/curriculum/professional/lessons/tx-proxy/reference/src/main/java/edu/lab/TransactionLab.java b/curriculum/professional/lessons/tx-proxy/reference/src/main/java/edu/lab/TransactionLab.java new file mode 100644 index 0000000..10f15be --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/reference/src/main/java/edu/lab/TransactionLab.java @@ -0,0 +1,33 @@ +package edu.lab; +import javax.sql.DataSource; +import java.util.UUID; +import org.h2.jdbcx.JdbcDataSource; +import org.springframework.context.annotation.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.*; +public final class TransactionLab { + public static class Orders { + private final JdbcTemplate jdbc; + public Orders(JdbcTemplate jdbc) { this.jdbc = jdbc; } + @Transactional + public void reserveThenFail() { + // BEGIN EXERCISE + jdbc.update("insert into audit_events(id) values('reserved')"); + throw new IllegalStateException("simulated downstream failure"); + // END EXERCISE + } + public void bypassProxy() { this.reserveThenFail(); } + } + @Configuration + @EnableTransactionManagement(proxyTargetClass=true) + public static class Config { + @Bean public DataSource dataSource() { + JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); return ds; + } + @Bean public JdbcTemplate jdbc(DataSource ds) { JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute("create table audit_events(id varchar(60) primary key)"); return jdbc; } + @Bean public PlatformTransactionManager transactionManager(DataSource ds) { return new DataSourceTransactionManager(ds); } + @Bean public Orders orders(JdbcTemplate jdbc) { return new Orders(jdbc); } + } +} diff --git a/curriculum/professional/lessons/tx-proxy/starter/pom.xml b/curriculum/professional/lessons/tx-proxy/starter/pom.xml new file mode 100644 index 0000000..5b32865 --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/starter/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltx-proxyorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context +org.springframeworkspring-jdbc +com.h2databaseh2 diff --git a/curriculum/professional/lessons/tx-proxy/starter/src/main/java/edu/lab/TransactionLab.java b/curriculum/professional/lessons/tx-proxy/starter/src/main/java/edu/lab/TransactionLab.java new file mode 100644 index 0000000..5cae4cf --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/starter/src/main/java/edu/lab/TransactionLab.java @@ -0,0 +1,30 @@ +package edu.lab; +import javax.sql.DataSource; +import java.util.UUID; +import org.h2.jdbcx.JdbcDataSource; +import org.springframework.context.annotation.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.*; +public final class TransactionLab { + public static class Orders { + private final JdbcTemplate jdbc; + public Orders(JdbcTemplate jdbc) { this.jdbc = jdbc; } + @Transactional + public void reserveThenFail() { + throw new UnsupportedOperationException("실습 구현 필요"); + } + public void bypassProxy() { this.reserveThenFail(); } + } + @Configuration + @EnableTransactionManagement(proxyTargetClass=true) + public static class Config { + @Bean public DataSource dataSource() { + JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); return ds; + } + @Bean public JdbcTemplate jdbc(DataSource ds) { JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute("create table audit_events(id varchar(60) primary key)"); return jdbc; } + @Bean public PlatformTransactionManager transactionManager(DataSource ds) { return new DataSourceTransactionManager(ds); } + @Bean public Orders orders(JdbcTemplate jdbc) { return new Orders(jdbc); } + } +} diff --git a/curriculum/professional/pom.xml b/curriculum/professional/pom.xml new file mode 100644 index 0000000..444070f --- /dev/null +++ b/curriculum/professional/pom.xml @@ -0,0 +1,14 @@ + + 4.0.0 + edu.javaeduprofessional-parent0.1.0pom + 17UTF-83.5.16 + org.springframework.bootspring-boot-dependencies${spring-boot.version}pomimport + + ${project.basedir}/../grader/src/test/java + ${project.basedir}/../grader/src/test/resources + + org.apache.maven.pluginsmaven-compiler-plugin3.14.117true + org.apache.maven.pluginsmaven-surefire-plugin3.5.4falsetrue + + + diff --git a/curriculum/professional/profiles.json b/curriculum/professional/profiles.json new file mode 100644 index 0000000..61362f8 --- /dev/null +++ b/curriculum/professional/profiles.json @@ -0,0 +1,1547 @@ +{ + "schemaVersion": 1, + "checkedAt": "2026-10-12", + "status": "source-prepared", + "profiles": [ + { + "id": "professional-junit", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "unit-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-mockito", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.mockito", + "artifactId": "mockito-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "unit-test", + "mock-subclass-no-agent" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-lombok", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.projectlombok", + "artifactId": "lombok", + "scope": "provided", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "unit-test", + "approved-annotation-processor" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-spring-core", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework", + "artifactId": "spring-context", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "context-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-spring-aop", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework", + "artifactId": "spring-context", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.aspectj", + "artifactId": "aspectjweaver", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "context-test", + "proxy" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-spring-web", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-web", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-validation", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "mock-http-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-jdbc", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework", + "artifactId": "spring-jdbc", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "database-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-mybatis", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.mybatis", + "artifactId": "mybatis", + "scope": "compile", + "version": "3.5.19" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "database-test", + "xml-mapper" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-jpa", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.hibernate.orm", + "artifactId": "hibernate-core", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "jakarta.persistence", + "artifactId": "jakarta.persistence-api", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "database-test", + "resource-local-jpa" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-flyway", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.flywaydb", + "artifactId": "flyway-core", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "database-test", + "sql-migration" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-spring-tx", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework", + "artifactId": "spring-context", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework", + "artifactId": "spring-jdbc", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "database-test", + "spring-transaction-proxy" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-security", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-web", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-security", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.security", + "artifactId": "spring-security-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "mock-http-test", + "security-filter-chain" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-oidc-fixture", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-web", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-oauth2-client", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.security", + "artifactId": "spring-security-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "oidc-test-principal", + "lifecycle": "in-process", + "network": false, + "fullProtocolFlow": false + } + ], + "capabilities": [ + "mock-http-test", + "oidc-principal-boundary" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-jwt-local", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.security", + "artifactId": "spring-security-oauth2-jose", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.security", + "artifactId": "spring-security-oauth2-resource-server", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "test-rsa-keys", + "lifecycle": "generated-per-test", + "productionCredentials": false + } + ], + "capabilities": [ + "jwt-signature-and-claims-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-redis", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": null + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": false, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-data-redis", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "redis", + "provisioning": "optional-local-docker", + "environment": [ + "JAVA_EDU_REDIS_HOST", + "JAVA_EDU_REDIS_PORT" + ], + "lifecycle": "isolated-training-instance" + } + ], + "capabilities": [ + "redis-integration-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "not-executed", + "network": { + "required": "loopback-training-services", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-kafka", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": null + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": false, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.apache.kafka", + "artifactId": "kafka-clients", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "kafka", + "provisioning": "optional-local-docker", + "environment": [ + "JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS" + ], + "lifecycle": "isolated-training-broker" + }, + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "kafka-integration-test", + "database-deduplication" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "not-executed", + "network": { + "required": "loopback-training-services", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-observability", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "io.micrometer", + "artifactId": "micrometer-core", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "io.micrometer", + "artifactId": "micrometer-observation", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "metric-and-observation-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-gradle-ci", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven-with-gradle-comparison", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "unit-test", + "gradle-comparison", + "configuration-review" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-capstone", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-web", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-validation", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-security", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-jdbc", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.security", + "artifactId": "spring-security-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.flywaydb", + "artifactId": "flyway-core", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "http-database-security-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + } + ] +} diff --git a/desktop/build-config.cjs b/desktop/build-config.cjs new file mode 100644 index 0000000..caaf61b --- /dev/null +++ b/desktop/build-config.cjs @@ -0,0 +1,11 @@ +module.exports = { + appId: 'kr.javaedu.desktop', + productName: 'Java Edu', + asar: true, + asarUnpack: ['desktop/job-runner.ps1'], + directories: { output: 'desktop-release' }, + files: ['desktop/**', 'server/**', 'dist/**', '!dist/server/**', '!dist/.openai/**', 'curriculum/professional/**', 'package.json', '!**/target/**', '!**/node_modules/**'], + win: { target: [{ target: 'nsis', arch: ['x64'] }], artifactName: 'Java-Edu-Setup-${version}-${arch}.${ext}', signAndEditExecutable: false }, + nsis: { oneClick: false, perMachine: false, allowElevation: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true, createStartMenuShortcut: true, runAfterFinish: false, deleteAppDataOnUninstall: false }, + publish: null, +}; diff --git a/desktop/job-runner.ps1 b/desktop/job-runner.ps1 new file mode 100644 index 0000000..933cabb --- /dev/null +++ b/desktop/job-runner.ps1 @@ -0,0 +1,108 @@ +param([Parameter(Mandatory = $true)][string]$SpecPath) +$ErrorActionPreference = 'Stop' +$source = @' +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +public static class JavaEduJob { + [StructLayout(LayoutKind.Sequential)] struct BasicLimit { + public long ProcessTime, JobTime; public uint Flags; public UIntPtr MinWorking, MaxWorking; + public uint ActiveProcesses; public UIntPtr Affinity; public uint Priority, Scheduling; + } + [StructLayout(LayoutKind.Sequential)] struct IoCounters { public ulong A,B,C,D,E,F; } + [StructLayout(LayoutKind.Sequential)] struct ExtendedLimit { + public BasicLimit Basic; public IoCounters Io; public UIntPtr ProcessMemory, JobMemory, PeakProcess, PeakJob; + } + [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct StartupInfo { + public uint cb; public string reserved, desktop, title; public uint x,y,xsize,ysize,xchars,ychars,fill,flags; + public ushort show, reservedSize; public IntPtr reservedBytes, input, output, error; + } + [StructLayout(LayoutKind.Sequential)] struct ProcessInfo { public IntPtr process, thread; public uint pid, tid; } + [StructLayout(LayoutKind.Sequential)] struct StartupInfoEx { public StartupInfo basic; public IntPtr attributes; } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attr, string name); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int cls, ref ExtendedLimit limits, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool CreateProcess(string app, StringBuilder cmd, IntPtr pa, IntPtr ta, bool inherit, uint flags, IntPtr env, string cwd, ref StartupInfoEx si, out ProcessInfo pi); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, IntPtr value, IntPtr size, IntPtr old, IntPtr result); + [DllImport("kernel32.dll")] static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetExitCodeProcess(IntPtr process, out uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint WaitForMultipleObjects(uint count, IntPtr[] handles, bool all, uint milliseconds); + [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); + [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int which); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + static void Check(bool success) { if (!success) throw new Win32Exception(Marshal.GetLastWin32Error()); } + static string Quote(string value) { + var b = new StringBuilder("\""); int slash = 0; + foreach(char c in value) { + if (c == '\\') { slash++; continue; } + if (c == '"') { b.Append('\\', slash * 2 + 1); b.Append(c); slash = 0; continue; } + b.Append('\\', slash); slash = 0; b.Append(c); + } + b.Append('\\', slash * 2); b.Append('"'); return b.ToString(); + } + public static string[] Run(string executable, string[] args, string cwd, int timeout, int memory, int processLimit, uint parentPid) { + IntPtr job = IntPtr.Zero, parent = IntPtr.Zero, attributes = IntPtr.Zero, handles = IntPtr.Zero, jobList = IntPtr.Zero; + var pi = new ProcessInfo(); bool resumed = false, attributesInitialized = false; + try { + // A non-inheritable, opened process handle prevents PID-reuse races while monitoring app death. + parent = OpenProcess(0x00100000, false, parentPid); Check(parent != IntPtr.Zero); + job = CreateJobObject(IntPtr.Zero, null); Check(job != IntPtr.Zero); + var limits = new ExtendedLimit(); + limits.Basic.Flags = 0x00002000 | 0x00000008 | 0x00000200; // KILL_ON_JOB_CLOSE | ACTIVE_PROCESS | JOB_MEMORY + limits.Basic.ActiveProcesses = (uint)processLimit; limits.JobMemory = new UIntPtr((ulong)memory * 1024 * 1024); + Check(SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf(limits))); + var cmd = new StringBuilder(Quote(executable)); foreach(var arg in args) cmd.Append(" ").Append(Quote(arg)); + var si = new StartupInfoEx(); si.basic.cb = (uint)Marshal.SizeOf(si); si.basic.flags = 0x00000100; + si.basic.input = GetStdHandle(-10); si.basic.output = GetStdHandle(-11); si.basic.error = GetStdHandle(-12); + // Explicit inheritance allowlist: no helper/app/Job handles can enter learner processes. + var inherited = new List(); + foreach (var handle in new[] { si.basic.input, si.basic.output, si.basic.error }) if (handle != IntPtr.Zero && handle != new IntPtr(-1) && !inherited.Contains(handle)) inherited.Add(handle); + IntPtr attributeSize = IntPtr.Zero; InitializeProcThreadAttributeList(IntPtr.Zero, 2, 0, ref attributeSize); + attributes = Marshal.AllocHGlobal(attributeSize); Check(InitializeProcThreadAttributeList(attributes, 2, 0, ref attributeSize)); attributesInitialized = true; + handles = Marshal.AllocHGlobal(IntPtr.Size * inherited.Count); + for (int i = 0; i < inherited.Count; i++) Marshal.WriteIntPtr(handles, i * IntPtr.Size, inherited[i]); + Check(UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x00020002), handles, new IntPtr(IntPtr.Size * inherited.Count), IntPtr.Zero, IntPtr.Zero)); + // Windows 10+: membership is atomic with process creation, including cancellation before ResumeThread. + jobList = Marshal.AllocHGlobal(IntPtr.Size); Marshal.WriteIntPtr(jobList, job); + Check(UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x0002000D), jobList, new IntPtr(IntPtr.Size), IntPtr.Zero, IntPtr.Zero)); + si.attributes = attributes; + Check(CreateProcess(executable, cmd, IntPtr.Zero, IntPtr.Zero, true, 0x00000004 | 0x08000000 | 0x00080000, IntPtr.Zero, cwd, ref si, out pi)); + if (ResumeThread(pi.thread) == UInt32.MaxValue) throw new Win32Exception(Marshal.GetLastWin32Error()); + resumed = true; + var reason = "exited"; + uint wait = WaitForMultipleObjects(2, new[] { pi.process, parent }, false, (uint)timeout); + if (wait == 1) { reason = "parent_exited"; TerminateJobObject(job, 125); } + else if (wait == 0x00000102) { reason = "timeout"; TerminateJobObject(job, 124); } + else if (wait != 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + uint code; Check(GetExitCodeProcess(pi.process, out code)); + return new[] { code.ToString(), reason }; + } finally { + if (!resumed && pi.process != IntPtr.Zero) TerminateProcess(pi.process, 126); + if (job != IntPtr.Zero) { TerminateJobObject(job, 125); CloseHandle(job); } + if (pi.thread != IntPtr.Zero) CloseHandle(pi.thread); + if (pi.process != IntPtr.Zero) CloseHandle(pi.process); + if (parent != IntPtr.Zero) CloseHandle(parent); + if (attributesInitialized) DeleteProcThreadAttributeList(attributes); + if (attributes != IntPtr.Zero) Marshal.FreeHGlobal(attributes); + if (handles != IntPtr.Zero) Marshal.FreeHGlobal(handles); + if (jobList != IntPtr.Zero) Marshal.FreeHGlobal(jobList); + } + } +} +'@ +try { + $spec = Get-Content -LiteralPath $SpecPath -Raw -Encoding UTF8 | ConvertFrom-Json + Add-Type -TypeDefinition $source -Language CSharp + $result = [JavaEduJob]::Run([string]$spec.executable, [string[]]$spec.args, [string]$spec.cwd, [int]$spec.timeoutMs, [int]$spec.memoryMiB, [int]$spec.maxProcesses, [uint32]$spec.parentPid) + $report = @{ exitCode = [long]$result[0]; reason = $result[1] } | ConvertTo-Json -Compress + [System.IO.File]::WriteAllText([string]$spec.resultPath, $report, (New-Object System.Text.UTF8Encoding($false))) +} catch { + [Console]::Error.WriteLine('Java Edu process supervisor could not start or supervise the command: ' + $_.Exception.Message) + exit 126 +} diff --git a/desktop/main.cjs b/desktop/main.cjs new file mode 100644 index 0000000..4cfdb91 --- /dev/null +++ b/desktop/main.cjs @@ -0,0 +1,102 @@ +const { app, BrowserWindow, dialog, ipcMain, session, shell } = require('electron'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { createDesktopStorage } = require('./storage.cjs'); +const { externalReferenceUrl } = require('./reference-policy.cjs'); +const { mkdirSync } = require('node:fs'); + +if (!app.isPackaged && process.env.JAVA_EDU_QA_DATA) { + if (!path.isAbsolute(process.env.JAVA_EDU_QA_DATA)) throw new Error('개발 검증 데이터 경로는 절대 경로여야 합니다.'); + mkdirSync(process.env.JAVA_EDU_QA_DATA, { recursive: true }); + app.setPath('userData', process.env.JAVA_EDU_QA_DATA); +} + +let mainWindow; +let localServer; +let settings; +let engine; +let allowQuit = false; +let quitting = false; + +function authorizeIpc(event) { + if (!mainWindow || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame || new URL(event.senderFrame.url).origin !== localServer.origin) throw new Error('설치형 앱의 기본 화면에서만 사용할 수 있습니다.'); +} + +function safelyOpenReference(value) { + const url = externalReferenceUrl(value); + if (url) void shell.openExternal(url); +} + +async function initialize() { + const root = path.join(__dirname, '..'); + const dataRoot = path.join(app.getPath('userData'), 'java-edu'); + const [{ createSettingsStore }, { createLocalEngine }, { startLocalServer, SESSION_COOKIE, CSRF_HEADER }] = await Promise.all([ + import(pathToFileURL(path.join(root, 'server/local/settings.mjs'))), + import(pathToFileURL(path.join(root, 'server/local/engine.mjs'))), + import(pathToFileURL(path.join(root, 'server/local/http.mjs'))), + ]); + settings = createSettingsStore(dataRoot); + await settings.initialize(); + const storage = createDesktopStorage(dataRoot); + engine = await createLocalEngine({ dataRoot, projectRoot: path.join(root, 'curriculum/professional'), settings }); + localServer = await startLocalServer({ distRoot: path.join(root, 'dist'), engine }); + const localSession = session.fromPartition('java-edu-desktop'); // In-memory cookies: no reusable session credential on disk. + localSession.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + localSession.setPermissionCheckHandler(() => false); + await localSession.cookies.set({ url: localServer.origin, name: SESSION_COOKIE, value: localServer.sessionToken, httpOnly: true, sameSite: 'strict', path: '/' }); + mainWindow = new BrowserWindow({ width: 1440, height: 940, minWidth: 720, minHeight: 560, show: false, title: 'Java Edu', backgroundColor: '#f5f2e9', + webPreferences: { preload: path.join(__dirname, 'preload.cjs'), partition: 'java-edu-desktop', contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, allowRunningInsecureContent: false, spellcheck: false } }); + mainWindow.removeMenu(); + localSession.webRequest.onBeforeRequest((details, callback) => { + const allowed = details.webContentsId === mainWindow?.webContents.id && new URL(details.url).origin === localServer.origin; + callback({ cancel: !allowed }); + }); + localSession.webRequest.onBeforeSendHeaders({ urls: [`${localServer.origin}/*`] }, (details, callback) => { + const headers = { ...details.requestHeaders }; + for (const key of Object.keys(headers)) if (key.toLowerCase() === CSRF_HEADER) delete headers[key]; + if (details.webContentsId === mainWindow?.webContents.id && new URL(details.url).origin === localServer.origin) headers[CSRF_HEADER] = localServer.csrfToken; + callback({ requestHeaders: headers }); + }); + mainWindow.webContents.on('will-navigate', (event, url) => { event.preventDefault(); safelyOpenReference(url); }); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { safelyOpenReference(url); return { action: 'deny' }; }); + mainWindow.webContents.on('will-attach-webview', (event) => event.preventDefault()); + localSession.on('will-download', (event) => event.preventDefault()); + const ipc = (channel, handler) => ipcMain.handle(channel, async (event, ...args) => { authorizeIpc(event); return handler(...args); }); + ipc('java-edu:settings', () => engine.runtime()); + ipc('java-edu:choose-jdk', async (version) => { + if (![8, 11, 17, 21].includes(version)) throw new Error('지원하는 JDK 버전을 선택하세요.'); + const selected = await dialog.showOpenDialog(mainWindow, { title: `Java ${version} JDK 폴더 선택 (bin 폴더의 상위)`, properties: ['openDirectory'] }); + if (!selected.canceled) await settings.chooseJdk(selected.filePaths[0], version); + return engine.runtime(); + }); + ipc('java-edu:choose-maven', async () => { + const selected = await dialog.showOpenDialog(mainWindow, { title: 'Apache Maven 3.9 이상 배포본 폴더 선택', properties: ['openDirectory'] }); + if (!selected.canceled) await settings.chooseMaven(selected.filePaths[0]); + return engine.runtime(); + }); + ipc('java-edu:consent', async (accepted) => { await settings.setConsent(accepted); if (!accepted) await engine.cancelActive(); return engine.runtime(); }); + ipcMain.on('java-edu:storage', (event, input) => { + try { + authorizeIpc(event); + if (!input || !['getItem', 'setItem', 'removeItem'].includes(input.method)) throw new Error('지원하지 않는 저장 요청입니다.'); + event.returnValue = { value: storage[input.method](input.key, input.value) }; + } catch (error) { event.returnValue = { error: error.message }; } + }); + mainWindow.once('ready-to-show', () => mainWindow.show()); + await mainWindow.loadURL(localServer.origin); +} + +app.setAppUserModelId('kr.javaedu.desktop'); +if (!app.requestSingleInstanceLock()) app.quit(); +else { + app.on('second-instance', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } }); + app.whenReady().then(initialize).catch(async (error) => { dialog.showErrorBox('Java Edu 시작 실패', error.message); await localServer?.close(); allowQuit = true; app.quit(); }); + app.on('window-all-closed', () => app.quit()); + app.on('before-quit', (event) => { + if (allowQuit) return; + event.preventDefault(); + if (quitting) return; + quitting = true; + Promise.resolve(localServer?.close()).finally(() => { allowQuit = true; app.quit(); }); + }); +} diff --git a/desktop/preload.cjs b/desktop/preload.cjs new file mode 100644 index 0000000..eb4e8f2 --- /dev/null +++ b/desktop/preload.cjs @@ -0,0 +1,20 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +function storageCall(method, key, value) { + const result = ipcRenderer.sendSync('java-edu:storage', { method, key, value }); + if (result.error) throw new Error(result.error); + return result.value ?? null; +} + +contextBridge.exposeInMainWorld('javaEduDesktop', Object.freeze({ + platform: process.platform, + getSettings: () => ipcRenderer.invoke('java-edu:settings'), + chooseJdk: (version) => ipcRenderer.invoke('java-edu:choose-jdk', version), + chooseMaven: () => ipcRenderer.invoke('java-edu:choose-maven'), + setExecutionConsent: (accepted) => ipcRenderer.invoke('java-edu:consent', accepted), + storage: Object.freeze({ + getItem: (key) => storageCall('getItem', key), + setItem: (key, value) => storageCall('setItem', key, value), + removeItem: (key) => storageCall('removeItem', key), + }), +})); diff --git a/desktop/reference-policy.cjs b/desktop/reference-policy.cjs new file mode 100644 index 0000000..b49aae4 --- /dev/null +++ b/desktop/reference-policy.cjs @@ -0,0 +1,55 @@ +// Explicitly reviewed textbook, tool download and hiring-evidence hosts. +// New lesson sources must pass the curriculum coverage test before release. +const EXTERNAL_HOSTS = new Set([ + 'adoptium.net', + 'assertj.github.io', + 'career.rememberapp.co.kr', + 'dev.java', + 'docs.docker.com', + 'docs.github.com', + 'docs.gradle.org', + 'docs.hibernate.org', + 'docs.junit.org', + 'docs.micrometer.io', + 'docs.oracle.com', + 'docs.spring.io', + 'documentation.red-gate.com', + 'github.com', + 'jakarta.ee', + 'java.testcontainers.org', + 'javadoc.io', + 'junit.org', + 'jumpit.saramin.co.kr', + 'kafka.apache.org', + 'maven.apache.org', + 'mybatis.org', + 'openid.net', + 'openjdk.org', + 'projectlombok.org', + 'redis.io', + 'site.mockito.org', + 'spring.io', + 'testcontainers.com', + 'toss.im', + 'www.jobplanet.co.kr', + 'www.jumpit.co.kr', + 'www.rallit.com', + 'www.rfc-editor.org', + 'www.wanted.co.kr', + 'www.work24.go.kr', +]); + +function externalReferenceUrl(value) { + if (typeof value !== 'string' || value.length > 8192 || /[\u0000-\u0020\u007f\\]/.test(value)) return null; + // Check the original authority as URL normalization removes explicit :443. + // Credentials, ports and encoded hostnames are not textbook link forms. + const authority = /^https:\/\/([^/?#]+)(?:[/?#]|$)/i.exec(value)?.[1]; + if (!authority || /[:@%]/.test(authority)) return null; + try { + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password || url.port || !EXTERNAL_HOSTS.has(url.hostname)) return null; + return url.href; + } catch { return null; } +} + +module.exports = { externalReferenceUrl }; diff --git a/desktop/storage.cjs b/desktop/storage.cjs new file mode 100644 index 0000000..b7cc3bf --- /dev/null +++ b/desktop/storage.cjs @@ -0,0 +1,29 @@ +const { readFileSync, writeFileSync, renameSync } = require('node:fs'); +const path = require('node:path'); + +const MAX_BYTES = 8 * 1024 * 1024; +const validKey = (key) => typeof key === 'string' && /^java-edu:[a-z0-9:-]{1,100}$/.test(key); + +module.exports.createDesktopStorage = (dataRoot) => { + const filename = path.join(dataRoot, 'learning-state.json'); + let data = Object.create(null); + try { + const text = readFileSync(filename, 'utf8'); + if (Buffer.byteLength(text) <= MAX_BYTES) { + const stored = JSON.parse(text); + for (const [key, value] of Object.entries(stored)) if (validKey(key) && typeof value === 'string') data[key] = value; + } + } catch { /* New or corrupt storage starts with an empty, recoverable learning state. */ } + const save = (next) => { + const text = JSON.stringify(next); + if (Buffer.byteLength(text) > MAX_BYTES) throw new Error('학습 저장 공간 8 MiB를 초과했습니다.'); + writeFileSync(`${filename}.tmp`, text, { mode: 0o600 }); + renameSync(`${filename}.tmp`, filename); + data = next; + }; + return { + getItem(key) { if (!validKey(key)) throw new Error('지원하지 않는 학습 데이터 키입니다.'); return data[key] ?? null; }, + setItem(key, value) { if (!validKey(key) || typeof value !== 'string') throw new Error('학습 데이터 형식을 확인하세요.'); save({ ...data, [key]: value }); }, + removeItem(key) { if (!validKey(key)) throw new Error('지원하지 않는 학습 데이터 키입니다.'); const next = { ...data }; delete next[key]; save(next); }, + }; +}; diff --git a/dist/app.js b/dist/app.js index d53a86b..9d68ce2 100644 --- a/dist/app.js +++ b/dist/app.js @@ -8,7 +8,11 @@ const TRANSITION_IDS = new Set(['java-basics', 'oop', 'polymorphism', 'collectio const ADVANCED_IDS = new Set(['oop', 'polymorphism', 'generics', 'streams', 'concurrency', 'records-sealed', 'java21', 'testing-design']); const EXECUTION_STATUSES = new Set(['passed', 'failed', 'compile_error', 'runtime_error', 'timeout', 'provider_error']); const SOURCE_LIMIT = 50000; +const PROJECT_SOURCE_LIMIT = 65536; +const PROJECT_SNAPSHOT_LIMIT = 384 * 1024; const NOTE_LIMIT = 20000; +const desktop = window.javaEduDesktop || null; +const isDesktop = Boolean(desktop); const mobileMedia = window.matchMedia('(max-width: 899px)'); let lessons = []; let exercises = []; @@ -22,10 +26,12 @@ let activeEditor = null; const state = { version: 21, track: 'all', search: '', lessonId: '', step: 'theory', quizPassed: new Set(), labPassed: new Map(), drafts: new Map(), notes: new Map(), + fileDrafts: new Map(), activeFiles: new Map(), projectInfo: new Map(), quizAnswers: new Map(), quizResults: new Map(), labResults: new Map(), undo: new Map(), storage: 'ready', runtime: null, runtimeStatus: 'checking', runtimeMessage: '', pending: null, menuOpen: false, editorTheme: 'dark', editorWrap: false, + runtimeVersion: null, settingsBusy: false, settingsMessage: '', cancelling: false, }; const dom = Object.fromEntries([ @@ -91,10 +97,34 @@ function toast(message) { function currentLesson() { return visibleLessons().find((lesson) => lesson.id === state.lessonId); } function exerciseFor(lesson) { return lesson && exercises.find((exercise) => exercise.id === lesson.exerciseId || exercise.lessonId === lesson.id); } -function sourceFor(exercise) { return state.drafts.has(exercise.id) ? state.drafts.get(exercise.id) : exercise.template; } +function storageAdapter() { return desktop?.storage ?? window.localStorage; } +function isProject(exercise) { return exercise?.mode === 'project'; } +function projectFiles(exercise) { return state.projectInfo.get(exercise.id)?.files || exercise.files || []; } +function activeFile(exercise) { + if (!isProject(exercise)) return null; + const files = projectFiles(exercise); + return files.find((file) => file.path === state.activeFiles.get(exercise.id)) || files.find((file) => file.editable) || files[0]; +} +function fileKey(exercise, path = activeFile(exercise)?.path) { return isProject(exercise) ? `${exercise.id}::${path}` : exercise.id; } +function sourceFor(exercise, path = activeFile(exercise)?.path) { + if (!isProject(exercise)) return state.drafts.has(exercise.id) ? state.drafts.get(exercise.id) : exercise.template; + const file = projectFiles(exercise).find((item) => item.path === path); + return file?.editable && state.fileDrafts.has(fileKey(exercise, path)) ? state.fileDrafts.get(fileKey(exercise, path)) : file?.source || ''; +} +function setSource(exercise, value, path = activeFile(exercise)?.path) { + if (isProject(exercise)) state.fileDrafts.set(fileKey(exercise, path), value); + else state.drafts.set(exercise.id, value); +} +function sourcesFor(exercise) { + return Object.fromEntries(projectFiles(exercise).filter((file) => file.editable).map((file) => [file.path, sourceFor(exercise, file.path)]).sort(([a], [b]) => a.localeCompare(b))); +} +function sourceSnapshot(exercise) { return isProject(exercise) ? JSON.stringify(sourcesFor(exercise)) : sourceFor(exercise); } +function sourceLimit(exercise) { return isProject(exercise) ? PROJECT_SOURCE_LIMIT : SOURCE_LIMIT; } +function utf8Size(value) { return new TextEncoder().encode(value).byteLength; } +function fileLanguage(path) { return path.endsWith('.java') ? 'Java' : `${path.split('.').at(-1).toUpperCase()} · 일반 텍스트`; } function labComplete(lesson) { const exercise = exerciseFor(lesson); - return Boolean(exercise && state.labPassed.has(exercise.id) && state.labPassed.get(exercise.id) === sourceFor(exercise)); + return Boolean(exercise && state.labPassed.has(exercise.id) && state.labPassed.get(exercise.id) === sourceSnapshot(exercise)); } function lessonComplete(lesson) { return state.quizPassed.has(lesson.id) && labComplete(lesson); } @@ -102,7 +132,9 @@ function visibleLessons() { const query = state.search.trim().toLocaleLowerCase('ko'); return lessons.filter((lesson) => { if (Number(lesson.minVersion) > state.version) return false; - if (state.track !== 'all') { + if (state.track === 'project') { + if (!isProject(exerciseFor(lesson))) return false; + } else if (state.track !== 'all') { const hasDeclaredTrack = ['transition', 'advanced', 'both'].includes(lesson.track); const fallback = state.track === 'transition' ? TRANSITION_IDS : ADVANCED_IDS; if (hasDeclaredTrack ? lesson.track !== state.track && lesson.track !== 'both' : !fallback.has(lesson.id)) return false; @@ -127,7 +159,7 @@ function ensureSelection() { function restoreStorage() { let raw; - try { raw = window.localStorage.getItem(STORAGE_KEY); } + try { raw = storageAdapter().getItem(STORAGE_KEY); } catch { state.storage = 'unavailable'; return; } if (!raw) return; try { @@ -140,7 +172,7 @@ function restoreStorage() { } for (const [key, destination, idKey, collection, limit] of [ ['drafts', state.drafts, 'exerciseId', exercises, SOURCE_LIMIT], - ['labPassed', state.labPassed, 'exerciseId', exercises, SOURCE_LIMIT], + ['labPassed', state.labPassed, 'exerciseId', exercises, PROJECT_SNAPSHOT_LIMIT], ['notes', state.notes, 'lessonId', lessons, NOTE_LIMIT], ]) { if (!Array.isArray(record[key])) continue; @@ -151,6 +183,13 @@ function restoreStorage() { destination.set(entry[idKey], value); } } + if (Array.isArray(record.fileDrafts)) { + for (const entry of record.fileDrafts) { + const exercise = exercises.find((item) => item.id === entry?.exerciseId && isProject(item)); + const file = exercise?.files?.find((item) => item.path === entry.path && item.editable); + if (file && typeof entry.source === 'string' && entry.source.length <= PROJECT_SOURCE_LIMIT) state.fileDrafts.set(fileKey(exercise, entry.path), entry.source); + } + } } catch { state.storage = 'invalid'; } @@ -158,7 +197,7 @@ function restoreStorage() { function restoreEditorPreferences() { try { - const preferences = JSON.parse(window.localStorage.getItem(EDITOR_PREFERENCES_KEY)); + const preferences = JSON.parse(storageAdapter().getItem(EDITOR_PREFERENCES_KEY)); if (!preferences || typeof preferences !== 'object') return; if (EDITOR_THEMES.includes(preferences.theme)) state.editorTheme = preferences.theme; if (typeof preferences.wrap === 'boolean') state.editorWrap = preferences.wrap; @@ -167,11 +206,11 @@ function restoreEditorPreferences() { function saveEditorPreferences() { try { - window.localStorage.setItem(EDITOR_PREFERENCES_KEY, JSON.stringify({ theme: state.editorTheme, wrap: state.editorWrap })); + storageAdapter().setItem(EDITOR_PREFERENCES_KEY, JSON.stringify({ theme: state.editorTheme, wrap: state.editorWrap })); } catch { state.storage = 'unavailable'; updateStorageIndicator(); - toast('코드 테마는 이번 방문 동안 적용됩니다. 브라우저 저장을 사용할 수 없습니다.'); + toast('코드 테마는 현재 화면에서만 적용됩니다. 학습 기록 저장소를 사용할 수 없습니다.'); } } @@ -218,13 +257,15 @@ function destroyCodeEditor() { function updateStorageIndicator() { const indicator = document.getElementById('storage-indicator'); indicator.classList.toggle('is-unavailable', state.storage !== 'ready'); - const label = state.storage === 'unavailable' ? '이번 방문 동안만 기록' : state.storage === 'invalid' ? '기존 기록을 읽지 못했습니다' : '이 브라우저에 저장'; + const location = isDesktop ? '이 PC의 앱에 저장' : '이 브라우저에 저장'; + const label = state.storage === 'unavailable' ? '현재 화면 동안만 기록' : state.storage === 'invalid' ? '기존 기록을 읽지 못했습니다' : location; document.getElementById('storage-label').textContent = label; - indicator.title = state.storage === 'unavailable' ? '브라우저 저장소를 사용할 수 없습니다. 작성 코드를 별도로 복사해 주세요.' : '진도와 코드는 현재 브라우저에만 저장됩니다.'; + indicator.title = state.storage === 'unavailable' ? '학습 기록 저장소를 사용할 수 없습니다. 작성 코드를 별도로 복사해 주세요.' : isDesktop ? '진도와 코드는 이 PC의 앱 데이터에 저장되며 앱 재시작 후에도 유지됩니다.' : '진도와 코드는 현재 브라우저에만 저장됩니다.'; + document.getElementById('storage-location-badge').textContent = isDesktop ? '이 PC의 앱 기록' : '브라우저 기록'; const editorSave = document.getElementById('editor-save-state'); - if (editorSave) editorSave.textContent = state.storage === 'unavailable' ? '저장 불가 · 코드 복사 권장' : '브라우저에 저장'; + if (editorSave) editorSave.textContent = state.storage === 'unavailable' ? '저장 불가 · 코드 복사 권장' : location; const notesSave = document.getElementById('notes-save-state'); - if (notesSave) notesSave.textContent = state.storage === 'unavailable' ? '브라우저 저장을 사용할 수 없어 이번 방문 동안만 유지됩니다.' : '이 브라우저에 저장됩니다. 설계 메모는 자동 평가하지 않습니다.'; + if (notesSave) notesSave.textContent = state.storage === 'unavailable' ? '저장소를 사용할 수 없어 현재 화면 동안만 유지됩니다.' : `${location}됩니다. 설계 메모는 자동 평가하지 않습니다.`; } function saveStorage() { @@ -234,13 +275,14 @@ function saveStorage() { quizPassed: [...state.quizPassed], labPassed: [...state.labPassed].map(([exerciseId, source]) => ({ exerciseId, source })), drafts: [...state.drafts].map(([exerciseId, source]) => ({ exerciseId, source })), + fileDrafts: [...state.fileDrafts].map(([key, source]) => { const [exerciseId, path] = key.split('::'); return { exerciseId, path, source }; }), notes: [...state.notes].map(([lessonId, text]) => ({ lessonId, text })), }; try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record)); + storageAdapter().setItem(STORAGE_KEY, JSON.stringify(record)); state.storage = 'ready'; } catch { - if (state.storage !== 'unavailable') toast('브라우저에 저장할 수 없습니다. 작성 코드를 별도로 복사해 주세요.'); + if (state.storage !== 'unavailable') toast('학습 기록을 저장할 수 없습니다. 작성 코드를 별도로 복사해 주세요.'); state.storage = 'unavailable'; } updateStorageIndicator(); @@ -327,7 +369,7 @@ function renderSidebar() { const copy = el('span', 'lesson-link-copy'); copy.append(el('span', 'lesson-link-title', lesson.title)); const meta = el('span', 'lesson-link-meta'); - meta.append(el('span', '', `Java ${lesson.minVersion}+`), el('span', '', complete ? '완료' : lesson.track === 'advanced' ? '실무 심화' : lesson.track === 'both' ? '공통' : 'Java 전환')); + meta.append(el('span', '', `Java ${lesson.minVersion}+`), el('span', '', complete ? '완료' : isProject(exerciseFor(lesson)) ? '실무 프로젝트' : lesson.track === 'advanced' ? '실무 심화' : lesson.track === 'both' ? '공통' : 'Java 전환')); copy.append(meta); link.append(number, copy); link.addEventListener('click', (event) => { @@ -355,7 +397,7 @@ function renderProgress() { progress.setAttribute('aria-valuetext', `전체 ${lessons.length}개 수업 중 ${count}개 완료`); document.getElementById('overall-progress-fill').style.width = `${lessons.length ? (count / lessons.length) * 100 : 0}%`; const next = continuationLesson(); - document.getElementById('continue-label').textContent = next && lessonComplete(next) ? '완료한 수업 다시 보기' : state.quizPassed.size || state.drafts.size ? '이어서 학습하기' : '학습 시작하기'; + document.getElementById('continue-label').textContent = next && lessonComplete(next) ? '완료한 수업 다시 보기' : state.quizPassed.size || state.drafts.size || state.fileDrafts.size ? '이어서 학습하기' : '학습 시작하기'; document.getElementById('continue-title').textContent = next?.title || '수업을 준비하고 있습니다'; document.getElementById('continue-button').disabled = !next; } @@ -409,6 +451,7 @@ function renderLesson() { if (lesson.featureSince) dom['lesson-meta'].append(el('span', 'meta-chip is-neutral', `기능 도입 ${String(lesson.featureSince)}`)); const trackLabel = lesson.track === 'both' ? '전환 · 심화 공통' : lesson.track === 'advanced' ? '실무 심화' : 'Java 전환'; dom['lesson-meta'].append(el('span', 'meta-chip is-neutral', trackLabel)); + if (isProject(exerciseFor(lesson))) dom['lesson-meta'].append(el('span', 'meta-chip is-neutral', '실무 프로젝트 · Windows 앱 실행')); if (lesson.duration) { const time = el('span', 'meta-chip is-neutral'); time.append(icon('clock'), document.createTextNode(typeof lesson.duration === 'number' ? `${lesson.duration}분` : lesson.duration)); @@ -546,6 +589,35 @@ async function copyText(text, selectableNode) { } } +function fileTabs(files, selectedPath, onSelect, prefix, panelId, label) { + const tabs = el('div', 'file-tabs'); + tabs.setAttribute('role', 'tablist'); + tabs.setAttribute('aria-label', label); + files.forEach((file, index) => { + const button = actionButton(file.path.split('/').at(-1), 'file-tab', () => { + onSelect(file.path); + document.getElementById(`${prefix}-${index}`)?.focus({ preventScroll: true }); + }, `${prefix}-${index}`); + button.title = file.path; + button.dataset.filePath = file.path; + button.setAttribute('role', 'tab'); + button.setAttribute('aria-controls', panelId); + button.setAttribute('aria-selected', String(file.path === selectedPath)); + button.setAttribute('aria-label', `${file.path}${file.editable === false ? ' · 읽기 전용' : ''}`); + button.tabIndex = file.path === selectedPath ? 0 : -1; + if (file.editable === false) button.append(el('span', 'file-readonly-badge', '읽기 전용')); + button.addEventListener('keydown', (event) => { + const next = event.key === 'ArrowRight' ? (index + 1) % files.length : event.key === 'ArrowLeft' ? (index + files.length - 1) % files.length : event.key === 'Home' ? 0 : event.key === 'End' ? files.length - 1 : -1; + if (next < 0) return; + event.preventDefault(); + onSelect(files[next].path); + document.getElementById(`${prefix}-${next}`)?.focus({ preventScroll: true }); + }); + tabs.append(button); + }); + return tabs; +} + function renderExample(lesson) { const example = lesson.example; if (!example) return; @@ -553,18 +625,38 @@ function renderExample(lesson) { const shell = el('div', 'code-shell'); shell.dataset.editorTheme = state.editorTheme; const heading = el('div', 'code-heading'); - const filename = el('span'); filename.append(icon('code'), document.createTextNode('Main.java')); + const files = Array.isArray(example.files) && example.files.length ? example.files : [{ path: 'Main.java', language: 'java', code: example.code }]; + let selected = files[0]; + const filename = el('span', 'example-filename'); const pre = el('pre', 'code-block'); pre.tabIndex = 0; pre.setAttribute('aria-label', '학습 예제 소스 코드'); + pre.id = 'example-file-panel'; const code = el('code'); - if (editorLibrary) editorLibrary.highlightJavaCode(code, example.code); - else code.textContent = example.code; pre.append(code); - const copy = actionButton('코드 복사', 'copy-button', () => copyText(example.code, pre), 'copy-example'); + const copy = actionButton('코드 복사', 'copy-button', () => copyText(selected.code, pre), 'copy-example'); copy.prepend(icon('copy')); heading.append(filename, copy); - shell.append(heading, codePreferences(), syntaxLegend(), pre); + const legend = syntaxLegend(); + const tabsHost = el('div'); + function selectFile(path) { + selected = files.find((file) => file.path === path) || files[0]; + filename.replaceChildren(icon('code'), document.createTextNode(selected.path)); + filename.append(el('span', 'file-readonly-badge', `${fileLanguage(selected.path)} · 읽기 전용 예제`)); + pre.setAttribute('aria-label', `${selected.path} · ${fileLanguage(selected.path)} · 읽기 전용 예제`); + const java = selected.language === 'java' || selected.path.endsWith('.java'); + if (editorLibrary && java) editorLibrary.highlightJavaCode(code, selected.code); + else code.textContent = selected.code; + legend.hidden = !java; + if (files.length > 1) { + tabsHost.replaceChildren(fileTabs(files, selected.path, selectFile, 'example-file', pre.id, '예제 파일')); + pre.setAttribute('role', 'tabpanel'); + pre.setAttribute('aria-labelledby', `example-file-${files.indexOf(selected)}`); + } + pre.scrollLeft = 0; + } + shell.append(heading, codePreferences(), legend, tabsHost, pre); + selectFile(selected.path); const output = el('div', 'expected-output'); output.append(el('p', 'small-label', '예상 출력 · 실행 결과가 아닙니다'), el('pre', '', example.output || '(표준 출력 없음)')); dom['lesson-panel'].append(shell, el('p', 'inline-note syntax-note', editorLibrary ? '색상은 코드의 구문을 구분하며, 컴파일 결과를 대신하지 않습니다.' : '문법 강조를 불러오지 못해 원문으로 표시합니다.'), output, el('p', 'explanation', example.explanation), nextStepButton('quiz', '문제로 이해 확인하기')); @@ -707,8 +799,8 @@ function renderLab(lesson) { return; } const heading = el('div', 'lab-header'); - heading.append(el('h3', '', exercise.title), el('span', 'lab-mode', exercise.mode === 'class' ? 'CLASS DESIGN' : 'METHOD IMPLEMENTATION')); - dom['lesson-panel'].append(heading, el('p', 'section-caption', exercise.mode === 'class' ? '주어진 계약을 만족하는 클래스를 직접 설계하세요. 클래스 전체를 편집할 수 있습니다.' : '제공된 클래스와 메서드 골격을 완성하세요. 클래스 전체를 편집할 수 있습니다.')); + heading.append(el('h3', '', exercise.title), el('span', 'lab-mode', isProject(exercise) ? 'PROJECT LAB' : exercise.mode === 'class' ? 'CLASS DESIGN' : 'METHOD IMPLEMENTATION')); + dom['lesson-panel'].append(heading, el('p', 'section-caption', isProject(exercise) ? '파일을 오가며 업무 계약을 구현하세요. 편집 가능한 Java 파일 전체를 함께 검증합니다. POM과 지원 리소스는 읽기 전용입니다.' : exercise.mode === 'class' ? '주어진 계약을 만족하는 클래스를 직접 설계하세요. 클래스 전체를 편집할 수 있습니다.' : '제공된 클래스와 메서드 골격을 완성하세요. 클래스 전체를 편집할 수 있습니다.')); const runtime = el('div', 'runtime-card'); runtime.id = 'runtime-info'; runtime.setAttribute('aria-live', 'polite'); @@ -733,7 +825,7 @@ function renderLab(lesson) { editorShell.dataset.editorTheme = state.editorTheme; const toolbar = el('div', 'editor-toolbar'); const filename = el('span', 'editor-filename'); - filename.append(icon('code'), document.createTextNode('Solution · Java')); + filename.id = 'editor-filename'; const editorTools = el('div', 'editor-tools'); const editor = el('div', 'code-editor'); editor.id = 'code-editor'; @@ -753,11 +845,13 @@ function renderLab(lesson) { wrapToggle, actionButton('복사', '', () => copyText(activeEditor?.getValue() ?? sourceFor(exercise), activeEditor), 'copy-code'), actionButton('초기 코드', '', () => { + if (isProject(exercise) && !activeFile(exercise)?.editable) return; const previous = activeEditor?.getValue() ?? sourceFor(exercise); - if (previous === exercise.template) { toast('이미 초기 코드입니다.'); return; } - state.undo.set(exercise.id, previous); - state.drafts.set(exercise.id, exercise.template); - activeEditor?.setValue(exercise.template); + const template = isProject(exercise) ? activeFile(exercise).source : exercise.template; + if (previous === template) { toast('이미 초기 코드입니다.'); return; } + state.undo.set(fileKey(exercise), previous); + setSource(exercise, template); + activeEditor?.setValue(template); refreshDraftDependentUi(exercise); renderUndo(exercise); saveStorage(); @@ -769,21 +863,24 @@ function renderLab(lesson) { const lines = el('span'); lines.id = 'editor-line-count'; const save = el('span'); save.id = 'editor-save-state'; editorStatus.append(lines, save); - editorShell.append(toolbar, codePreferences(), syntaxLegend(), editor, editorStatus); - const hint = el('p', 'editor-hint', '문법 강조 · 줄 번호 · 괄호 짝 표시 · Tab은 다음 항목으로 이동합니다. 들여쓰기 버튼은 선택한 줄에 공백 4칸을 넣습니다. 색상은 구문을 구분하며 컴파일 결과를 대신하지 않습니다. class Solution 골격을 유지하고 최상위 public class·package·Main 선언은 추가하지 마세요. 실행 요청은 UTF-8 32 KiB까지 전송할 수 있습니다.'); + const tabsHost = el('div'); tabsHost.id = 'project-file-tabs'; + const legend = syntaxLegend(); legend.id = 'editor-syntax-legend'; + editorShell.append(toolbar, codePreferences(), legend, tabsHost, editor, editorStatus); + const hint = el('p', 'editor-hint', `Tab은 다음 항목으로 이동하고 들여쓰기 버튼은 선택 줄에 공백 4칸을 넣습니다. 색상은 구문을 구분하며 컴파일 결과를 대신하지 않습니다. ${isProject(exercise) ? '현재 파일만 초기화합니다. 편집은 파일당 65,536자, 실행은 UTF-8 파일당 64 KiB·전체 256 KiB까지입니다. 한글은 여러 바이트를 사용합니다.' : `class Solution 골격을 유지하고 최상위 public class·package·Main 선언은 추가하지 마세요. 편집은 50,000자까지이며 ${isDesktop ? '실행 소스' : '실행 요청 전체'}는 UTF-8 32 KiB까지입니다. 한글은 여러 바이트를 사용합니다.`}`); hint.id = 'editor-hint'; const undo = el('div', 'reset-undo'); undo.id = 'lab-reset-undo'; undo.hidden = true; - const disclosure = el('p', 'execution-disclosure', '실행·제출을 누르면 작성 코드가 외부 서비스 Wandbox로 전송됩니다. 운영 로그에 코드가 남을 수 있으니 비밀정보를 입력하지 마세요. '); + const disclosure = el('p', 'execution-disclosure', isDesktop ? '실행은 이 PC에서 이루어집니다. 코드는 PC의 사용자 권한으로 파일과 네트워크에 접근할 수 있으며, 격리된 보안 샌드박스가 아닙니다. 설정에서 동의한 뒤 실행하세요.' : isProject(exercise) ? '실무 프로젝트 실행은 Windows 앱에서 지원합니다. 이 웹 화면의 코드는 외부 실행 서비스로 전송하지 않습니다.' : '실행·제출을 누르면 작성 코드가 외부 서비스 Wandbox로 전송됩니다. 운영 로그에 코드가 남을 수 있으니 비밀정보를 입력하지 마세요. '); disclosure.id = 'execution-disclosure'; - disclosure.append(safeLink('Wandbox 서비스 안내 ↗', 'https://github.com/melpon/wandbox#faq')); + if (!isDesktop && !isProject(exercise)) disclosure.append(safeLink('Wandbox 서비스 안내 ↗', 'https://github.com/melpon/wandbox#faq')); const actions = el('div', 'lab-actions'); - const run = actionButton('예시 실행', 'button button-secondary', () => executeCode(exercise, 'run'), 'run-code'); + if (isDesktop && isProject(exercise)) actions.append(actionButton('환경 준비', 'button button-secondary', () => executeCode(exercise, 'prepare'), 'prepare-project')); + const run = actionButton(isProject(exercise) ? '테스트 실행' : '예시 실행', 'button button-secondary', () => executeCode(exercise, 'run'), 'run-code'); run.prepend(icon('play')); run.setAttribute('aria-describedby', 'execution-disclosure'); const submit = actionButton('제출하고 검증하기', 'button button-primary', () => executeCode(exercise, 'submit'), 'submit-code'); submit.prepend(icon('check')); submit.setAttribute('aria-describedby', 'execution-disclosure'); - const cancel = actionButton('응답 대기 취소', 'button button-quiet', () => cancelPending('user'), 'cancel-execution'); + const cancel = actionButton(isDesktop ? '실행 취소' : '응답 대기 취소', 'button button-quiet', () => cancelPending('user'), 'cancel-execution'); cancel.hidden = true; const executionState = el('span', 'execution-state'); executionState.id = 'execution-state'; executionState.setAttribute('aria-live', 'polite'); actions.append(run, submit, cancel, executionState); @@ -794,28 +891,67 @@ function renderLab(lesson) { output.setAttribute('aria-busy', 'false'); output.tabIndex = -1; dom['lesson-panel'].append(editorShell, hint, undo, disclosure, actions, output); - mountCodeEditor(editor, exercise); + renderEditorFile(exercise); if (!editorLibrary) hint.prepend(document.createTextNode('편집기 확장을 불러오지 못해 기본 텍스트 편집기를 사용합니다. ')); renderRuntime(); renderUndo(exercise); refreshEditorCounter(exercise); renderExecution(exercise); updateLabControls(); + if (isDesktop && isProject(exercise)) void loadProject(exercise); +} + +function renderEditorFile(exercise) { + destroyCodeEditor(); + const parent = document.getElementById('code-editor'); + if (!parent) return; + parent.replaceChildren(); + const file = activeFile(exercise); + const name = document.getElementById('editor-filename'); + name.replaceChildren(icon('code'), document.createTextNode(file ? file.path : 'Solution · Java')); + const readonly = Boolean(file && !file.editable); + if (file && !file.path.endsWith('.java')) name.append(el('span', 'file-readonly-badge', fileLanguage(file.path))); + if (readonly) name.append(el('span', 'file-readonly-badge', '읽기 전용')); + document.getElementById('indent-code').disabled = readonly; + document.getElementById('lab-reset').disabled = readonly; + document.getElementById('editor-syntax-legend').hidden = Boolean(file && !file.path.endsWith('.java')); + const host = document.getElementById('project-file-tabs'); + if (file) { + const files = projectFiles(exercise); + host.replaceChildren(fileTabs(files, file.path, (path) => { + if (activeFile(exercise)?.path === path) return; + saveStorage(); + state.activeFiles.set(exercise.id, path); + renderEditorFile(exercise); + announce(`${path}${files.find((item) => item.path === path)?.editable ? ' 편집' : ' 읽기 전용'}`); + }, 'project-file', parent.id, '실습 프로젝트 파일')); + parent.setAttribute('role', 'tabpanel'); + parent.setAttribute('aria-labelledby', `project-file-${files.indexOf(file)}`); + } + mountCodeEditor(parent, exercise); + renderUndo(exercise); + refreshEditorCounter(exercise); + updateStorageIndicator(); } function mountCodeEditor(parent, exercise) { + const file = activeFile(exercise); + const path = file?.path; + const readOnly = Boolean(file && !file.editable); const onChange = (value) => { - state.drafts.set(exercise.id, value); - state.undo.delete(exercise.id); + if (readOnly) return; + setSource(exercise, value, path); + state.undo.delete(fileKey(exercise, path)); renderUndo(exercise); refreshDraftDependentUi(exercise); scheduleSave(); }; const options = { parent, source: sourceFor(exercise), theme: state.editorTheme, wrap: state.editorWrap, - label: `${exercise.title} Java 코드 편집기`, descriptionId: 'editor-hint editor-save-state', - maxLength: SOURCE_LIMIT, onChange, onBlur: saveStorage, - onLimit: () => toast(`코드는 ${SOURCE_LIMIT.toLocaleString('ko-KR')}자까지 작성할 수 있습니다.`), + label: `${file?.path || exercise.title} ${file ? fileLanguage(file.path) : 'Java'} ${readOnly ? '읽기 전용 파일' : '코드 편집기'}`, descriptionId: 'editor-hint editor-save-state', + readOnly, language: file && !file.path.endsWith('.java') ? 'text' : 'java', + maxLength: sourceLimit(exercise), onChange, onBlur: saveStorage, + onLimit: () => toast(`코드는 ${sourceLimit(exercise).toLocaleString('ko-KR')}자까지 작성할 수 있습니다. 실행 바이트 제한은 별도로 확인합니다.`), }; if (editorLibrary) { activeEditor = editorLibrary.createJavaEditor(options); @@ -823,7 +959,9 @@ function mountCodeEditor(parent, exercise) { } const textarea = el('textarea', 'code-editor-fallback'); textarea.value = options.source; - textarea.maxLength = SOURCE_LIMIT; + textarea.maxLength = options.maxLength; + textarea.readOnly = readOnly; + textarea.setAttribute('aria-readonly', String(readOnly)); textarea.spellcheck = false; textarea.setAttribute('aria-label', options.label); textarea.setAttribute('aria-describedby', options.descriptionId); @@ -842,6 +980,7 @@ function mountCodeEditor(parent, exercise) { focus: () => textarea.focus(), selectAll: () => { textarea.focus(); textarea.select(); }, indent: () => { + if (readOnly) return; textarea.setRangeText(' ', textarea.selectionStart, textarea.selectionEnd, 'end'); onChange(textarea.value); textarea.focus(); @@ -854,20 +993,22 @@ function mountCodeEditor(parent, exercise) { function refreshEditorCounter(exercise) { const count = document.getElementById('editor-line-count'); - if (count) count.textContent = `${sourceFor(exercise).split('\n').length} lines · ${sourceFor(exercise).length.toLocaleString('ko-KR')} / ${SOURCE_LIMIT.toLocaleString('ko-KR')} chars`; + const source = sourceFor(exercise); + if (count) count.textContent = `${source.split('\n').length} lines · ${source.length.toLocaleString('ko-KR')} / ${sourceLimit(exercise).toLocaleString('ko-KR')} chars · ${(utf8Size(source) / 1024).toFixed(1)} KiB`; } function renderUndo(exercise) { const row = document.getElementById('lab-reset-undo'); if (!row) return; row.replaceChildren(); - row.hidden = !state.undo.has(exercise.id); + const key = fileKey(exercise); + row.hidden = !state.undo.has(key); if (row.hidden) return; row.append(document.createTextNode('초기 코드로 되돌렸습니다.'), actionButton('되돌리기', '', () => { - if (!state.undo.has(exercise.id)) return; - const source = state.undo.get(exercise.id); - state.undo.delete(exercise.id); - state.drafts.set(exercise.id, source); + if (!state.undo.has(key)) return; + const source = state.undo.get(key); + state.undo.delete(key); + setSource(exercise, source); activeEditor?.setValue(source); refreshDraftDependentUi(exercise); renderUndo(exercise); @@ -885,16 +1026,99 @@ function refreshDraftDependentUi(exercise) { renderExecution(exercise); } +function localRuntimes(exercise) { + const minimum = Number(state.projectInfo.get(exercise?.id)?.runtimeRequirements?.minimumJava || exercise?.minVersion || 8); + return (state.runtime?.runtimes || []).filter((runtime) => runtime.available && VERSIONS.includes(Number(runtime.javaVersion)) && Number(runtime.javaVersion) >= minimum).sort((a, b) => b.javaVersion - a.javaVersion); +} + +function runtimeFor(exercise) { + if (!isDesktop) return 21; + const available = localRuntimes(exercise); + return available.some((runtime) => Number(runtime.javaVersion) === state.runtimeVersion) ? state.runtimeVersion : Number(available[0]?.javaVersion) || null; +} + +function environmentKey(version) { + const runtime = state.runtime?.runtimes?.find((item) => Number(item.javaVersion) === version && item.available); + return JSON.stringify([version, runtime?.path || '', state.runtime?.maven?.path || '', state.runtime?.maven?.version || '']); +} + +function projectPrepared(exercise) { + const version = runtimeFor(exercise); + const key = environmentKey(version); + return (state.runtime?.preparedProjects || []).some((item) => item.exerciseId === exercise.id && JSON.stringify([Number(item.runtimeVersion), item.jdkPath || '', item.mavenPath || '', item.mavenVersion || '']) === key); +} + +function executionBlockReason(exercise, action = 'run') { + if (isProject(exercise) && !isDesktop) return '프로젝트 실행은 Windows 앱에서 지원합니다'; + if (state.cancelling) return '이전 실행의 취소를 확인하고 있습니다'; + if (state.settingsBusy) return '실행 설정을 확인하고 있습니다'; + if (state.runtimeStatus === 'checking') return '실행 환경 확인 중'; + if (isDesktop) { + if (!state.runtime?.consent?.accepted) return '앱 설정에서 로컬 코드 실행에 동의해 주세요'; + if (!runtimeFor(exercise)) return `앱 설정에서 Java ${exercise.minVersion} 이상의 JDK를 등록해 주세요`; + if (state.runtimeStatus !== 'ready') return state.runtimeMessage || '로컬 실행 환경을 다시 확인해 주세요'; + if (isProject(exercise)) { + if (!state.runtime.maven?.available) return '앱 설정에서 Maven 3.9 이상을 등록해 주세요'; + const project = state.projectInfo.get(exercise.id); + if (project?.status !== 'ready') return project?.message || '프로젝트 파일을 확인하고 있습니다'; + if (action !== 'prepare' && !projectPrepared(exercise)) return '선택한 JDK로 환경 준비를 먼저 완료해 주세요'; + } + return ''; + } + return state.runtimeStatus === 'ready' && state.runtime?.supportedTargets?.includes(Number(exercise.minVersion)) ? '' : '실행 연결을 확인해 주세요'; +} + function renderRuntime() { const card = document.getElementById('runtime-info'); if (!card) return; const exercise = exerciseFor(currentLesson()); card.replaceChildren(); - card.classList.toggle('is-unavailable', state.runtimeStatus === 'unavailable'); + card.classList.toggle('is-unavailable', state.runtimeStatus === 'unavailable' || (isProject(exercise) && !isDesktop)); const line = el('div', 'runtime-line'); - const runtimeVersion = state.runtime?.runtime?.javaVersion || 21; - line.append(el('strong', '', `실제 실행 JVM · Java ${runtimeVersion}`), el('span', '', `컴파일 대상 · Java ${exercise?.minVersion || 8}`)); + const runtimeVersion = runtimeFor(exercise); + line.append(el('strong', '', isProject(exercise) && !isDesktop ? '실행 위치 · Windows 앱' : runtimeVersion ? `실제 실행 JVM · Java ${runtimeVersion}` : '실제 실행 JVM · JDK 등록 필요'), el('span', '', `컴파일 대상 · Java ${exercise?.minVersion || 8}`)); card.append(line); + if (isProject(exercise) && !isDesktop) { + card.append(el('p', '', '웹에서 이론·문제·다중 파일 코드를 학습하고, Windows 앱에서 등록한 JDK와 Maven으로 테스트를 실행하세요. 웹과 앱의 학습 기록은 각각 저장됩니다.')); + const actions = el('div', 'project-access-actions'); + actions.append(actionButton('Windows 앱 안내', 'button button-secondary', () => openDialog('install-dialog'), 'project-desktop-guide')); + if (/^\/downloads\/[a-z0-9-]+\.zip$/.test(exercise.downloadUrl || '')) { + const download = el('a', 'button button-secondary', '프로젝트 ZIP 다운로드'); + download.id = 'project-download'; download.href = exercise.downloadUrl; download.download = ''; + actions.append(download); + } + card.append(actions); + return; + } + if (isDesktop) { + const row = el('div', 'runtime-controls'); + const label = el('label', '', '실제 실행 JDK'); label.htmlFor = 'runtime-version-select'; + const select = el('select'); select.id = 'runtime-version-select'; select.dataset.testid = select.id; + const available = localRuntimes(exercise); + if (!available.length) select.append(el('option', '', '등록된 호환 JDK 없음')); + available.forEach((runtime) => { const option = el('option', '', `Java ${runtime.javaVersion}`); option.value = String(runtime.javaVersion); select.append(option); }); + select.value = String(runtimeVersion || ''); + select.disabled = !available.length || Boolean(state.pending) || state.cancelling || state.settingsBusy; + select.addEventListener('change', () => { + cancelPending('실행 JDK 변경', false); + state.runtimeVersion = Number(select.value); + renderRuntime(); updateLabControls(); + document.getElementById('runtime-version-select')?.focus({ preventScroll: true }); + announce(`실제 실행 JDK를 Java ${state.runtimeVersion}로 변경했습니다. 프로젝트 준비 상태를 확인하세요.`); + }); + row.append(label, select, actionButton('실행 설정', 'button button-secondary', openDesktopSettings, 'runtime-settings-open')); + card.append(row, el('p', '', state.runtimeMessage || '이 PC의 등록 JDK로 실행합니다. 학습 기준 버전·컴파일 대상·실제 실행 JVM을 따로 확인하세요.')); + if (isProject(exercise)) { + const project = state.projectInfo.get(exercise.id); + const preparation = el('p', 'project-preparation', projectPrepared(exercise) ? '환경 준비 완료 · 현재 JDK와 Maven으로 테스트할 수 있습니다.' : '환경 준비를 누르면 고정 기준 답안으로 Maven 의존성을 내려받고 환경을 검증합니다. 이후 학습자 테스트는 오프라인으로 실행합니다.'); + preparation.id = 'project-preparation-state'; preparation.dataset.prepared = String(projectPrepared(exercise)); + card.append(preparation); + if (project?.status === 'error') card.append(el('p', '', project.message), actionButton('프로젝트 다시 확인', 'runtime-retry', () => loadProject(exercise, true), 'project-retry')); + for (const service of project?.runtimeRequirements?.services || []) card.append(el('p', 'project-service', `${service.label || service.type}: 학습용 ${service.host}:${service.port}가 필요합니다. 포트 확인 후 실제 통합 테스트로 검증합니다.`)); + } + if (state.runtimeStatus === 'unavailable') card.append(actionButton('설정 다시 확인', 'runtime-retry', loadRuntime, 'runtime-retry')); + return; + } if (state.runtimeStatus === 'checking') { card.append(el('p', '', 'Wandbox 실행 환경의 연결 상태를 확인하고 있습니다.')); } else if (state.runtimeStatus === 'unavailable') { @@ -912,15 +1136,29 @@ function updateLabControls() { const cancel = document.getElementById('cancel-execution'); const status = document.getElementById('execution-state'); if (!run || !submit || !cancel || !status || !exercise) return; - const busy = Boolean(state.pending && state.pending.exerciseId === exercise.id); - const available = state.runtimeStatus === 'ready' && state.runtime.supportedTargets.includes(Number(exercise.minVersion)); - run.disabled = busy || !available; - submit.disabled = busy || !available; - cancel.hidden = !busy; - status.textContent = busy ? '실제 Java 컴파일·실행 중…' : state.runtimeStatus === 'checking' ? '실행 환경 확인 중' : !available ? '실행 연결을 확인해 주세요' : '코드는 버튼을 누를 때 전송됩니다'; + const busy = Boolean(state.pending); + const reason = executionBlockReason(exercise); + run.disabled = busy || Boolean(reason); + submit.disabled = busy || Boolean(reason); + run.dataset.blocked = reason ? 'true' : 'false'; submit.dataset.blocked = run.dataset.blocked; + const prepare = document.getElementById('prepare-project'); + if (prepare) { prepare.disabled = busy || Boolean(executionBlockReason(exercise, 'prepare')); prepare.dataset.prepared = String(projectPrepared(exercise)); } + const runtimeSelect = document.getElementById('runtime-version-select'); + if (runtimeSelect) runtimeSelect.disabled = busy || state.cancelling || state.settingsBusy || !localRuntimes(exercise).length; + cancel.hidden = !busy || state.pending.exerciseId !== exercise.id; + status.textContent = busy ? state.pending.action === 'prepare' ? 'Maven 환경 준비 중 · 처음에는 수 분 걸릴 수 있습니다' : '실제 Java 컴파일·실행 중…' : reason || (isDesktop ? '이 PC에서 명시적으로 실행합니다' : '코드는 버튼을 누를 때 전송됩니다'); document.getElementById('execution-output')?.setAttribute('aria-busy', String(busy)); } +function acceptRuntime(runtime) { + if (!runtime || typeof runtime !== 'object' || !Array.isArray(runtime.supportedTargets)) throw new Error('실행 환경 응답을 확인할 수 없습니다.'); + if (isDesktop && (runtime.mode !== 'local' || !Array.isArray(runtime.runtimes))) throw new Error('로컬 실행 환경 응답을 확인할 수 없습니다.'); + state.runtime = { ...runtime, supportedTargets: runtime.supportedTargets.map(Number).filter((version) => VERSIONS.includes(version)) }; + const available = runtime.available === true && (isDesktop || Number(runtime.runtime?.javaVersion) === 21); + state.runtimeStatus = available ? 'ready' : 'unavailable'; + state.runtimeMessage = available ? '' : typeof runtime.reason === 'string' ? runtime.reason : typeof runtime.notice === 'string' ? runtime.notice : '실행 환경 설정을 확인해 주세요.'; +} + async function loadRuntime() { const sequence = ++runtimeSequence; state.runtimeStatus = 'checking'; @@ -933,50 +1171,129 @@ async function loadRuntime() { if (!response.ok) throw new Error('Runtime unavailable'); const runtime = await response.json(); if (sequence !== runtimeSequence) return; - if (!runtime || typeof runtime !== 'object' || runtime.available !== true || Number(runtime.runtime?.javaVersion) !== 21 || !Array.isArray(runtime.supportedTargets)) { - state.runtimeStatus = 'unavailable'; - state.runtimeMessage = typeof runtime?.notice === 'string' ? runtime.notice : '현재 Wandbox 실행 환경을 사용할 수 없습니다. 잠시 후 다시 확인해 주세요.'; - } else { - state.runtime = { ...runtime, supportedTargets: runtime.supportedTargets.map(Number).filter((version) => VERSIONS.includes(version)) }; - state.runtimeStatus = 'ready'; - state.runtimeMessage = ''; - } + acceptRuntime(runtime); } catch { if (sequence !== runtimeSequence) return; state.runtimeStatus = 'unavailable'; - state.runtimeMessage = '실행 서버에 연결하지 못했습니다. 수업·문제·코드 작성은 계속 이용할 수 있습니다.'; + state.runtimeMessage = isDesktop ? '로컬 실행 환경을 읽지 못했습니다. 설정을 다시 확인해 주세요.' : '실행 서버에 연결하지 못했습니다. 수업·문제·코드 작성은 계속 이용할 수 있습니다.'; + } finally { + clearTimeout(timer); + if (sequence === runtimeSequence) { renderRuntime(); updateLabControls(); renderDesktopSettings(); } + } +} + +async function loadProject(exercise, retry = false) { + if (!isDesktop || !isProject(exercise)) return; + if (!retry && state.projectInfo.has(exercise.id)) return; + const marker = { status: 'loading' }; + state.projectInfo.set(exercise.id, marker); + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch(`/api/local/projects/${encodeURIComponent(exercise.id)}`, { signal: controller.signal, headers: { Accept: 'application/json' }, cache: 'no-store' }); + const payload = await response.json(); + if (state.projectInfo.get(exercise.id) !== marker) return; + if (!response.ok) throw new Error(payload.error?.message || '프로젝트 파일을 읽지 못했습니다.'); + const files = payload.files; + if (payload.exerciseId !== exercise.id || !Array.isArray(files) || files.length !== exercise.files.length || !files.every((file) => typeof file.path === 'string' && typeof file.source === 'string' && typeof file.editable === 'boolean' && exercise.files.some((expected) => expected.path === file.path && expected.editable === file.editable)) || new Set(files.map((file) => file.path)).size !== files.length) throw new Error('교재와 실행기의 파일 목록이 다릅니다. 앱의 교재 버전을 확인해 주세요.'); + const changed = files.some((file) => exercise.files.find((item) => item.path === file.path)?.source !== file.source); + state.projectInfo.set(exercise.id, { status: 'ready', files, runtimeRequirements: payload.runtimeRequirements }); + if (changed && currentLesson()?.id === exercise.lessonId && state.step === 'lab') renderEditorFile(exercise); + } catch (error) { + if (state.projectInfo.get(exercise.id) === marker) state.projectInfo.set(exercise.id, { status: 'error', message: error instanceof Error ? error.message : '프로젝트 파일을 읽지 못했습니다.' }); } finally { clearTimeout(timer); - if (sequence === runtimeSequence) { renderRuntime(); updateLabControls(); } + if (currentLesson()?.id === exercise.lessonId && state.step === 'lab') { renderRuntime(); updateLabControls(); } + } +} + +function renderDesktopSettings() { + const body = document.getElementById('desktop-settings-body'); + if (!isDesktop || !body) return; + body.replaceChildren(); + const tools = el('div', 'settings-tools'); + for (const version of VERSIONS) { + const runtime = state.runtime?.runtimes?.find((item) => Number(item.javaVersion) === version); + const row = el('div', 'settings-tool-row'); + const copy = el('div'); + copy.append(el('strong', '', `JDK ${version}`), el('p', 'settings-tool-path', runtime?.available ? runtime.path || '등록됨' : '등록하지 않음')); + const button = actionButton(runtime?.available ? '변경' : '폴더 선택', 'button button-secondary', () => changeDesktopSettings(() => desktop.chooseJdk(version)), `jdk-register-${version}`); + button.setAttribute('aria-label', `JDK ${version} 설치 폴더 선택`); button.disabled = state.settingsBusy; + row.append(copy, button); tools.append(row); + } + const row = el('div', 'settings-tool-row'); + const copy = el('div'); + copy.append(el('strong', '', `Maven${state.runtime?.maven?.version ? ` ${state.runtime.maven.version}` : ' 3.9+'}`), el('p', 'settings-tool-path', state.runtime?.maven?.available ? state.runtime.maven.path || '등록됨' : '등록하지 않음')); + const maven = actionButton(state.runtime?.maven?.available ? '변경' : '폴더 선택', 'button button-secondary', () => changeDesktopSettings(() => desktop.chooseMaven()), 'maven-register'); + maven.setAttribute('aria-label', 'Maven 설치 폴더 선택'); maven.disabled = state.settingsBusy; + row.append(copy, maven); tools.append(row); body.append(tools); + const consent = el('div', 'settings-consent'); + consent.append(el('h3', '', '이 PC에서 코드 실행'), el('p', '', 'Java 코드는 현재 PC의 사용자 권한으로 파일과 네트워크에 접근할 수 있습니다. 이 앱은 격리된 보안 샌드박스가 아닙니다. 신뢰하는 코드를 직접 확인한 뒤 실행하세요.')); + const label = el('label'); + const checkbox = el('input'); checkbox.type = 'checkbox'; checkbox.id = 'execution-consent'; + checkbox.checked = state.runtime?.consent?.accepted === true; + checkbox.disabled = state.settingsBusy; + checkbox.addEventListener('change', () => changeDesktopSettings(() => desktop.setExecutionConsent(checkbox.checked))); + label.append(checkbox, el('span', '', '위 실행 권한을 이해했으며 로컬 코드 실행에 동의합니다.')); + consent.append(label); body.append(consent); + const status = el('p', 'settings-status', state.settingsBusy ? '설정을 확인하고 있습니다…' : state.settingsMessage); + status.id = 'desktop-settings-status'; status.setAttribute('role', 'status'); body.append(status); +} + +async function changeDesktopSettings(operation, mutation = true) { + if (!isDesktop || state.settingsBusy) return; + const focusId = document.activeElement?.id; + if (mutation) cancelPending('실행 설정 변경', false); + state.settingsBusy = true; state.settingsMessage = ''; + renderDesktopSettings(); updateLabControls(); + const sequence = ++runtimeSequence; + try { + const runtime = await operation(); + if (sequence !== runtimeSequence) return; + acceptRuntime(runtime); + state.settingsMessage = mutation ? '실행 설정을 확인했습니다.' : ''; + } catch (error) { + state.settingsMessage = error instanceof Error ? error.message : '실행 설정을 저장하지 못했습니다.'; + } finally { + state.settingsBusy = false; + renderDesktopSettings(); renderRuntime(); updateLabControls(); + if (document.getElementById('desktop-settings-dialog').open && focusId) document.getElementById(focusId)?.focus({ preventScroll: true }); } } +function openDesktopSettings() { + if (!isDesktop) return; + renderDesktopSettings(); + openDialog('desktop-settings-dialog'); + void changeDesktopSettings(() => desktop.getSettings(), false); +} + function renderExecution(exercise) { const panel = document.getElementById('execution-output'); if (!panel) return; panel.replaceChildren(); const pending = state.pending?.exerciseId === exercise.id ? state.pending : null; const result = state.labResults.get(exercise.id); - const outdated = Boolean(result && result.sourceSnapshot !== sourceFor(exercise)); + const outdated = Boolean(result && result.action !== 'prepare' && result.sourceSnapshot !== sourceSnapshot(exercise)); panel.dataset.status = pending ? 'running' : outdated ? 'outdated' : result?.status || 'idle'; panel.setAttribute('aria-busy', String(Boolean(pending))); const heading = el('div', 'execution-panel-heading'); const title = el('strong'); title.append(icon('terminal'), document.createTextNode('실제 실행 결과')); - const metadata = result && result.runtimeVersion ? `Java ${result.runtimeVersion} · --release ${result.targetVersion}` : 'Wandbox / Java 21'; + const metadata = result && result.runtimeVersion ? `Java ${result.runtimeVersion} · 컴파일 대상 ${result.targetVersion}` : isDesktop ? '이 PC의 JDK' : isProject(exercise) ? 'Windows 앱에서 실행' : 'Wandbox / Java 21'; heading.append(title, el('span', '', metadata)); const body = el('div', 'execution-output-body'); if (pending) { - body.append(el('p', 'execution-placeholder', '작성 코드를 컴파일하고 실제 JVM에서 실행하고 있습니다. 결과가 도착하면 여기에 표시됩니다.')); - if (pending.source !== sourceFor(exercise)) body.append(el('p', 'execution-warning', '요청 후 코드가 변경되었습니다. 도착하는 결과는 요청 당시 코드의 결과이며 현재 코드의 완료 기록에 반영하지 않습니다.')); + body.append(el('p', 'execution-placeholder', pending.action === 'prepare' ? '고정 기준 답안으로 Maven 의존성을 내려받고 실행 환경을 검증합니다. 처음에는 수 분 걸릴 수 있습니다. 이 단계는 작성 코드의 제출 통과로 기록하지 않습니다.' : '작성 코드를 컴파일하고 실제 JVM에서 실행하고 있습니다. 결과가 도착하면 여기에 표시됩니다.')); + if (pending.action !== 'prepare' && pending.source !== sourceSnapshot(exercise)) body.append(el('p', 'execution-warning', '요청 후 코드가 변경되었습니다. 도착하는 결과는 요청 당시 코드의 결과이며 현재 코드의 완료 기록에 반영하지 않습니다.')); } else if (!result) { - body.append(el('p', 'execution-placeholder', '아직 실행하지 않았습니다. 예시 실행으로 공개 사례를 확인하고, 제출로 전체 테스트를 검증하세요. 예상 출력은 실제 실행 결과로 사용하지 않습니다.')); + body.append(el('p', 'execution-placeholder', isProject(exercise) ? '아직 실행하지 않았습니다. Windows 앱에서 환경 준비 후 프로젝트 전체 테스트를 실행하세요. 제출 통과와 객관식 성취를 모두 마치면 수업 완료입니다.' : '아직 실행하지 않았습니다. 예시 실행으로 공개 사례를 확인하고, 제출로 전체 테스트를 검증하세요. 예상 출력은 실제 실행 결과로 사용하지 않습니다.')); } else { if (outdated) body.append(el('p', 'execution-warning', '요청 후 코드가 변경되었습니다. 현재 코드의 실행 결과가 아닙니다. 현재 코드를 다시 실행·제출해 주세요.')); const labels = { - passed: result.action === 'submit' ? '제출 테스트 통과' : '공개 예시 실행 통과', + passed: result.action === 'prepare' ? '환경 준비 완료' : result.action === 'submit' ? '제출 테스트 통과' : isProject(exercise) ? '프로젝트 테스트 통과' : '공개 예시 실행 통과', failed: '검증 조건을 충족하지 못했습니다', compile_error: '컴파일 오류', runtime_error: '실행 중 오류', timeout: '실행 시간 초과', - provider_error: '실행 요청을 완료하지 못했습니다', cancelled: '응답 대기를 취소했습니다', validation_error: '실행할 코드의 크기를 확인해 주세요', + provider_error: '실행 요청을 완료하지 못했습니다', cancelled: isDesktop ? '실행 취소 요청' : '응답 대기를 취소했습니다', validation_error: '실행할 코드의 크기를 확인해 주세요', }; const isError = !['passed', 'cancelled'].includes(result.status); body.append(el('p', `execution-result-label${isError ? ' is-error' : ''}`, labels[result.status] || '실행 결과 확인 필요')); @@ -1004,7 +1321,8 @@ function renderExecution(exercise) { section.append(el('h4', '', name), pre); body.append(section); } - if (result.action === 'run' && result.status === 'passed') body.append(el('p', 'inline-note', '예시 실행은 수업 완료에 포함되지 않습니다. 전체 테스트는 제출로 확인하세요.')); + if (result.action === 'run' && result.status === 'passed') body.append(el('p', 'inline-note', '실행만으로 수업 완료를 기록하지 않습니다. 현재 코드의 성취를 기록하려면 제출하세요.')); + if (result.action === 'prepare') body.append(el('p', 'inline-note', '환경 준비는 기준 답안의 결과입니다. 작성 코드의 테스트 통과·수업 완료와 별도로 표시합니다.')); if (result.action === 'submit' && result.status === 'passed' && !outdated) body.append(el('p', 'inline-note', '현재 코드의 테스트 통과를 기록했습니다. 설계 판단과 설명은 실무 탭에서 직접 점검하세요.')); if (Number.isFinite(result.durationMs)) body.append(el('p', 'execution-timing', `${result.provider || 'Wandbox'} · 응답 시간 ${(result.durationMs / 1000).toFixed(2)}초`)); } @@ -1020,10 +1338,11 @@ function cancelPending(reason = 'user', update = true) { if (reason === 'user') { state.labResults.set(pending.exerciseId, { status: 'cancelled', action: pending.action, sourceSnapshot: pending.source, - message: '브라우저의 응답 대기를 중단했습니다. 외부 서비스에서 이미 시작한 실행이 종료되었다는 의미는 아닙니다.', + message: isDesktop ? '로컬 실행의 취소를 요청하고 있습니다.' : '브라우저의 응답 대기를 중단했습니다. 외부 서비스에서 이미 시작한 실행이 종료되었다는 의미는 아닙니다.', }); - announce('응답 대기를 취소했습니다. 외부 실행의 종료를 보장하지 않습니다.'); + announce(isDesktop ? '로컬 실행의 취소를 요청했습니다.' : '응답 대기를 취소했습니다. 외부 실행의 종료를 보장하지 않습니다.'); } + if (isDesktop) void cancelLocalRequest(pending, reason === 'user'); if (update) { const exercise = exerciseFor(currentLesson()); if (exercise) renderExecution(exercise); @@ -1031,9 +1350,35 @@ function cancelPending(reason = 'user', update = true) { } } +async function cancelLocalRequest(request, showResult = false) { + if (!isDesktop) return; + state.cancelling = true; + updateLabControls(); + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 10000); + let message; + try { + const response = await fetch('/api/local/cancel', { method: 'POST', signal: controller.signal, headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ requestId: request.id }) }); + const payload = await response.json(); + if (!response.ok || typeof payload.cancelled !== 'boolean') throw new Error('취소 응답을 확인하지 못했습니다.'); + message = payload.cancelled ? '로컬 실행기에 취소가 반영되었습니다.' : '취소할 실행이 없습니다. 요청이 이미 종료되었을 수 있습니다.'; + } catch { + message = '로컬 실행의 취소 여부를 확인하지 못했습니다. 앱의 실행 상태를 다시 확인해 주세요.'; + toast(message); + } finally { + clearTimeout(timer); + state.cancelling = false; + if (showResult && state.labResults.get(request.exerciseId)?.status === 'cancelled') state.labResults.get(request.exerciseId).message = message; + const current = exerciseFor(currentLesson()); + if (current) renderExecution(current); + updateLabControls(); + } +} + function executionResult(payload, request, exercise) { if (!payload || typeof payload !== 'object' || payload.requestId !== request.id || !EXECUTION_STATUSES.has(payload.status)) throw new Error('실행 서버의 응답 형식을 확인할 수 없습니다. 다시 시도해 주세요.'); - if (Number(payload.runtimeVersion) !== 21 || Number(payload.targetVersion) !== Number(exercise.minVersion)) throw new Error('응답의 실행 환경이 이 실습의 계약과 일치하지 않습니다. 완료 기록에 반영하지 않았습니다.'); + if (Number(payload.runtimeVersion) !== request.runtimeVersion || Number(payload.targetVersion) !== Number(exercise.minVersion)) throw new Error('응답의 실행 환경이 이 실습의 계약과 일치하지 않습니다. 완료 기록에 반영하지 않았습니다.'); + if (request.action === 'prepare' && payload.status === 'passed' && payload.prepared !== true) throw new Error('환경 준비 성공을 확인하지 못했습니다. 다시 준비해 주세요.'); const checks = Array.isArray(payload.checks) ? payload.checks.filter((check) => check && typeof check.name === 'string' && typeof check.passed === 'boolean').map((check) => ({ name: check.name, passed: check.passed })) : []; const total = Number(payload.total); const passed = Number(payload.passed); @@ -1046,41 +1391,57 @@ function executionResult(payload, request, exercise) { compilerOutput: typeof payload.compilerOutput === 'string' ? payload.compilerOutput : '', message: typeof payload.error?.message === 'string' ? payload.error.message : '', checks, passed: Number.isFinite(passed) ? passed : 0, total: Number.isFinite(total) ? total : checks.length, - provider: typeof payload.provider === 'string' ? payload.provider : 'Wandbox', + provider: typeof payload.provider === 'string' ? payload.provider : isDesktop ? 'Local JDK' : 'Wandbox', durationMs: Number.isFinite(payload.durationMs) ? payload.durationMs : undefined, }; } async function executeCode(exercise, action) { - if (state.pending || state.runtimeStatus !== 'ready' || !state.runtime.supportedTargets.includes(Number(exercise.minVersion))) return; - const source = sourceFor(exercise); - if (!source.trim()) { + if (state.pending || executionBlockReason(exercise, action)) return; + if (action === 'prepare' && (!isDesktop || !isProject(exercise))) return; + const source = sourceSnapshot(exercise); + const files = isProject(exercise) ? sourcesFor(exercise) : null; + if (action !== 'prepare' && (files ? Object.values(files).some((value) => !value.trim()) : !source.trim())) { toast('실행할 Java 코드를 입력해 주세요.'); activeEditor?.focus(); return; } saveStorage(); const id = window.crypto?.randomUUID ? window.crypto.randomUUID() : `request_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`; - const requestBody = JSON.stringify({ exerciseId: exercise.id, source, action, requestId: id }); - if (new TextEncoder().encode(requestBody).byteLength > 32 * 1024) { + const runtimeVersion = runtimeFor(exercise); + const body = { exerciseId: exercise.id, requestId: id }; + if (isDesktop) body.runtimeVersion = runtimeVersion; + if (action !== 'prepare') { body.action = action; if (files) body.files = files; else body.source = source; } + const requestBody = JSON.stringify(body); + let sizeMessage = ''; + if (action !== 'prepare') { + if (files) { + const oversized = Object.entries(files).find(([, value]) => utf8Size(value) > 64 * 1024); + if (oversized) sizeMessage = `${oversized[0]} 파일이 UTF-8 64 KiB를 초과합니다.`; + else if (Object.values(files).reduce((sum, value) => sum + utf8Size(value), 0) > 256 * 1024) sizeMessage = '편집 파일 전체가 UTF-8 256 KiB를 초과합니다.'; + } else if (isDesktop && utf8Size(source) > 32 * 1024) sizeMessage = 'Java 소스가 UTF-8 32 KiB를 초과합니다.'; + if (!sizeMessage && utf8Size(requestBody) > (isDesktop ? 384 : 32) * 1024) sizeMessage = `요청 전체가 UTF-8 ${isDesktop ? 384 : 32} KiB를 초과합니다.`; + } + if (sizeMessage) { state.labResults.set(exercise.id, { status: 'validation_error', action, sourceSnapshot: source, - message: '요청 전체가 UTF-8 32 KiB를 초과합니다. 코드를 줄여 다시 시도해 주세요. 한글은 여러 바이트를 사용하며 작성 코드는 이 브라우저에 유지됩니다.', + message: `${sizeMessage} 코드를 줄여 다시 시도해 주세요. 한글은 여러 바이트를 사용하며 작성 코드는 유지됩니다.`, }); renderExecution(exercise); - announce('코드가 실행 요청의 32 KiB 제한을 초과했습니다. 코드를 줄여 주세요.'); + announce(sizeMessage); return; } const controller = new AbortController(); - const request = { id, source, action, exerciseId: exercise.id, lessonId: state.lessonId, version: state.version, controller, timedOut: false, timer: null }; - request.timer = window.setTimeout(() => { request.timedOut = true; controller.abort(); }, 60000); + const request = { id, source, action, runtimeVersion, environment: environmentKey(runtimeVersion), exerciseId: exercise.id, lessonId: state.lessonId, version: state.version, controller, timedOut: false, timer: null }; + const timeoutMs = isDesktop ? action === 'prepare' ? 360000 : isProject(exercise) ? 180000 : 60000 : 60000; + request.timer = window.setTimeout(() => { request.timedOut = true; controller.abort(); if (isDesktop) void cancelLocalRequest(request); }, timeoutMs); state.pending = request; state.labResults.delete(exercise.id); updateLabControls(); renderExecution(exercise); - announce(action === 'submit' ? '코드를 제출해 전체 테스트를 검증합니다.' : '공개 예시를 실제 Java 환경에서 실행합니다.'); + announce(action === 'prepare' ? '기준 답안으로 실행 환경을 준비합니다.' : action === 'submit' ? '코드를 제출해 전체 테스트를 검증합니다.' : '실제 Java 환경에서 코드를 실행합니다.'); try { - const response = await fetch('/api/execute', { + const response = await fetch(action === 'prepare' ? '/api/local/prepare' : '/api/execute', { method: 'POST', signal: controller.signal, headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: requestBody, @@ -1094,7 +1455,7 @@ async function executeCode(exercise, action) { } const result = executionResult(payload, request, exercise); state.labResults.set(exercise.id, result); - const fresh = request.source === sourceFor(exercise) && request.lessonId === state.lessonId && request.version === state.version; + const fresh = request.source === sourceSnapshot(exercise) && request.lessonId === state.lessonId && request.version === state.version && (!isDesktop || request.environment === environmentKey(runtimeFor(exercise))); if (result.status === 'passed' && action === 'submit' && fresh) { state.labPassed.set(exercise.id, request.source); saveStorage(); @@ -1108,12 +1469,12 @@ async function executeCode(exercise, action) { renderSidebar(); renderProgress(); } - announce(!fresh ? '요청 당시 코드의 결과가 도착했습니다. 현재 코드를 다시 제출해 주세요.' : result.status === 'passed' ? action === 'submit' ? '제출한 코드가 전체 테스트를 통과했습니다.' : '공개 예시 실행을 통과했습니다.' : '실행 결과를 확인해 주세요.'); + announce(action === 'prepare' ? result.status === 'passed' ? '실행 환경 준비를 완료했습니다. 작성 코드를 제출해 주세요.' : '실행 환경 준비 결과를 확인해 주세요.' : !fresh ? '요청 당시 코드의 결과가 도착했습니다. 현재 코드를 다시 제출해 주세요.' : result.status === 'passed' ? action === 'submit' ? '제출한 코드가 전체 테스트를 통과했습니다.' : '코드 실행을 통과했습니다.' : '실행 결과를 확인해 주세요.'); } catch (error) { if (state.pending !== request) return; state.labResults.set(exercise.id, { status: request.timedOut ? 'timeout' : 'provider_error', action, sourceSnapshot: source, - message: request.timedOut ? '60초 안에 응답을 받지 못했습니다. 외부 실행이 계속 중일 수 있습니다. 잠시 후 다시 시도해 주세요.' : error instanceof Error ? error.message : '실행 서비스에 연결할 수 없습니다.', + message: request.timedOut ? isDesktop ? '응답 대기 시간을 초과해 로컬 실행 취소를 요청했습니다. 실행 상태를 확인한 뒤 다시 시도해 주세요.' : '60초 안에 응답을 받지 못했습니다. 외부 실행이 계속 중일 수 있습니다. 잠시 후 다시 시도해 주세요.' : error instanceof Error ? error.message : '실행 서비스에 연결할 수 없습니다.', }); announce(request.timedOut ? '실행 응답 대기 시간이 초과되었습니다.' : '실행 요청을 완료하지 못했습니다. 오류 안내를 확인해 주세요.'); } finally { @@ -1122,6 +1483,7 @@ async function executeCode(exercise, action) { state.pending = null; renderExecution(exercise); updateLabControls(); + if (isDesktop) void loadRuntime(); } } } @@ -1207,15 +1569,20 @@ function openDialog(id) { function resetProgress() { cancelPending('학습 기록 초기화', false); - for (const key of ['quizPassed', 'labPassed', 'drafts', 'notes', 'quizAnswers', 'quizResults', 'labResults', 'undo']) state[key].clear(); + for (const key of ['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'activeFiles', 'notes', 'quizAnswers', 'quizResults', 'labResults', 'undo']) state[key].clear(); saveStorage(); document.getElementById('reset-dialog').close(); renderAll(); dom['lesson-title'].focus({ preventScroll: true }); - toast('이 브라우저의 진도, 작성 코드와 설계 메모를 초기화했습니다.'); + toast(`${isDesktop ? '이 PC 앱' : '이 브라우저'}의 진도, 작성 코드와 설계 메모를 초기화했습니다.`); } function bindEvents() { + document.getElementById('desktop-settings-open').hidden = !isDesktop; + document.getElementById('desktop-install-open').hidden = isDesktop; + document.getElementById('desktop-settings-open').addEventListener('click', openDesktopSettings); + document.getElementById('desktop-install-open').addEventListener('click', () => openDialog('install-dialog')); + document.getElementById('reset-records-description').textContent = `${isDesktop ? '이 PC 앱' : '이 브라우저'}의 진도, 파일별 작성 코드와 설계 메모를 모두 지웁니다. 현재 학습 기준 버전은 유지됩니다.`; dom['version-select'].addEventListener('change', (event) => { const version = Number(event.target.value); if (!VERSIONS.includes(version)) return; @@ -1282,12 +1649,14 @@ function bindEvents() { async function boot() { try { - const [curriculumModule, exerciseModule, evidenceModule, editorModule] = await Promise.all([ + const [curriculumModule, exerciseModule, evidenceModule, editorModule, professionalModule] = await Promise.all([ import('./data/curriculum.js'), import('./data/exercises.js'), import('./data/job-evidence.js'), import('./assets/editor.bundle.js').catch(() => null), + import('./data/professional.js'), ]); - lessons = curriculumModule.lessons; - exercises = exerciseModule.exercises; + if (!Array.isArray(professionalModule.professionalLessons) || !Array.isArray(professionalModule.professionalExercises)) throw new Error('Professional content data unavailable'); + lessons = [...curriculumModule.lessons, ...professionalModule.professionalLessons]; + exercises = [...exerciseModule.exercises, ...professionalModule.professionalExercises]; jobEvidence = evidenceModule.jobEvidence; editorLibrary = editorModule; if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(exercises) || !Array.isArray(jobEvidence)) throw new Error('Content data unavailable'); @@ -1302,7 +1671,7 @@ async function boot() { closeMenu(false); syncHash(); if (state.storage === 'invalid') toast('기존 저장 기록을 읽을 수 없어 새 학습 상태로 시작합니다.'); - else if (state.storage === 'unavailable') toast('브라우저 저장이 제한되어 기록은 이번 방문 동안만 유지됩니다.'); + else if (state.storage === 'unavailable') toast('학습 기록 저장소가 제한되어 현재 화면 동안만 유지됩니다.'); void loadRuntime(); } catch (error) { dom['loading-state'].classList.add('is-error'); diff --git a/dist/editor.js b/dist/editor.js index 7137623..1bd60f6 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -59,7 +59,7 @@ export function highlightJavaCode(target, source) { target.replaceChildren(fragment); } -export function createJavaEditor({ parent, source, theme = 'dark', wrap = false, label, descriptionId, maxLength = 50000, onChange, onBlur, onLimit }) { +export function createJavaEditor({ parent, source, theme = 'dark', wrap = false, language = 'java', readOnly = false, label, descriptionId, maxLength = 50000, onChange, onBlur, onLimit }) { const mode = new Compartment(); const wrapping = new Compartment(); let suppressChange = false; @@ -69,8 +69,9 @@ export function createJavaEditor({ parent, source, theme = 'dark', wrap = false, state: EditorState.create({ doc: source, extensions: [ - new LanguageSupport(learningJava), + language === 'java' ? new LanguageSupport(learningJava) : [], syntaxHighlighting(javaHighlighter), + EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly), lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), highlightSpecialChars(), drawSelection(), dropCursor(), history(), bracketMatching(), indentOnInput(), EditorState.tabSize.of(4), indentUnit.of(' '), @@ -81,6 +82,7 @@ export function createJavaEditor({ parent, source, theme = 'dark', wrap = false, EditorView.contentAttributes.of({ role: 'textbox', 'aria-label': label, 'aria-multiline': 'true', 'aria-describedby': descriptionId, 'data-testid': 'code-editor-input', + 'aria-readonly': String(readOnly), tabindex: '0', spellcheck: 'false', autocorrect: 'off', autocapitalize: 'off', autocomplete: 'off', }), EditorState.transactionFilter.of((transaction) => { @@ -103,7 +105,7 @@ export function createJavaEditor({ parent, source, theme = 'dark', wrap = false, view.dispatch({ selection: EditorSelection.single(0, view.state.doc.length) }); view.focus(); }, - indent: () => { indentMore(view); view.focus(); }, + indent: () => { if (!readOnly) indentMore(view); view.focus(); }, setValue: (value) => { if (value === view.state.doc.toString()) return; suppressChange = true; diff --git a/dist/index.html b/dist/index.html index e90e717..faede90 100644 --- a/dist/index.html +++ b/dist/index.html @@ -28,14 +28,14 @@
-

THE JAVA LEARNING WORKBENCH

이해에서 한 걸음 더,
설계하는 Java로.

다른 언어에서 Java로, 익숙한 코드에서 깊은 설계로.
공식 문서를 읽고 직접 구현하며 판단의 근거를 쌓습니다.

2 학습 트랙4 기준 버전12 코어 수업
-
MY LEARNING브라우저 기록
나의 학습 여정0 / 12

객관식 성취 + 코드 제출 통과 시 수업 완료

+

THE JAVA LEARNING WORKBENCH

이해에서 한 걸음 더,
설계하는 Java로.

다른 언어에서 Java로, 익숙한 코드에서 깊은 설계로.
공식 문서를 읽고 직접 구현하며 판단의 근거를 쌓습니다.

2 학습 트랙4 기준 버전34 전체 수업
+
MY LEARNING브라우저 기록
나의 학습 여정0 / 12

객관식 성취 + 코드 제출 통과 시 수업 완료

@@ -45,7 +45,7 @@
javaedu.

공식 문서로 이해하고, 코드로 확인하는 시간.

@@ -70,8 +70,10 @@
-

LEARNING GUIDE

이 학습실을 사용하는 방법

다른 언어 경험자의 Java 전환과 현직 개발자의 심화 학습을 함께 지원합니다. 이론 → 예제 → 문제 → 실무 → 코드 실습 순서로 진행해 보세요.

  • 학습 기준: Java 8·11·17·21. 선택 버전에 맞는 수업을 표시합니다.
  • 객관식 성취: 한 수업의 모든 문제에 정답을 제출하면 기록됩니다.
  • 실습 통과: 현재 작성 코드로 제출한 테스트가 모두 통과하면 기록됩니다. 두 조건을 모두 충족하면 수업 완료입니다.
  • 설계 기록: 나의 판단을 정리하는 메모입니다. 자동 평가나 정답 판정이 아닙니다.
  • 실행 환경: 실제 JVM은 Java 21이며, 실습별 최소 버전을 컴파일 대상으로 사용합니다. 다른 JVM 버전에서 실행한 결과는 아닙니다.
  • 저장: 코드, 메모, 진도는 이 브라우저에 저장됩니다. 기기 간 동기화·계정 백업을 제공하지 않습니다.

코드 예제와 실습에서 Java 구문 강조와 3가지 테마를 사용할 수 있습니다. 실습은 줄 번호·자동 들여쓰기·괄호 짝 표시를 지원합니다. 색상은 코드의 구문을 구분하며 컴파일 결과를 대신하지 않습니다.

-

BROWSER RECORDS

학습 기록을 초기화할까요?

이 브라우저의 진도, 작성 코드와 설계 메모를 모두 지웁니다. 현재 학습 기준 버전은 유지됩니다.

+

LEARNING GUIDE

이 학습실을 사용하는 방법

다른 언어 경험자의 Java 전환과 현직 개발자의 심화 학습을 함께 지원합니다. 이론 → 예제 → 문제 → 실무 → 코드 실습 순서로 진행해 보세요.

  • 학습 기준: Java 8·11·17·21. 선택 버전에 맞는 수업을 표시합니다.
  • 객관식 성취: 한 수업의 모든 문제에 정답을 제출하면 기록됩니다.
  • 실습 통과: 현재 작성 코드로 제출한 테스트가 모두 통과하면 기록됩니다. 두 조건을 모두 충족하면 수업 완료입니다.
  • 설계 기록: 나의 판단을 정리하는 메모입니다. 자동 평가나 정답 판정이 아닙니다.
  • 실행 환경: 웹의 코어 실습은 실제 JVM 21에서 실행합니다. Windows 앱은 PC에 등록한 JDK를 선택해 코어와 실무 프로젝트를 실행합니다. 컴파일 대상과 실제 JVM은 실행 화면에 따로 표시됩니다.
  • 저장: 웹은 현재 브라우저에, Windows 앱은 이 PC의 앱 데이터에 코드·메모·진도를 저장합니다. 앱 기록은 재시작 후에도 유지됩니다. 웹과 앱의 기록은 서로 동기화되지 않습니다.

코드 예제와 실습에서 Java 구문 강조와 3가지 테마를 사용할 수 있습니다. 실습은 줄 번호·자동 들여쓰기·괄호 짝 표시를 지원합니다. 색상은 코드의 구문을 구분하며 컴파일 결과를 대신하지 않습니다.

+

LEARNING RECORDS

학습 기록을 초기화할까요?

이 브라우저의 진도, 파일별 작성 코드와 설계 메모를 모두 지웁니다. 현재 학습 기준 버전은 유지됩니다.

+

LOCAL EXECUTION

내 PC의 실행 환경

설치된 JDK와 Maven의 폴더를 선택하세요. 시스템 PATH를 바꾸거나 도구를 자동 설치하지 않습니다. 코드는 실행 버튼을 눌렀을 때 실행됩니다.

+

WINDOWS APP

내 PC에서 프로젝트까지

Windows 앱에서 등록한 실제 JDK로 코어 코드를 실행하고, Maven으로 Spring·데이터 접근·테스트 프로젝트를 검증합니다.

  • Windows x64와 학습에 필요한 JDK 8·11·17·21을 준비하세요.
  • 실무 프로젝트에는 Maven 3.9 이상이 필요합니다. 앱 설정에서 설치 폴더를 등록합니다.
  • 실행 권한에 동의한 뒤 프로젝트의 환경 준비를 누르세요. 처음에는 Maven Central에서 고정 의존성을 내려받습니다.
  • Java 코드는 PC의 파일·네트워크에 접근할 수 있습니다. 격리된 보안 샌드박스가 아니므로 신뢰하는 코드를 실행하세요.

Windows 설치 파일은 배포 준비 중입니다. 공개된 설치 파일은 GitHub 릴리스에서 확인할 수 있습니다. 웹과 앱의 학습 기록은 각각 저장됩니다.

GitHub 릴리스 확인 ↗
diff --git a/dist/styles.css b/dist/styles.css index e49ec25..817b8b5 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -44,7 +44,7 @@ ul, ol { padding-left: 1.35rem; } .brand-light { font-weight: 400; } .brand-mark { display: inline-flex; flex-shrink: 0; width: 2.25rem; height: 2.375rem; align-items: center; justify-content: center; padding-bottom: .25rem; border-radius: .5625rem; background: var(--green); color: white; font-family: Georgia, serif; font-weight: 600; font-style: italic; font-size: 1.9rem; line-height: 1; } .brand-mark span { color: var(--yellow); font-style: normal; } -.header-navigation { display: flex; align-items: center; gap: 2rem; } +.header-navigation { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem 2rem; max-width: 100%; } .header-link { padding: .5rem 0; font-size: .875rem; color: var(--muted); text-decoration: none; white-space: nowrap; } .header-link.is-current { color: var(--green); font-weight: 700; position: relative; } .header-link.is-current::after { content: ""; width: 4px; height: 4px; border-radius: 50%; position: absolute; bottom: 0; left: calc(50% - 2px); background: var(--green); } @@ -98,7 +98,7 @@ ul, ol { padding-left: 1.35rem; } .version-control label { font-size: .75rem; font-weight: 600; word-break: keep-all; } .version-control select { width: 100%; min-height: 2.5rem; padding: .35rem 1.3rem .35rem .65rem; border: 1px solid #cfd6c7; border-radius: 7px; background: var(--card); color: var(--green); font-size: .75rem; font-weight: 600; max-width: 100%; } .version-control p { grid-column: 1 / -1; color: var(--muted); font-size: .6875rem; } -.track-filter { display: flex; gap: 3px; padding: 4px; border-radius: 8px; background: #e9ebe2; margin-top: 1rem; } +.track-filter { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px; padding: 4px; border-radius: 8px; background: #e9ebe2; margin-top: 1rem; } .track-filter button { min-width: 0; flex: 1; padding: .4rem .15rem; border-radius: 5px; font-size: .6875rem; min-height: 36px; line-height: 1.5; } .track-filter button[aria-pressed="true"] { background: var(--card); box-shadow: 0 1px 3px #173d3510; color: var(--green); font-weight: 700; } .search-field { display: flex; align-items: center; gap: .4rem; margin: .75rem 0 1rem; padding: .55rem .65rem; background: #fffefa; border: 1px solid var(--line); border-radius: 8px; color: #7c867b; } @@ -249,7 +249,7 @@ ul, ol { padding-left: 1.35rem; } .code-block code { font: inherit; } .code-settings { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem .8rem; padding: .65rem .95rem; background: var(--code-toolbar); color: var(--code-muted); } .code-theme-label { font-size: .75rem; } -.editor-theme-select { min-height: 36px; min-width: 6.5rem; max-width: 100%; background: var(--code-bg); color: var(--code-fg); border: 1px solid var(--code-border); border-radius: 6px; padding: .3rem .55rem; font-size: .75rem; } +.editor-theme-select { min-height: 36px; min-width: 0; width: 6.5rem; max-width: 100%; background: var(--code-bg); color: var(--code-fg); border: 1px solid var(--code-border); border-radius: 6px; padding: .3rem .55rem; font-size: .75rem; } .syntax-legend { display: flex; flex-wrap: wrap; gap: .35rem .8rem; padding: .55rem .95rem .7rem; border-bottom: 1px solid var(--code-border); background: var(--code-toolbar); } .syntax-legend-item { display: inline-flex; align-items: center; gap: .3rem; font: .6875rem/1.6 var(--font); } .syntax-legend-item::before { content: ""; display: inline-block; width: .35rem; height: .35rem; border-radius: 50%; background: currentColor; flex-shrink: 0; } @@ -264,6 +264,12 @@ ul, ol { padding-left: 1.35rem; } .tok-operator, .tok-punctuation { color: var(--code-operator); } .tok-annotation { color: var(--code-class); } .syntax-note { margin-top: -.3rem; } +.example-filename { overflow-wrap: anywhere; } +.file-tabs { display: flex; flex-wrap: wrap; gap: .2rem; padding: .5rem .65rem 0; background: var(--code-toolbar); border-bottom: 1px solid var(--code-border); } +.file-tab { display: inline-flex; align-items: center; flex-wrap: wrap; gap: .35rem; max-width: 100%; min-width: 0; min-height: 42px; padding: .5rem .65rem; border-bottom: 2px solid transparent; color: var(--code-muted); font: .75rem/1.65 var(--mono); text-align: left; overflow-wrap: anywhere; } +.file-tab[aria-selected="true"] { color: var(--code-fg); border-bottom-color: var(--code-focus); background: var(--code-active-line); } +.file-tab:focus-visible { outline-color: var(--code-focus); outline-offset: -3px; } +.file-readonly-badge { display: inline-block; padding: .05rem .3rem; border: 1px solid var(--code-border); border-radius: 4px; color: var(--code-muted); font: .6875rem/1.6 var(--font); white-space: normal; } .expected-output { margin: 1.2rem 0; padding: 1rem 1.1rem; border: 1px dashed #cbd6c1; border-radius: 8px; background: #f6f8f1; } .expected-output .small-label { color: #5d7850; margin-bottom: .6rem; font-size: .5625rem; } .expected-output pre { font: .75rem/1.75 var(--mono); color: #385731; white-space: pre; overflow-x: auto; } @@ -309,6 +315,14 @@ ul, ol { padding-left: 1.35rem; } .runtime-card.is-unavailable { background: #faf1e8; border-color: #ebd6c4; } .runtime-card.is-unavailable p, .runtime-card.is-unavailable strong { color: #8e5535; } .runtime-retry { font-size: .625rem; padding: .2rem 0; color: #7b4d31; text-decoration: underline; } +.runtime-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .7rem; margin-top: .8rem; } +.runtime-controls label { font-size: .75rem; font-weight: 600; } +.runtime-controls select { max-width: 100%; min-height: 42px; background: var(--card); border: 1px solid #c5d3bb; border-radius: 6px; color: var(--green); padding: .35rem .6rem; font-size: .8125rem; } +.runtime-controls .button, .project-access-actions .button { font-size: .75rem; } +.project-access-actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .85rem; } +.project-preparation { border-top: 1px solid #dce5d3; padding-top: .55rem; margin-top: .65rem !important; } +.project-preparation[data-prepared="true"] { font-weight: 650; } +.project-service { overflow-wrap: anywhere; } .lab-instructions { padding-left: 1.3rem; font-size: .875rem; line-height: 1.9; margin: 1rem 0; color: #596852; } .lab-instructions li + li { margin-top: .35rem; } .lab-rubric { margin: 1rem 0; border: 1px solid #dfe5d8; border-radius: 8px; padding: .7rem .9rem; } @@ -316,7 +330,7 @@ ul, ol { padding-left: 1.35rem; } .lab-rubric ul { margin-top: .6rem; font-size: .6875rem; line-height: 1.85; color: #687762; } .editor-shell { border: 1px solid var(--code-border); border-radius: 10px; overflow: hidden; margin-top: 1.2rem; background: var(--code-bg); } .editor-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .3rem .7rem; background: var(--code-toolbar); border-bottom: 1px solid var(--code-border); padding: .55rem .85rem; color: var(--code-muted); } -.editor-filename { display: inline-flex; align-items: center; gap: .4rem; font: .75rem/1.6 var(--mono); } +.editor-filename { display: inline-flex; align-items: center; flex-wrap: wrap; gap: .4rem; min-width: 0; max-width: 100%; font: .75rem/1.6 var(--mono); overflow-wrap: anywhere; } .editor-filename .icon { width: 13px; height: 13px; flex-basis: 13px; } .editor-tools { display: flex; flex-wrap: wrap; gap: .35rem; } .editor-tools button { padding: .3rem .45rem; font-size: .75rem; color: var(--code-muted); border-radius: 4px; min-height: 36px; } @@ -416,7 +430,7 @@ ul, ol { padding-left: 1.35rem; } .drawer-backdrop { position: fixed; inset: 0; background: #10261ab3; z-index: 90; backdrop-filter: blur(2px); } .toast { position: fixed; z-index: 150; left: 50%; bottom: 1.5rem; transform: translateX(-50%) translateY(20px); background: #1d3d2f; color: white; border: 1px solid #466341; padding: .75rem 1.15rem; border-radius: 10px; max-width: calc(100% - 2rem); width: max-content; font-size: .75rem; box-shadow: 0 8px 28px #112b2430; pointer-events: none; opacity: 0; transition: opacity .18s, transform .18s; } .toast.is-visible { opacity: 1; transform: translateX(-50%) translateY(0); } -.app-dialog { width: min(580px, calc(100% - 2rem)); max-height: calc(100dvh - 2rem); border: 1px solid #cbd7be; border-radius: 14px; padding: 1.6rem 1.8rem; color: var(--ink); background: var(--card); box-shadow: 0 20px 70px #162f2040; } +.app-dialog { width: min(580px, calc(100% - 2rem)); max-height: calc(100dvh - 2rem); overflow-y: auto; overscroll-behavior: contain; border: 1px solid #cbd7be; border-radius: 14px; padding: 1.6rem 1.8rem; color: var(--ink); background: var(--card); box-shadow: 0 20px 70px #162f2040; } .app-dialog::backdrop { background: #132c208c; backdrop-filter: blur(2px); } .app-dialog h2 { font-size: 1.35rem; line-height: 1.6; letter-spacing: -.035em; margin-bottom: .75rem; } .app-dialog p, .app-dialog li { font-size: .8125rem; line-height: 1.85; color: #5c6d52; } @@ -428,6 +442,19 @@ ul, ol { padding-left: 1.35rem; } .compact-dialog { max-width: 460px; } .compact-dialog > .small-label { margin-bottom: .65rem; } .dialog-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: .6rem; margin-top: 1.5rem; } +.settings-dialog { width: min(660px, calc(100% - 2rem)); } +.settings-tools { display: grid; gap: .3rem; margin: 1.2rem 0; } +.settings-tool-row { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1rem; padding: .8rem 0; border-bottom: 1px solid var(--line); } +.settings-tool-row > div { flex: 1 1 15rem; min-width: 0; } +.settings-tool-row strong { font-size: .875rem; } +.settings-tool-row .button { font-size: .75rem; } +.settings-tool-path { font-family: var(--mono); font-size: .6875rem !important; overflow-wrap: anywhere; margin-top: .2rem; } +.settings-consent { padding: 1rem; border: 1px solid #dac9b8; background: #faf4eb; border-radius: 9px; } +.settings-consent h3 { font-size: .9375rem; margin-bottom: .4rem; } +.settings-consent p { color: #684e3a; } +.settings-consent label { display: flex; gap: .65rem; align-items: flex-start; margin-top: .85rem; font-size: .8125rem; font-weight: 600; line-height: 1.8; } +.settings-consent input { flex: 0 0 1.1rem; width: 1.1rem; height: 1.1rem; margin: .25rem 0 0; accent-color: var(--green); } +.settings-status { margin-top: .65rem; min-height: 1.8em; overflow-wrap: anywhere; } .noscript-note { margin: 2rem; padding: 1rem; border: 1px solid #cba58c; background: #faf0e5; } @media (min-width: 1600px) { diff --git a/docs/architecture.md b/docs/architecture.md index a0c1e3e..87db1b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,12 +1,12 @@ # 실행 아키텍처와 공개 범위 -기준일 2026-10-12. 프로토타입은 정적 교육 UI와 Cloudflare Worker API를 owner-private Sites에 배포한다. 사용자 기기에 임의 Java 코드를 직접 실행하지 않는다. 공개 GitHub와 개인 전용 호스팅의 접근 범위는 별개다. +기준일 2026-10-12. Windows 설치형 앱과 owner-private Sites의 웹 교재가 같은 UI·교육 데이터를 공유한다. 설치형은 로컬 JDK/Maven으로 학습자 코드를 실행한다. 웹 코어 실행은 Cloudflare Worker API에서 Wandbox로 연결한다. 공개 GitHub와 개인 전용 호스팅의 접근 범위는 별개다. ## Java 실행 계약 브라우저→같은 origin의 실행 API→외부 Wandbox 컴파일/실행 API로 연결한다. 제공자 endpoint·compiler·옵션은 서버가 고정한다. 교육 콘텐츠가 가진 과제 ID·버전·trusted checks를 서버가 결정하며 클라이언트의 기대값·컴파일 옵션을 신뢰하지 않는다. -실제 JVM은21(`openjdk-jdk-21+35`). `--release`로8/11/17/21의 언어/API 대상 컴파일을 검사해도 실행 JVM은21이다. 8/11/17의 실제 런타임 검증은 별도 CI와 후속 실행 서버에서 분리한다. 2026-10-12 실제 확인용 코드는 `--release\n8`과 package-private `class Main`으로 컴파일하고 `JAVA_EDU_OK`·`21-internal`을 출력했다. Java8 obsolete 경고와 종료0을 함께 확인했다. 이 결과는 전체 과제 검증을 대신하지 않는다. +웹 실행의 실제 JVM은21(`openjdk-jdk-21+35`). `--release`로8/11/17/21의 언어/API 대상 컴파일을 검사해도 실행 JVM은21이다. 설치형은 등록한 실제 JDK8/11/17/21을 사용하며 CI에서도 네 런타임을 분리한다. 2026-10-12 웹 확인용 코드는 `--release\n8`과 package-private `class Main`으로 컴파일하고 `JAVA_EDU_OK`·`21-internal`을 출력했다. Java8 obsolete 경고와 종료0을 함께 확인했다. 이 결과는 전체 과제 검증을 대신하지 않는다. 주 파일은prog.java이므로 template의 Solution과 Main은 package-private다. 사용자 Solution과 trusted Main harness를 합쳐 실행한다. 호출·반환 타입·컴파일 대상·샘플/제출 검사는 배포 소스에서 고정한다. 결과는 실행ID·작성 코드 snapshot에 연결한다. 단순 stdout 문구만으로 정답을 판정하지 않는다. 교육용 행동 검증은 악의적 제출에 대한 완전한 부정행위 방지 시스템을 뜻하지 않는다. @@ -22,9 +22,13 @@ Sites private gateway가 접근을 제한하고 인증된 방문자의 `oai-auth 서버에서 요청·소스·응답 크기와 대기시간을 제한하고 동일 origin·과제 ID·JSON 구조를 검사한다. 제공자별429·장애·컴파일 오류·실행 오류·검사 실패를 구분한다. POST 실행을 자동 재시도하지 않는다. 제공자의 IP 쿼터는 shared proxy 요청이 합산될 수 있으며 운영 상한은 미확인이다. 브라우저 취소는 결과 대기를 중단하는 것이며 원격 프로세스 종료를 보장하지 않는다. -## 후속 실행 기반 +## Windows 설치형 실행 기반 -네 실제 JDK, 승인 의존성, 다중 Java/XML/SQL 파일, 임시 DB, HTTP 앱·준비된 외부 시스템을 실행별 격리한다. 컴파일·annotation processing부터 제한한다. 데이터 초기화·하위 프로세스 종료·임시 파일 정리·네트워크 범위를 검사한다. 교육 코드에 호스트 Docker 권한을 제공하지 않는다. 배포 계정·비용·실행 자원은 현재 확보하지 않았다. +사용자가 Windows 설치형 + 웹 교재 구성을 선택했다. Electron 앱의 격리된 렌더러가 세션·Origin·Host·CSRF로 보호된 loopback API와 통신한다. 등록한 실제 JDK8/11/17/21를 검사하고 선택한 JVM으로 실행한다. 전용 실행 디렉터리, 고정된 POM·테스트·편집 파일 목록, 출력·시간·Job Object 프로세스 한도로 실행과 정리를 관리한다. 학습 코드 자체는 학습자 OS 권한으로 동작하는 개인 IDE이므로 보안 샌드박스로 표현하지 않는다. + +Maven 의존성은 명시적인 환경 준비에서 내려받고 과제 실행은 오프라인이다. 기본 DB·웹 요청 실습은 H2·MockMvc를 사용하며 별도 호스팅 컨테이너 서버가 필요하지 않다. Redis/Kafka 단원의 실제 연동은 선택적인 고정 loopback 자원이 필요하다. 상세 계약과 제한은 [로컬 실행 계약](local-execution.md)을 따른다. + +설치형의 진도·초안·테마는 앱 userData에 보존하며 실행 서버의 임의 포트 변경과 무관하다. 웹과 설치형 사이 자동 동기화는 제공하지 않는다. 웹은 실무22단원의 정적 교재·다중 파일 예제·퀴즈·프로젝트 ZIP을 제공하고 해당 실행은 설치형에서 진행한다. 다운로드에는 상대 경로가 유지된 부모 POM·starter·reference·공개 grader를 함께 넣는다. ## 공식 근거 diff --git a/docs/deployment.md b/docs/deployment.md index 4fc3688..5aad61d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -20,3 +20,9 @@ 기능·반응형·키보드·저장소·경쟁 응답은 모의 API 브라우저 QA로, 제공자 실행은 별도 실제 호출로 확인했다. 배포된 인증 세션의 Java 제출·실제 기기·장기 부하 검증은 수행하지 않았다. 상세 범위는 [검증 기록](verification.md)을 따른다. 배포 성공 응답은 런타임 종단 간 검증을 대신하지 않는다. 다음 배포는 현재 프로젝트 ID·접근 범위를 재사용하고 새 source SHA의 CI·독립 검수를 확인한 뒤 저장된 버전으로 배포한다. 이전 버전 재배포 시에도 저장된 정확한 버전 ID와 당시 계약을 확인한다. 공개 접근 전환이나 별도 실행 자원은 추가 결정 대상이다. + +## M4.1 편집기 배포 및 v0.2 미배포 상태 + +M4.1 GitHub 소스 `a4d277e10f6c9e03b6a14f4fb6a11a2672987b54`, CI38204310833 전체6작업 성공. Sites source SHA `de02f178b7e236805e4e1594b7f7f4634ff0dd1e`, 버전 `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_91e1ae45c3f88191b32bc7c994e34ad2`, 배포 `appgdep_6acc654d2f788191b0fcec1f82f8738a`가 2026-10-12 04:43:08 UTC에 succeeded를 반환했다. 같은 개인 전용 URL에 코어12수업과 문법 강조·3테마를 제공한다. + +Windows v0.2.0 설치 파일은 로컬 빌드가 완료됐지만 GitHub Release에는 아직 게시하지 않았다. 34수업 웹도 아직 배포하지 않았다. 미완료 설치·시각 gate 및 새 CI 결과를 확인한 뒤 동일 검증 소스를 게시한다. 실제 최신 상태는 [개발 인계](development-handoff.md)를 따른다. diff --git a/docs/desktop-qa-report.md b/docs/desktop-qa-report.md new file mode 100644 index 0000000..93847b4 --- /dev/null +++ b/docs/desktop-qa-report.md @@ -0,0 +1,96 @@ +# M5·M6 독립 기능 QA + +검증일: 2026-10-12. 기능 판정: **PASS**. 독립 시각 검수는 별도 담당자의 최종 판정을 기다린다. 이 보고서의 앱 검증 대상은 개발 모드 Electron이며, 설치 프로그램 검증과 배포 환경 검증은 포함하지 않는다. + +## 환경과 실행 경계 + +- Windows, Node.js 22.17.0, Playwright 1.62.1, Edge 155.0.4283.45, Electron 44.7.0. +- 웹은 `http://127.0.0.1:4173`의 동결된 공용 UI를 격리된 Edge 컨텍스트에서 검사했다. `/api/**`는 모의 응답으로 처리했으며 외부 실행 제공자에 코드를 전송하지 않았다. +- 앱은 프로젝트의 실제 Electron 실행 파일, preload, 인증된 임시 포트의 로컬 서버와 로컬 실행기를 사용했다. `JAVA_EDU_QA_DATA`로 별도의 `user-data-한글 & 25%-…` 폴더를 만들었다. 기존 사용자 설정·진도는 변경하지 않았다. +- JDK는 OpenJDK 8u265와 Eclipse Temurin 21.0.12.1+1, Maven은 3.10.0을 등록했다. 파일 선택 대화상자의 반환 경로만 모의 처리했고, JDK·Maven 검사, 동의 저장, IPC, HTTP, 컴파일, JVM 실행 및 Maven 테스트는 실제 수행했다. +- 웹의 320/360/390/768/1440 CSS px, 낮은 높이와 방향 전환은 브라우저 에뮬레이션이다. 실제 휴대전화 검증이 아니다. 앱은 기본 창과 최소 외곽 크기 720×560에서 확인했다. 이 환경의 최소 창 내부는 704×521 CSS px였다. +- 글자 확대는 문서 기본 글꼴을 200%로 설정해 검사했다. Windows 시스템 배율·브라우저 페이지 확대·실제 OS IME 검증으로 간주하지 않는다. + +## 실행 결과 + +| 검사 | 실제 결과 | 범위 | +| --- | --- | --- | +| `node --test tests/professional.test.mjs` | 최종 4/4 PASS | 34단원 계약, 22실무 선행학습 관계, 원본·예제·편집 가능 파일, 전체 ZIP의 POM·starter·reference·grader 일치 | +| `JAVA_EDU_QA_MODE=web node tests/desktop.mjs` | 4/4 PASS | 실무 전체 단계·퀴즈, 웹 실행 차단, 파일별 초안과 읽기 전용 파일, ZIP 다운로드, 반응형 | +| `JAVA_EDU_QA_MODE=desktop node tests/desktop.mjs` | 9/9 PASS | 최초 설정, 실제 도구 등록·동의, 실제 JDK 실행·실패·취소, Spring/H2 준비·제출, 재시작 영속성 | +| `node tests/browser.mjs` | 48 PASS, 기대값 정정 후 영향받은 1개 PASS | 기존 49개 시나리오를 유지하고 34단원 기준으로 검사. 한 번의 최종 49/49 실행으로 기록하지 않음 | + +실행 예시(PowerShell, 의존성·번들·실무 자료·웹 서버는 총괄 담당자가 미리 준비): + +```powershell +$env:NODE_TLS_REJECT_UNAUTHORIZED = '1' +$env:NODE_OPTIONS = '--use-system-ca' +$env:PLAYWRIGHT_CHANNEL = 'msedge' +$env:QA_ARTIFACT_DIR = '/work/java-edu-desktop-qa' +node --test tests/professional.test.mjs +$env:JAVA_EDU_QA_MODE = 'web' +node tests/desktop.mjs +node tests/browser.mjs + +$env:JAVA_EDU_QA_MODE = 'desktop' +$env:JAVA_EDU_JDK_8 = '' +$env:JAVA_EDU_JDK_21 = '' +$env:JAVA_EDU_MAVEN_HOME = '' +node tests/desktop.mjs +``` + +수정된 단일 브라우저 검사에는 `QA_FILTER=all-lessons-render-five-learning-steps`를 사용했다. Electron 실행 파일이 없으면 테스트는 실패하며 자동으로 다운로드하지 않는다. `JAVA_EDU_ELECTRON_PATH`로 준비된 실행 파일을 지정할 수 있다. + +## 수용 조건별 근거 + +| 수용 조건 | 확인한 행동과 결과 | +| --- | --- | +| 34단원·버전 선택 | 코어 12+실무 22의 고유 ID 확인. 8/11/17/21 필터 결과를 각 단원의 최소 버전과 대조. 실무 필터에서 Java 8을 고르면 빈 결과와 안내가 나오며 본문은 유지됨 | +| 실무 학습 내용 | 22개 실무 수업의 공식 자료 링크, 예제의 모든 파일 원문, 퀴즈 전체 정답, 실무 단계, 코드 실습을 방문. 퀴즈만 통과해도 실습 배지는 완료되지 않음 | +| 웹 실행 차단 | 실무의 실행·제출 버튼이 비활성이고 설치 안내·ZIP이 표시됨. 준비 버튼 없음. 웹 검사에서 runtime 조회 외의 실행·로컬 API 요청은 0건 | +| 파일별 상태 | Java 초안→읽기 전용 POM→Java 복귀, 다른 수업 왕복, 새로고침 후 초안 보존. 읽기 전용 POM에 키보드 입력을 시도해도 원문 불변. ZIP은 초안이 섞이지 않은 공식 starter임 | +| 최초 실행·도구 등록 | JDK 미등록·동의 없음 상태에서 실행 불가. 폴더 선택 취소로 등록되지 않음. Java 8 등록에 JDK 21 경로를 선택하면 거부. 실제 8·21 및 Maven을 등록해도 자동 코드 실행 없음 | +| 실제 코어 실행 | `java-basics-lab` JDK 8 샘플 1/1, 제출 6/6. JDK 21 제출 6/6. `oop-lab` JDK 21 클래스 제출 5/5. 실제 런타임과 언어 대상 버전을 응답에서 구분 | +| 실제 오류 | 한글 미정의 식별자 컴파일 오류가 실제 출력에 유지됨. 틀린 답은 3/6 실패. 종료 코드 7은 실행 오류. 실패·초안 편집으로 실습 완료를 얻지 않음 | +| 실행 취소 | 테스트 코드가 작업 폴더에 marker를 쓴 뒤 무한 반복하는 것을 확인하고 UI에서 취소. 취소 표시, 작업 폴더 정리, 초안 보존, 미완료 배지를 확인 | +| 동의 철회 | 철회 후 실행·제출 비활성, 초안 유지. 다시 동의해도 자동 실행하지 않음. 실행 중 동의 철회로 자식 프로세스가 종료되는 경우는 본 앱 UI 시나리오에서 별도 수행하지 않음 | +| Maven 준비 구분 | `tx-proxy` Spring/H2 실습은 준비 전 실행 불가. 실제 준비 2/2 통과 후 실행 가능. 준비 요청에 학생 source/files 없음. 준비만으로 학습 완료되지 않으며 초안 보존 | +| 실제 실무 제출 | 준비 이후 편집 가능한 reference 파일을 UI에 입력하고 실제 Maven 제출 2/2 통과. 제출 요청은 편집 가능한 파일만 포함하고 POM을 포함하지 않음. 퀴즈+실습 완료가 함께 표시됨 | +| 앱 재시작 | 실제 창·프로세스 종료 후 같은 QA 데이터로 재실행, 다른 로컬 포트 확인. 코어 초안, 프로젝트 파일 초안, 퀴즈 진도, 테마가 보존됨. 새 앱 세션은 실무 준비를 다시 요구 | +| UI·입력 회귀 | 기존 49개 시나리오의 검색·잘못된 링크·키보드·포커스·오류·취소·stale 응답·불일치 성공 차단·저장소 손상·편집기 fallback·3테마·선택 대비·긴 한글 코드·확대·레이아웃 확인 | + +기존 49개 검사의 실행 응답은 모의 응답이다. 해당 통과를 실제 JVM 또는 외부 실행 제공자 검증으로 확장하지 않는다. 한글 composition은 DOM 이벤트 기반 시뮬레이션이며 실제 Windows IME의 입력 후보 창 검증은 아니다. + +## 발견 사항과 처리 + +1. **동결 전 ZIP 불일치:** 캡스톤 원본 grader가 변경됐는데 생성된 ZIP에는 이전 내용이 남아 정적 검사가 실패했다. 총괄·교재 담당자에게 알렸고 자료 재생성 후 전체 22개 ZIP 내용 검사 PASS. 빌드가 필요한 생성물이라는 점을 유지한다. +2. **테스트 기대값 정정:** MyBatis 수업은 편집 가능한 XML을 처음 보여주고 Java는 읽기 전용이다. 기존 단일 파일 검사에서 Java `template`을 첫 본문으로 기대해 1건 실패했다. 새 프로젝트 계약에 따라 최초 편집 가능 파일 원문과 대조하도록 정정한 뒤, 전체 34단원을 도는 해당 시나리오 PASS. 앱 결함으로 분류하지 않는다. +3. **최초 Electron 시작 환경:** 처음에는 패키지의 실행 파일 조회 과정에서 자동 다운로드가 시작됐고 상속된 TLS 검사 해제 변수의 경고와 45초 launch 시간 초과가 발생했다. 부모에게 즉시 보고했다. 준비된 바이너리의 절대 경로를 명시하고 실행 환경을 정리한 재시도는 9/9 PASS. 첫 시도의 정확한 지연 원인을 확정하지 않으며, 최종 테스트는 실행 파일이 없으면 다운로드 없이 중단한다. 종료된 최초 PID와 최종 앱의 정상 종료를 확인했다. +4. **독립 코드 검토의 링크 정책 P2:** 앱 검사 종료 후 별도 리뷰에서 일부 공식 문서 호스트가 앱의 외부 링크 허용목록에 빠진 문제가 확인됐다. 백엔드 담당자가 `reference-policy.cjs`로 정책을 분리·보완하고 로컬 계약 16/16 및 독립 재리뷰 PASS를 보고했다. 이 결과는 본 QA 담당자가 외부 브라우저를 실제 열어 재검증한 결과와 구분한다. 실행 엔진·IPC·초기화의 변경은 없다고 담당자가 확인했으며, 수정 후 설치 패키지 검증은 총괄 담당자의 범위다. + +현재 수행한 기능 범위에 미해결 주요 결함은 없다. 시각 검수 및 설치 패키지의 판정은 독립 기록으로 확인한다. + +## 산출물과 화면 + +실제 산출물 디렉터리: `/work/java-edu-desktop-qa/`. + +- 웹 4개 결과: `results-web-1791781255008.json`. +- 실제 앱 9개 결과: `results-desktop-1791781614522.json`. +- 최초 앱 환경 실패: `results-desktop-1791781284948.json` — 최종 PASS 근거가 아님. +- Edge 공용 실무 UI: `web-professional-theory.png`, `web-project-editor.png`, `web-project-readonly-pom.png`, `web-project-{dark,light,contrast}-{java,xml,sql}.png`. +- Edge 반응형: `web-project-320x720.png`, `web-project-390x844.png`, `web-project-768x1024.png`, `web-project-1440x900.png`, `web-project-844x390.png`, `web-project-320-200.png`, `web-project-mobile-200-{files,runtime}.png`, `web-install-dialog-320-200.png`. +- 실제 앱 설정: `desktop-first-settings.png`, `desktop-configured-settings.png`, `desktop-settings-200.png`, `desktop-settings-minimum-200.png`, `desktop-settings-minimum-200-consent.png`. +- 실제 JVM: `desktop-core-running.png`, `desktop-core-passed-jvm21.png`, `desktop-korean-compiler-error.png`, `desktop-cancelled.png`. +- 실제 Spring/H2: `desktop-spring-h2-preparing.png`, `desktop-spring-h2-prepared.png`, `desktop-spring-h2-submitted.png`, `desktop-restarted-project.png`, `desktop-project-200.png`. + +`failure-…` 이미지는 당시의 실패 증거로 남겨 두며 최종 정상 화면 근거로 사용하지 않는다. 이름이 `desktop-…`인 기존 웹 회귀 이미지는 Edge 화면일 수 있다. 위에서 **실제 앱**으로 구체적으로 열거한 파일만 Electron 캡처다. + +## 남은 범위 + +- 실제 JDK 11·17 실행, ARM·macOS·Linux, Safari·Firefox, 실제 모바일 기기, OS 화면 배율 및 실제 IME는 이 검증에서 수행하지 않았다. +- 실제 NSIS 설치·제거, 설치된 바이너리에서의 동작, 코드 서명·SmartScreen, 릴리스 다운로드 및 배포 웹의 결과는 총괄 담당자의 별도 검증이다. +- 실제 실행을 수행한 실무 UI 대표는 `tx-proxy` Spring/H2이다. 모든 22개 실무 프로젝트, 실제 Redis/Kafka 컨테이너, 외부 네트워크 장애·프록시·기업 인증서는 본 독립 앱 시나리오 범위에 포함되지 않는다. +- 실행 중 앱 강제 종료, 악의적 코드의 보안 경계, 디스크 가득 참 등 OS 장애는 이 UI 검사로 검증했다고 주장하지 않는다. 별도 백엔드·보안 검토가 필요하다. +- ZIP과 링크 원문은 검사했으나 이번 검사에서 모든 외부 링크를 실제 방문하거나 채용 공고의 현재 유효성을 다시 조사하지 않았다. + +수정한 파일: `tests/browser.mjs`, `tests/desktop.mjs`, `tests/professional.test.mjs`, 이 보고서. 앱 구현 파일은 QA 담당자가 수정하지 않았다. diff --git a/docs/development-handoff.md b/docs/development-handoff.md new file mode 100644 index 0000000..1486a09 --- /dev/null +++ b/docs/development-handoff.md @@ -0,0 +1,41 @@ +# Java Edu 개발 인계 — 2026-10-12 + +프로젝트 폴더는 이 문서의 상위 저장소 루트 `java-learning-lab`이다. 원격은 https://github.com/jbsim999/java-edu, 브랜치는 `feat/java-learning-mvp`, PR은 https://github.com/jbsim999/java-edu/pull/1 이다. 파일 이동이나 새 clone 없이 현재 폴더를 로컬 프로젝트의 주 폴더로 등록하면 된다. 새 대화는 AGENTS.md, CONTRIBUTING.md, 이 문서를 먼저 읽는다. + +## 승인된 목표 + +- 다른 언어 경험자·현직자 대상. OOP·타입·동시성 심화와 공식 Java8/11/17/21 기준. +- 이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩. 클래스 전체·메서드·로직과 다중 파일 과제. +- Windows 설치형 + 같은 웹 교재. 기본 실행은 학습자 PC의 JDK/Maven/H2이며 호스팅 컨테이너 서버가 필요하지 않다. +- 마일스톤 커밋·푸시 및 검증된 앱·웹의 배포는 사용자 승인됨. 강제 푸시·PR 병합·유료 구매·사이트 공개 범위 변경은 승인되지 않음. + +## 구현과 실제 검증 + +Java 코어12수업 + 실무22수업 = 총34수업·68문항·34코딩 과제. Spring/REST/Lombok/MyBatis/JPA/트랜잭션/Security/OIDC/JWT/Redis/Kafka/관측/빌드·운영을 포함한다. Java 코드 문법 색상, 다크·라이트·고대비 테마, 읽기 전용 보조 파일과 파일별 초안, 진도·테마의 재시작 저장을 구현했다. + +실무 기본20프로젝트를 JDK21/컴파일 대상17/Maven3.10.0에서 실제 실행해 기준 답안54개 JUnit 검사가 통과하고 미완성 starter20개가 실패했다. 독립 리뷰가 현재129소스 파일·부모 POM과 실제 실행 복사본의 해시 및 보고서를 대조했다. Redis/Kafka2개는 컴파일만 확인했고 실제 서비스 연동은 미검증이다. + +독립 기능 QA: 실제 개발 Electron9/9, 웹 실무 모의API4/4, 기존 브라우저49개 회귀 PASS. 로컬 계약16/16, 공용 Node35 PASS/실제 외부 제공자1 SKIP. 독립 코드 리뷰의 미해결 P1/P2는 없다. 새 링크 정책은 실제 교재 출처90개·공고5개·UI/설치 링크를 검사했다. 정확한 검사 구분과 이미지 경로는 docs/desktop-qa-report.md 및 docs/local-execution.md를 따른다. + +최종 NSIS 빌드는 성공했고 `desktop-release/Java-Edu-Setup-0.2.0-x64.exe`가 있다. 크기111738936바이트, SHA256 `4e437a43afba366bab838b05ef0911acdbe8e95c2d5fec6461fb9fa409d02a21`. 서명되지 않은 Windows x64 설치 파일이다. 설치·제거 및 설치된 바이너리 실행은 아직 수행하지 않았다. `win-unpacked/Java Edu.exe` 역시 별도 패키지 실행 검증이 남는다. + +## 배포 상태와 남은 작업 + +현재 웹은 https://java-edu-lab-jbsim999.junbo1218.chatgpt.site 에 M4.1 편집기 테마까지 배포돼 있다. 마지막 기존 GitHub 출시 커밋은 `a4d277e10f6c9e03b6a14f4fb6a11a2672987b54`, 기존 CI38204310833 전부 성공. 새34수업 웹과 Windows v0.2.0 릴리스는 아직 게시하지 않았다. + +독립 시각 검수 및 설치 검증 에이전트가 실제 서비스 사용량 한도 응답으로 마지막 게이트를 끝내지 못했다. 완료된 기능 검사를 설치 파일·최종 시각 승인으로 바꾸지 않는다. 한도 해제 후 다음을 수행한다. + +1. `git status`와 이 문서 이후 커밋·CI 결과를 확인한다. 사용자 변경을 보존한다. +2. docs/desktop-qa-report.md의 실제 Electron 및 Edge 화면을 독립 시각 담당자가 검토한다. 최초 실패 이미지를 최종 정상 근거로 사용하지 않는다. +3. 별도 설치 검증: workspace/work/installed-java-edu-test 에 현재 사용자 NSIS 설치 → 설치된 앱 실제 core JDK21 제출·Spring/H2 tx-proxy → 종료/재시작 저장 → 해당 테스트 설치만 제거. 기존 AppData/바로가기/제거 정보가 있으면 보존한다. 설치 전 APPDATA/java-edu와 APPDATA/Java Edu는 존재하지 않았으나 현재 상태를 다시 확인한다. production은 JAVA_EDU_QA_DATA 우회를 허용하지 않는다. +4. 새 CI: 콘텐츠/Worker/브라우저, Java8/11/17/21, Windows 로컬8/11/17/21, 실무17/21을 확인한다. 새 CI는 정의만 완료됐으며 결과를 따로 확인해야 한다. +5. 같은 검증 커밋으로 Sites 공식 source helper를 통해34수업을 빌드·패키징·배포한다. 프로젝트 ID `appgprj_6acc4fec8bb48191a296197d2d55c1e3`, 기존 소유자 전용 접근을 유지한다. Sites source SHA와 GitHub SHA는 별개다. Sites 체크아웃은 workspace/work/java-edu-site-source. 토큰을 파일·출력·명령 인수에 기록하지 않는다. +6. 같은 검증 커밋을 v0.2.0으로 GitHub Release에 게시하고 설치 EXE 및 SHA256 파일을 첨부한다. PR 본문·README·milestones·verification·deployment를 실제 결과로 갱신한다. PR은 병합하지 않는다. + +## 개발 환경 + +Node22+, Windows, `npm ci` 후 `npm run dev` 또는 `npm run desktop`. 생성된 editor.bundle/professional.js/ZIP은 Git에 포함하지 않으며 빌드 스크립트로 재생성한다. `npm run build:desktop`은 최종 소스에서 설치 파일을 재빌드한다. + +이 작업 환경은 상속된 NODE_TLS_REJECT_UNAUTHORIZED=0 값이 있었다. 네트워크/빌드 실행에서는 반드시1로 설정하고 Node22.17은 NODE_OPTIONS=--use-system-ca 또는 node --use-system-ca로 시스템 CA를 사용한다. 인증서 검사 해제를 오류 해결책으로 사용하지 않는다. + +로컬 도구 경로는 시스템 변경 없이 workspace/work/toolchains/jdk21/jdk-21.0.12.1+1 및 workspace/work/toolchains/maven/apache-maven-3.10.0 이다. JDK8은 C:/Program Files/Java/java-1.8.0-openjdk-1.8.0.265-1. 앱 등록은 폴더 선택 방식이다. 설치 앱·실제 JDK11/17·실제 Redis/Kafka·실제 모바일·IME·기기 간 동기화의 미검증 범위를 유지한다. diff --git a/docs/education-roadmap.md b/docs/education-roadmap.md index ce505f2..99a8a8a 100644 --- a/docs/education-roadmap.md +++ b/docs/education-roadmap.md @@ -1,4 +1,4 @@ -# Java Edu 교육 로드맵 v0.2 +# Java Edu 교육 로드맵 v0.3 기준일 2026-10-12. 전환 경로는 진단→Java 코어→테스트·빌드→Spring·DB→인증·운영→통합 프로젝트, 현직자 경로는 진단→부족한 코어→설계·동시성·정합성→버전 전환→개선 프로젝트다. 경력·객관식 총점만으로 단원을 면제하지 않는다. @@ -28,3 +28,13 @@ OOP·타입→컬렉션·실패→테스트→Spring 실행 모델→영속성 공통 프로젝트는 주문·재고 예약·취소다. 도메인 모델→테스트→HTTP API→영속화/트랜잭션→권한/중복/동시 요청→캐시/이벤트/관측→버전 상향을 연결한다. 모든 도구를 한 프로젝트에 무조건 끼워 넣지 않는다. 선택 심화는 Batch·스케줄링, QueryDSL·검색, WebFlux, Kubernetes·분산 설계로 분기한다. 수료는 원리 설명·반례·코드 수정·행동 검증·설계 대안과 비용 설명으로 판정한다. 학습 효과는 실제 학습자 검토 전까지 미확인이다. + +## 웹·설치형 학습 순서 + +1. Java 코어12단원에서 버전별 API, OOP, 타입·컬렉션·실패·동시성·설계 검증을 학습한다. +2. Maven → JUnit·AssertJ → Mockito → Lombok → Spring DI·AOP → MVC·Validation으로 진행한다. +3. SQL/JDBC 뒤 MyBatis와 JPA를 비교하고 Flyway·트랜잭션·낙관적 잠금으로 정합성을 검증한다. +4. Security·CSRF → OAuth2/OIDC → JWT의 역할·검증 경계를 학습한다. +5. Redis/Kafka 선택 실습, Micrometer 관측, Gradle/Docker/CI 비교를 거쳐 주문·재고·취소 통합 과제를 수행한다. + +실무22단원의 선수 관계·공식 근거·실습 파일은 [실무 과정 명세](professional-curriculum.md)와 curriculum/professional/catalog.json에 연결한다. 현대 Spring 실습은 Java17 대상으로 컴파일하고 JDK17/21 실행을 검증한다. Java8/11은 코어·레거시 이행 교재에 사용한다. 웹에서 읽고 프로젝트 ZIP을 내려받거나, Windows 앱에서 같은 코드와 고정 테스트를 직접 실행한다. 기본20개 실무 단원에는 별도 DB 서버가 필요하지 않다. Redis/Kafka 두 단원은 선택 자원을 별도로 준비한다. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..24b1d1c --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,28 @@ +# Windows 설치와 학습 시작 + +Windows 10/11 x64용 현재 사용자 설치 앱이다. 설치 파일과 SHA-256은 이 저장소의 GitHub Releases에 함께 제공한다. 앱은 Java와 Maven을 포함하지 않으며 시스템 PATH를 바꾸지 않는다. + +## 준비 + +1. [Eclipse Temurin JDK 21](https://adoptium.net/temurin/releases/?version=21)을 설치하거나 ZIP을 풀어 둔다. 기존 JDK8/11/17/21도 등록할 수 있다. JRE가 아닌 java와 javac가 함께 있는 JDK가 필요하다. +2. Spring 등 실무 프로젝트에는 [Apache Maven 3.9 이상](https://maven.apache.org/download.cgi)을 준비한다. Java 코어 과제에는 Maven이 필요하지 않다. +3. Java Edu 설치 파일을 실행하고 설치 위치를 선택한다. 현재 사용자용으로 설치하며 관리자 권한을 요구하지 않는다. 배포 파일은 코드 서명되지 않았다. + +## 앱에서 시작 + +1. 앱의 실행 환경 설정에서 JDK의 최상위 폴더를 선택한다. 앱이 java/javac 버전의 일치를 확인한다. +2. 실무 과제를 풀 때 Maven 최상위 폴더도 선택한다. +3. 로컬 학습 코드 실행에 동의한 후 실제 실행 JDK를 선택한다. 학습 코드에는 사용자 PC의 파일·네트워크 접근 권한이 있으므로 자신의 학습 코드를 실행한다. +4. Java 코어는 코드를 작성하고 실행·제출한다. 실무 프로젝트는 먼저 환경 준비를 눌러 고정된 의존성과 기준 테스트를 확인한 뒤 파일 탭에서 구현하고 제출한다. + +설치형의 작성 코드가 Wandbox로 전송되지는 않는다. 최초 환경 준비에는 Maven Central 다운로드가 필요하며 과제 실행은 오프라인 모드다. 진도·초안·메모·테마는 앱 데이터에 보존된다. 앱 제거 시에도 학습 데이터는 기본 보존한다. + +## 실무 과정 + +기본20개 프로젝트는 추가 DB 서버 없이 H2·MockMvc·로컬 테스트로 실행한다. Redis/Kafka 실제 연동 두 단원은 선택 환경이며 해당 교재의 고정 loopback 주소·포트를 사용한다. 공유 운영 서비스에 연결하지 않는다. Gradle/Docker/CI 비교 파일은 교재 자료이며 앱의 자동 실행 도구는 Maven이다. + +## 웹에서 학습 + +웹도 같은34단원의 이론·예제·퀴즈·메모·문법 강조 편집기를 제공한다. 실무 프로젝트는 ZIP을 내려받아 IDE에서 사용하거나 Windows 앱에서 실행한다. ZIP의 부모 pom.xml과 lessons/<단원>/starter·reference·grader의 상대 위치를 유지해야 한다. starter 또는 reference 폴더에서 mvn test를 수행한다. + +웹 Java 코어 실행은 Wandbox의 실제 JVM21로 연결하고8/11/17/21 컴파일 대상을 구분해 표시한다. 웹 진도는 해당 브라우저에 저장되며 앱과 자동 동기화되지 않는다. 현재 Sites 웹 배포는 소유자 전용 접근을 유지한다. diff --git a/docs/local-execution.md b/docs/local-execution.md new file mode 100644 index 0000000..73cbce0 --- /dev/null +++ b/docs/local-execution.md @@ -0,0 +1,90 @@ +# Windows 설치형 실행 계약 + +Windows 설치형 앱과 웹 교재는 같은 교육 화면을 사용한다. 설치형의 Java/Maven 실행은 학습자 PC에서만 수행하며 원격 Java 제공자로 코드를 보내지 않는다. 웹은 교재·문제·코드 편집과 기존 코어 실행 환경을 제공한다. 이 앱은 개인 IDE이며 Java 코드의 파일·네트워크 접근을 막는 보안 샌드박스가 아니다. + +## 설치와 도구 등록 + +- Windows 10 이상 x64 현재 사용자용 NSIS 설치 파일. 관리자 권한·시스템 PATH 변경·자동 JDK/Maven 다운로드·자동 업데이트는 사용하지 않는다. +- 앱 설정에서 JDK 8·11·17·21의 설치 디렉터리와 Apache Maven 3.9 이상(Maven 3)의 디렉터리를 선택한다. java/javac 버전을 함께 검사한다. +- 학습자 코드 실행에 대한 명시적인 동의가 필요하다. 앱을 여는 것만으로 학습 코드를 실행하지 않는다. 동의 문구는 파일·네트워크 접근 권한을 설명한다. 동의를 철회하면 진행 중인 실행도 취소한다. +- 기본 실제 JVM은 등록한 JDK 중 수업의 최소 버전을 만족하는 가장 높은 버전이다. 실행 요청의 runtimeVersion으로 다른 등록 JDK를 선택할 수 있다. 교재의 Java 버전·컴파일 대상·실제 JVM을 구분한다. +- 실무 프로젝트 환경 준비는 명시적인 버튼으로 고정 기준 답안의 Maven 테스트를 실행한다. Maven Central에서 고정 버전 의존성을 내려받는다. 이후 학습자 Maven 실행은 오프라인 모드다. 앱 재시작 또는 JDK 변경 뒤에는 환경 준비를 다시 확인한다. + +## 브라우저 연결과 IPC + +Electron은 contextIsolation=true, nodeIntegration=false, sandbox=true, webSecurity=true로 설정한다. 정적 교재와 API는 127.0.0.1의 임의 포트에만 바인딩한다. Host·Origin을 실제 바인딩 주소와 대조하며 모든 요청에는 앱 세션의 HttpOnly/SameSite=Strict 쿠키가 필요하다. 상태 변경에는 정확한 Origin과 Electron main이 붙이는 CSRF 헤더도 필요하다. 세션 토큰을 URL·쿼리·localStorage에 저장하지 않는다. 외부 탐색·새 앱 창·권한 요청은 차단하고 허용한 출처 링크만 OS 브라우저로 연다. + +외부 출처 정책은 `desktop/reference-policy.cjs`의 명시적 호스트 목록을 사용한다. 교재 공식 문서·검토한 채용 공고·도구 다운로드·저장소 릴리스만 포함하며 와일드카드를 사용하지 않는다. HTTPS라도 사용자 정보, 명시적 포트, 인코딩된 호스트 이름 또는 다른 호스트는 거절한다. 34개 수업의 출처 90개와 채용 근거 5개, UI·설치 안내 링크를 실제 데이터와 대조하는 회귀 검사가 새 출처 누락을 탐지한다. + +`window.javaEduDesktop`이 있으면 설치형이다. 브리지는 다음 메서드만 노출한다. main은 기본 창의 mainFrame과 같은 origin인지 검사한다. + +| 메서드 | 입력 | 결과 | +| --- | --- | --- | +| getSettings() | 없음 | 아래 runtime 응답 | +| chooseJdk(version) | 8/11/17/21 숫자 | OS 폴더 선택 후 runtime 응답 | +| chooseMaven() | 없음 | OS 폴더 선택 후 runtime 응답 | +| setExecutionConsent(accepted) | boolean | 동의 저장 후 runtime 응답 | +| storage.getItem(key) | java-edu: 접두 키 | 문자열 또는 null | +| storage.setItem(key,value) | 접두 키·문자열 | 동기 저장 | +| storage.removeItem(key) | 접두 키 | 동기 삭제 | + +교재 UI는 `window.javaEduDesktop?.storage ?? window.localStorage` 어댑터를 사용한다. 설치형 학습 데이터는 고정 userData/learning-state.json에 저장하므로 임의 포트와 앱 재시작에 영향을 받지 않는다. 총 8 MiB를 넘으면 저장 실패를 UI에 알린다. 렌더러에 임의 파일·경로·명령·환경 변수 IPC는 제공하지 않는다. + +## HTTP API + +일반 fetch를 사용한다. Electron main이 인증 쿠키와 CSRF 헤더를 관리한다. + +- GET `/api/runtime`: 기존 provider/available/runtime/supportedTargets/notice 외에 `mode:'local'`, `runtimes:[{javaVersion,available,path}]`, `maven:{available,version,path}`, `consent:{accepted,notice}`, `dependencyNotice`, `reason`, `limits`, `preparedProjects:[{exerciseId,runtimeVersion,jdkPath,mavenPath,mavenVersion}]`. 도구 미등록/동의 전에도 200이며 available=false다. 실제 실행에서 도구 버전을 다시 확인한다. 준비 상태는 현재 등록한 JDK 경로와 Maven 경로·버전에 일치하는 항목만 반환한다. +- GET `/api/local/projects/`: `{exerciseId,lessonId,title,profileId,runtimeRequirements,files:[{path,source,editable}],notice}`. 파일은 교재의 고정 allowlist에 해당한다. +- POST `/api/execute`: 코어는 `{exerciseId,source,action:'run'|'submit',requestId,runtimeVersion?}`. 실무는 source 대신 `files:{고정상대경로:소스}`. 편집 가능 파일 전부를 제출하며 pom.xml·grader·명령·URL은 변경할 수 없다. +- POST `/api/local/prepare`: `{exerciseId,requestId,runtimeVersion?}`. 기준 답안으로 환경 준비. 결과에 `prepared:boolean`이 추가된다. +- POST `/api/local/cancel`: `{requestId}` → `{cancelled:boolean}`. HTTP 응답 연결이 끊어져도 해당 실행을 취소한다. + +실행 결과는 `{requestId,provider,runtimeVersion,targetVersion,status,stdout,stderr,compilerOutput,checks:[{name,passed}],passed,total,durationMs,error?}`다. status는 기존 passed/failed/compile_error/runtime_error/timeout/provider_error 집합을 유지한다. 로컬 설정·입력 오류는 HTTP 400/404/409/413/429/503과 `{error:{code,message}}`다. 실무의 실행/제출은 모두 교재에 고정된 테스트 전체를 수행한다. + +## 실행·실패 경계 + +- 한 번에 한 실행. 동시 요청은 429, 2분 안의 동일 requestId 재사용은 409. 자동 재시도는 없다. +- 앱의 검증된 전용 jobs/job-* 디렉터리에만 교재 파일을 만든다. core 소스 32 KiB, 실무 파일별 64 KiB/합계 256 KiB, 요청 384 KiB, 출력 128 KiB 제한. +- Java 컴파일 20초, 코어 실행 5초, 실무 테스트 120초, 최초 환경 준비 300초. 초기 보호 한도이며 측정 성능 보장이 아니다. +- Windows trusted PowerShell helper가 PROC_THREAD_ATTRIBUTE_JOB_LIST를 통해 CreateProcess(CREATE_SUSPENDED)와 Job Object 배정을 원자적으로 수행한 뒤 Resume한다. 표준 입출력 핸들만 명시적으로 상속하며 job handle은 상속하지 않고 KILL_ON_JOB_CLOSE를 사용한다. helper는 최초에 앱 부모의 process handle을 열고 앱 종료·자식 종료·timeout을 함께 감시한다. 생성 직후 Resume 전 helper가 종료되더라도 Job에 배정된 자식이 정리된다. Resume 실패 경로도 자식을 종료한다. +- 코어 Job memory 512 MiB/자식 8개, Maven 1536 MiB/자식 16개. Java heap은 코어 128 MiB, 컴파일러 256 MiB, Maven+테스트 512 MiB다. Windows 메모리 한도는 전체 프로세스 묶음이며 JVM heap과 다르다. +- Maven은 선택한 JDK로 classworlds launcher를 직접 호출한다. shell=false이며 cmd/mvn.cmd, 학습자 wrapper·POM·프로파일·명령을 실행하지 않는다. 전용 저장소·고정 settings·고정 Maven Central mirror를 사용한다. JAVA_TOOL_OPTIONS/JDK_JAVA_OPTIONS/_JAVA_OPTIONS/CLASSPATH/MAVEN_OPTS/MAVEN_ARGS 등 사용자의 실행 옵션·자격증명을 상속하지 않는다. +- Surefire는 forkCount=0으로 매 작업의 독립 Maven JVM 안에서 테스트한다. 이 설정은 Windows 경로의 `%이름%` 문자가 Surefire의 내부 cmd 실행에서 확장되는 문제를 피한다. 수업 간 JVM은 공유하지 않는다. +- Windows Maven TLS는 Windows-ROOT 신뢰 저장소를 사용하며 인증서 검증을 유지한다. JVM·OS의 인증서 저장소를 변경하지 않는다. +- 코드 실행 권한과 전체 PC 접근 가능성은 별개로 남는다. 제어된 파일 편집 API·Job Object·timeout은 악성 학습 코드의 완전한 격리를 보장하지 않는다. 테스트 판정은 교육 피드백이며 부정행위 방지용 보안 채점 서버가 아니다. +- H2·MockMvc·협력 객체 테스트는 교재의 해당 범위만 검증한다. 실제 Redis/Kafka/외부 OAuth 서비스 운영 검증은 별도 환경과 실제 연결 시험이 필요하다. +- Redis·Kafka 2개 선택 단원은 학습 전용 127.0.0.1:16379 / 127.0.0.1:19092 고정 주소만 전달한다. 먼저 750ms TCP 준비 확인을 수행하며 미준비면 unsupported_resources/409로 안내한다. 포트 연결 성공은 서비스 동작 검증이 아니며 뒤의 실제 통합 테스트가 성공해야 완료된다. 임의 주소·포트·사용자 환경변수를 받지 않는다. 나머지 기본 실무 단원에는 외부 서버를 요구하지 않는다. + +## 근거 + +- [Electron security](https://www.electronjs.org/docs/latest/tutorial/security/), [context isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation), [process sandboxing](https://www.electronjs.org/docs/latest/tutorial/sandbox). +- [Windows Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects), [CreateProcessW](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw), [WaitForMultipleObjects](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitformultipleobjects). +- [UpdateProcThreadAttribute / JOB_LIST](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute), [JSSE trust store settings](https://docs.oracle.com/en/java/javase/21/security/java-secure-socket-extension-jsse-reference-guide.html). +- [Maven CLI options](https://maven.apache.org/ref/3.9.11/maven-embedder/cli.html), [Maven settings](https://maven.apache.org/settings.html), [electron-builder NSIS](https://www.electron.build/nsis.html). + +## 2026-10-12 로컬 엔진 검증 + +환경: Windows x64, Node 22.17.0, OpenJDK 8u265, Temurin JDK 21.0.12.1, Apache Maven 3.10.0. 기준 답안은 저장소에 작성된 고정 교재 코드만 사용했으며 원격 Java 실행 제공자를 호출하지 않았다. + +| 검사 | 실제 결과 | +| --- | --- | +| `node --test tests/local/*.test.mjs` | 16 PASS. 입력/파일 allowlist·동의·1작업 한도·중복 요청·취소·원자적 저장·Maven 설정·선택 자원·HTTP 인증·외부 링크 정책 계약 | +| 외부 문서·채용·설치 링크 | 34수업 출처 90개, 공고 5개, 실제 UI·설치 안내의 HTTPS URL 허용 PASS. 악성 프로토콜·사용자 정보·명시 포트·호스트 혼동 거절 PASS. OS 브라우저의 원격 페이지 로드 검증과는 구분 | +| Windows 프로세스 supervisor | 공백·한글·&·%·따옴표 인수 보존, timeout 자손 종료, 출력 초과 종료, 앱 부모 강제 종료 뒤 자손 종료 PASS | +| 생성 직후 취소 경계 | 제품 helper의 별도 계측 복사본을 CreateProcess 직후 중지한 다음 helper를 종료. Resume 전 자식도 ESRCH로 종료 확인 PASS | +| 로컬 실제 JVM 8 | 코어 9과제·56 행동 검사 PASS | +| 로컬 실제 JVM 21 | 코어 12과제·73 행동 검사 PASS | +| 한국어 컴파일 진단 | Java 8·21 각각 `없는변수` 식별자가 포함된 실패 진단의 UTF-8 원문 보존 PASS | +| Maven build-maven | 환경 준비 2/2·오프라인 제출 2/2 PASS | +| Maven mvc-rest | 환경 준비 3/3·오프라인 제출 3/3 PASS | +| Maven mybatis-mapping | 환경 준비 2/2·오프라인 제출 2/2 PASS | +| Maven jpa-state | 환경 준비 3/3·오프라인 제출 3/3 PASS | +| Maven security-csrf | 환경 준비 4/4·오프라인 제출 4/4 PASS | +| Maven jwt-validation | 환경 준비 5/5·오프라인 제출 5/5 PASS | +| Maven tx-proxy | 환경 준비 2/2·오프라인 제출 2/2 PASS | +| Maven 특수 경로 회귀 | 한글·공백·&·`%JAVA_HOME%`를 포함한 작업/저장소 경로에서 위 7개 실습의 준비와 오프라인 제출 전부 PASS. 최종 forkCount=0 실행 방식으로 확인 | + +실제 Java 11·17 PC 런타임은 이 환경에 설치되지 않아 로컬 엔진으로 실행하지 않았다. 다른 JDK의 CI 결과와 별개다. Redis·Kafka 실제 서비스 연결은 미검증이며 미준비 시 409 반환과 고정 loopback 주소 정책만 검증했다. Electron 전체 화면 흐름·설치 파일·패키지 실행 검증은 독립 QA 및 최종 릴리스 기록을 따른다. + +실행 중 발견한 실패는 숨기지 않았다. Java 8 도구 검사에서 자동 heap 크기가 Job 메모리 한도를 넘는 문제는 명시적 heap 한도로 수정했다. Maven의 첫 TLS 실패는 Windows-ROOT의 정상 인증서 검증으로 해결했다. Surefire 자식 JVM은 PATHEXT 누락과 `%JAVA_HOME%` 경로의 내부 셸 확장에서 실패했으며, 최종 구현은 forkCount=0으로 매 작업의 독립 Maven JVM에서 테스트하여 내부 셸 호출을 없앴다. JDK 21의 표준 오류 인코딩은 file.encoding만으로 고정되지 않아 stdout/stderr 인코딩도 명시했다. JWT 기준 테스트의 모호한 RSAKey import는 콘텐츠 담당자가 고친 후 5/5로 재검증했다. diff --git a/docs/milestones.md b/docs/milestones.md index 8d6441d..51e1da2 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -8,11 +8,13 @@ | M2 | Java 코어12수업·과제·UI·진도·출처·CI | 콘텐츠 정합성·예제/답안·UI mock 검사·독립 리뷰·커밋/푸시 | 완료: `731b4a2` 원격 반영·CI PASS | | M3 | 실제 JVM21 외부 실행·샘플/제출 API·Worker 패키징 | API 단위 검사·실제 코드 실행·오류/버전 계약·독립 리뷰·커밋/푸시 | 완료: `fe68aef` 원격 반영·CI PASS | | M4 | 프로토타입 통합·배포 | 반응형·키보드·진도·오래된 응답 무효화·합성 화면·CI 확인·출시 커밋/푸시·배포 성공 | 완료: `165e113` 원격 반영·CI PASS·private 배포 성공 | -| M4.1 | Java 문법 색상·3테마·편집 기능 | 구문·브라우저 회귀·선택 대비·새 체크아웃 빌드·독립 코드/시각 검수 | 로컬 PASS, 원격·배포 진행 | -| M5 | Windows 설치형·로컬 JDK8/11/17/21·Maven 실행 | 세션·도구 등록·프로세스 정리·앱 재시작 진도·실제 로컬 실행·설치 파일 | 구현·검증 중 | -| M6 | Spring/REST/Lombok/MyBatis/테스트 대표 수업 | 실제 의존성·다중 파일·DB·HTTP 코딩 실습 | M5 의존 | -| M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | M5/6 의존 | +| M4.1 | Java 문법 색상·3테마·편집 기능 | 구문·브라우저 회귀·선택 대비·새 체크아웃 빌드·독립 코드/시각 검수 | 완료: `a4d277e` 원격·CI PASS·private 배포 성공 | +| M5 | Windows 설치형·로컬 JDK8/11/17/21·Maven 실행 | 세션·도구 등록·프로세스 정리·앱 재시작 진도·실제 로컬 실행·설치 파일 | 구현·개발 앱9/9·로컬16/16 PASS, 설치·제거 및 최종 시각 gate 남음 | +| M6 | Spring/REST/Lombok/MyBatis/테스트 대표 수업 | 실제 의존성·다중 파일·DB·HTTP 코딩 실습 | 22실무수업 통합, 기본20프로젝트 기준54JUnit PASS·starter 실패 확인 | +| M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | 구현·독립 코드/출처 PASS, Redis/Kafka 실제 서비스 미검증·새 배포 남음 | M4는 전체 교육 과정 완료를 뜻하지 않는다. 2026-10-12 사용자는 **Windows 설치형 + 웹 교재**를 선택했다. 학습자 로컬 PC에서 Java/Spring/H2를 실행하며, 호스팅 컨테이너 서버는 기본 과정에 필요하지 않다. Redis/Kafka 실제 연동은 별도 선택 환경이다. 웹의 기존 코어 실행은 Wandbox 실제 JVM21을 사용한다. 교재 버전·컴파일 대상·실제 JVM을 구분한다. 마일스톤마다 관련 파일만 stage하고 강제 푸시하지 않는다. 배포는 검증한 커밋의 동일 소스를 사용한다. M4.1 검증: 브라우저 고유49개를 전체47개와 추가/수정 영향 검사로 나눠 확인, 토큰 단위4개 PASS, 전체 Node31 PASS/실제 제공자1 SKIP. 선택 상태 최저 대비4.859/4.810/5.772:1. 생성 번들이 없는 작업 복사본에서 npm ci→build→Worker 검사→Node 검사 PASS. npm audit은 scoped global-agent 보완 후0건이며 실제 설치 파일 빌드는 M5에서 검사한다. 상세 범위·미확인은 docs/editor-qa-report.md를 따른다. + +M5–M7 현재 source를 인계용으로 통합 보존한다. 설치 NSIS 빌드는 성공했으나 설치된 앱 승인과 마지막 독립 시각 검수는 에이전트 사용량 한도로 미완료다. 새 CI의 네 Windows JVM 및 두 실무 JVM 작업은 실제 결과를 별도로 확인해야 한다. 이 단계의 커밋·푸시를 최종 출시 성공으로 표현하지 않는다. 이어갈 조건은 [개발 인계](development-handoff.md)를 따른다. diff --git a/docs/product-plan.md b/docs/product-plan.md index 8fb5e31..c05f635 100644 --- a/docs/product-plan.md +++ b/docs/product-plan.md @@ -1,6 +1,6 @@ -# Java Edu 기획안 v0.2 +# Java Edu 기획안 v0.3 -기준일 2026-10-12 (Asia/Seoul). 사용자가 승인한 교육 방향과 첫 프로토타입 범위를 기록한다. +기준일 2026-10-12 (Asia/Seoul). 사용자가 승인한 교육 방향과 Windows 설치형 + 웹 교재 구성을 기록한다. ## 목표와 대상 @@ -40,4 +40,12 @@ Java 명세, 도구의 공식 계약, 교육자의 설계 지침을 구분한다 후속 **실무 MVP**는 Spring·REST·Lombok·MyBatis·테스트의 대표 수업을 공개한다. 해당 라이브러리·다중 파일·DB·HTTP 실행 환경 검증이 공개 조건이다. 그 뒤 JPA·보안·캐시·메시징·운영 단원을 확장한다. 필요한 배포 계정·자원은 명시하고 비용·권한을 임의로 확대하지 않는다. +## 선택한 제품 구성 + +Windows 현재 사용자용 설치 앱과 웹 교재가 동일한 교육 화면을 공유한다. 코어12개와 실무22개 단원을 연결하고, 코드에는 Java 구문 색상과 다크·라이트·고대비 테마를 적용한다. 실무 단원의 Java/XML/SQL/설정 파일은 탭으로 탐색하며 학습자가 편집할 파일과 고정된 빌드·테스트를 구분한다. + +설치형은 학습자가 등록한 로컬 JDK8/11/17/21와 Maven3.9+를 사용한다. 코드 실행은 PC에서 수행하고, 최초 환경 준비에서 고정된 Maven 의존성을 내려받은 뒤 과제는 오프라인으로 실행한다. 기본 DB는 H2이며 필수 Docker 서버를 요구하지 않는다. Redis/Kafka 실제 연동 단원에만 선택 자원을 안내한다. Gradle/Docker/CI 비교 자료와 실제 자동 실행 범위도 구분한다. + +웹은 같은 이론·예제·퀴즈·설계 메모·편집기 및 프로젝트 ZIP을 제공한다. 실무 프로젝트 실행 버튼은 설치형에서 사용한다. 웹의 기존 Java 코어 실행은 Wandbox를 유지한다. 설치형 진도·초안·테마는 앱 데이터에 보존하고, 웹 진도는 브라우저에 보존한다. 기기 사이 자동 동기화는 제공하지 않는다. + 구현자 외 독립 리뷰, 실제 기능 검사, 합성 화면 검수 후 마일스톤별 커밋·푸시한다. 완성된 프로토타입은 owner-private Sites에 배포한다. 공개 GitHub는 서비스 공개 권한과 구분한다. diff --git a/docs/professional-curriculum.md b/docs/professional-curriculum.md new file mode 100644 index 0000000..e5c2479 --- /dev/null +++ b/docs/professional-curriculum.md @@ -0,0 +1,129 @@ +# 실무 교육 과정: 주문·재고·취소 + +기준일: 2026-10-12. 이 문서는 기존 Java 코어 12단원 뒤에 연결하는 실무 22단원의 교육·소스 계약입니다. 전체 교육 지도의 단계는 [교육 로드맵](education-roadmap.md)을 따릅니다. 완전 초보자의 첫 프로그래밍 과정이나 모든 운영 환경을 보장하는 인증 과정으로 소개하지 않습니다. + +## 학습자와 선수 진단 + +다른 언어 경험자는 Java의 값 전달·타입·객체 불변식·동등성·제네릭·예외 소유권을 진단한 뒤 부족한 공통 코어를 학습합니다. 현직자는 같은 진단과 실행 과제로 공통 코어를 건너뛸 수 있습니다. 두 경로를 별도 교재로 복제하지 않습니다. + +진단은 용어 암기만으로 통과시키지 않습니다. 실패 후 상태가 보존되는 이유, equals/hashCode와 컬렉션의 관계, checked 예외의 소유권, 한 스레드의 관찰과 JMM의 차이, 프록시와 실제 객체의 호출 경로를 코드와 반례로 설명해야 합니다. 다형성·LSP·상속과 합성·책임 배분이 부족하면 코어 OOP로 돌아갑니다. + +이 실무 묶음의 컴파일 대상은 Java 17, 실행 요구는 Java 17 이상이고 기본 권장 JVM은 21입니다. Java 8·11·17·21의 언어·API 변화는 코어 수업에서 버전별로 학습합니다. 이 묶음에 Java 8/11용 Spring Boot 3 프로젝트가 있다고 표시하지 않습니다. Boot 2.7 유지보수와 8→11→17→21 마이그레이션은 별도 후속 단원으로 분리합니다. 날짜나 고정 학습 기간으로 숙련을 약속하지 않으며 lesson의 duration은 미실측 편집 추정치입니다. + +## 모든 단원의 다섯 단계 + +1. **이론**: 단원이 해결할 질문과 객체·트랜잭션·전송 경계를 읽습니다. +2. **예시**: 하나의 Java 파일뿐 아니라 POM·SQL·XML·설정까지 함께 읽고 기준 구현의 선택 이유를 추적합니다. +3. **문제풀이**: 정상 사례 암기보다 개념 오해와 실제로 혼동할 대안을 구분하는 객관식 2문항을 풉니다. +4. **실무 심화**: 부분 실패·중복 요청·동시성·권한·운영 비용의 반례를 설명하고 대안의 비용을 비교합니다. +5. **직접 코딩**: 설치형 앱에서 지정된 파일을 편집하고 실제 JDK/Maven 및 필요한 학습 자원으로 실행합니다. 웹은 교재·예제·퀴즈·해설·공개 정답 비교·프로젝트 다운로드를 제공합니다. + +코어의 클래스 골격형과 메서드 몸체형을 실무의 고정 멀티파일 프로젝트로 확장합니다. Java 과제는 클래스와 의존 관계를 읽으며 메서드 구현을 완성하고, MyBatis/Flyway는 SQL/XML 계약을 직접 수정합니다. POM·채점 테스트·일부 보조 파일은 실행 조건을 보존하기 위해 편집 대상에서 제외합니다. 공개 정답과 테스트는 학습용이며 부정행위를 막는 경쟁 채점 자료가 아닙니다. + +## 22개 단원 지도 + +아래 순서는 안내 순서이며 실제 진입은 prerequisites를 따릅니다. 특히 JDBC 이후 MyBatis와 JPA는 비교 가능한 독립 선택 경로입니다. 두 기술을 반드시 차례대로 모두 익혀야 다음 단계로 갈 수 있다고 강제하지 않습니다. 트랜잭션 공통 경계에서 다시 만나며 낙관적 잠금 단원은 JPA 경로에 속합니다. + +| 순서 | 단원 | 필수 선수 | 행동 기준 | +| --- | --- | --- | --- | +| 1 | [Maven으로 재현 가능한 주문 계산 빌드](../curriculum/professional/lessons/build-maven/lesson.json) | 코어:oop, 코어:testing-design | 정상 곱셈 / 0원 상품 / 잘못된 수량 거절 / 정수 오버플로 거절 | +| 2 | [JUnit·AssertJ로 경계와 상태 전이 검증](../curriculum/professional/lessons/test-contracts/lesson.json) | build-maven | 전량 예약 / 실패 후 상태 보존 / 생성 불변식 | +| 3 | [Mockito로 외부 협력의 실패를 통제](../curriculum/professional/lessons/test-collaborators/lesson.json) | test-contracts, 코어:polymorphism | 결제 한 번 호출 / 잘못된 입력에서 미호출 / 협력 오류 전파 | +| 4 | [Lombok 생성 코드와 동등성의 범위](../curriculum/professional/lessons/lombok-values/lesson.json) | test-contracts, 코어:collections | 같은 ID 동등성 / 가변 표시 값 제외 / HashSet 조회 유지 | +| 5 | [Spring IoC·DI와 객체 수명](../curriculum/professional/lessons/spring-di/lesson.json) | test-collaborators | 컨테이너 조립 / 정책 계약 / 기본 singleton 확인 | +| 6 | [프록시·AOP 호출 경로의 반례](../curriculum/professional/lessons/spring-aop/lesson.json) | spring-di | 외부 호출 advice / 자기 호출 우회 반례 | +| 7 | [MVC·REST의 상태 코드와 자원 계약](../curriculum/professional/lessons/mvc-rest/lesson.json) | spring-di | 기존 주문 조회 200 / 부재 주문 404 / 반복 조회 상태 보존 | +| 8 | [Jackson·Validation의 입력 경계](../curriculum/professional/lessons/json-validation/lesson.json) | mvc-rest | 정상 생성 201 / 0 수량·공백 sku 400 / 깨진 JSON·미지 필드 400 | +| 9 | [SQL·JDBC로 재고 변경과 롤백](../curriculum/professional/lessons/sql-jdbc/lesson.json) | test-contracts, 코어:exceptions | 성공 시 두 변경 커밋 / 재고 부족 상태 보존 / 중복 키 실패 재고 롤백 / 잘못된 입력 거절 | +| 10 | [MyBatis 파라미터 바인딩과 XML 매핑](../curriculum/professional/lessons/mybatis-mapping/lesson.json) | sql-jdbc | 명시적 열·속성 매핑 / 정확한 SKU 조회 / SQL 조각 입력을 값으로 취급 | +| 11 | [JPA·Hibernate의 상태와 변경 감지](../curriculum/professional/lessons/jpa-state/lesson.json) | sql-jdbc, lombok-values | 관리 엔티티 변경 감지 / 분리 객체 변경 비반영 / 실패 시 객체 수량 보존 | +| 12 | [Flyway로 스키마 변경을 기록](../curriculum/professional/lessons/flyway-migrations/lesson.json) | sql-jdbc | 두 버전 적용 / 재실행 시 0개 적용 / 중복 요청 키 거절 | +| 13 | [Spring 트랜잭션 프록시와 롤백 경계](../curriculum/professional/lessons/tx-proxy/lesson.json) | spring-aop, sql-jdbc | 외부 프록시 호출 롤백 / 자기 호출 반례 재현 / 호출 경로 설명 | +| 14 | [낙관적 잠금과 중복 취소의 의미](../curriculum/professional/lessons/optimistic-cancel/lesson.json) | jpa-state, tx-proxy | 취소 상태 전이 / 반복 취소 복원량 0 / 오래된 버전 커밋 거절 | +| 15 | [Spring Security의 인증·권한·CSRF](../curriculum/professional/lessons/security-csrf/lesson.json) | json-validation | 미인증 401 / 역할 부족 403 / CSRF 누락 403 / 허용된 요청 200 | +| 16 | [OAuth2·OIDC의 역할과 계정 식별 경계](../curriculum/professional/lessons/oidc-identity/lesson.json) | security-csrf | 미인증 401 / 신뢰한 issuer·subject 전달 / 비신뢰 issuer 403 / 이메일로 신원 병합하지 않음 | +| 17 | [JWT 서명·발급자·대상·만료 검증](../curriculum/professional/lessons/jwt-validation/lesson.json) | security-csrf | 정상 토큰 수용 / issuer·audience 불일치 거절 / 만료·미래 nbf 거절 / 다른 키 서명 거절 / 필수 claim 누락 거절 | +| 18 | [Redis TTL·무효화·키 소유권](../curriculum/professional/lessons/redis-cache/lesson.json) | tx-proxy | 문자열 수량 저장·조회 / 양수 TTL 설정 / 지정 키만 무효화 / 잘못된 캐시 입력 거절 | +| 19 | [Kafka 재전달과 DB 멱등 처리](../curriculum/professional/lessons/kafka-idempotency/lesson.json) | tx-proxy, flyway-migrations | 중복 이벤트 효과 한 번 / DB 실패 시 이벤트 기록 롤백 / 다른 payload 중복 ID 거절 / Kafka 전송 후 수동 오프셋 커밋 | +| 20 | [Micrometer 지표와 관측 실패 신호](../curriculum/professional/lessons/observability/lesson.json) | spring-di, test-collaborators | 성공·실패 계수 / 성공·실패 타이머 기록 / 예외 보존과 Observation 종료 / 주문 ID 태그 제외 | +| 21 | [Gradle·Docker·CI의 재현 조건](../curriculum/professional/lessons/build-gradle-ci/lesson.json) | build-maven, test-contracts | 동일 업무 합계 계약 / 잘못된 수량·오버플로 거절 / 두 빌드의 테스트 경로 설명 | +| 22 | [주문·예약·취소 통합 계약](../curriculum/professional/lessons/orders-capstone/lesson.json) | json-validation, tx-proxy, security-csrf, flyway-migrations | 입력·미인증 거절 / 동일 요청 멱등 처리 / payload 충돌과 재고 부족 롤백 / 다른 사용자 취소 거절 / 반복 취소 재고 한 번 복원 | + +tx-proxy는 mybatis-mapping 또는 jpa-state 경험을 권장하지만 실제 필수 선수는 Spring AOP와 JDBC입니다. orders-capstone은 JDBC·Spring 트랜잭션 경로로 구현되어 JPA 선택자에게 JPA 엔티티 구현을 강제하지 않습니다. 보안·캐시·메시징은 주문의 기능 경계와 실패 의미가 준비된 뒤 학습합니다. + +## 라이브러리를 배우는 깊이 + +- **JUnit·AssertJ·Mockito**: 상태와 반환값, 경계, 필수 협력 계약을 검증합니다. mock 호출 횟수만 통과했다고 업무 규칙이 완성됐다고 보지 않습니다. 테스트에서는 인터페이스 mock용 subclass mock maker를 명시합니다. +- **Lombok**: 줄 수 축소를 넘어 생성된 equals/hashCode·생성자·toString·setter의 실제 의미를 검토합니다. 가변 필드와 엔티티 연관관계에 모든 메서드를 기계적으로 생성하지 않습니다. +- **Spring IoC·DI·AOP**: 컨테이너별 수명, 공유 상태, 생성자 의존성, 외부 프록시 호출과 자기 호출을 구분합니다. SOLID를 클래스와 인터페이스 개수를 늘리는 규칙으로 적용하지 않습니다. +- **MVC·Jackson·Validation**: JSON 파싱·타입 변환·필드 검증·업무 불변식과 HTTP 상태의 책임을 구분합니다. 알 수 없는 필드 처리도 호환성 정책입니다. +- **SQL·JDBC·MyBatis·JPA·Flyway**: SQL 영향 행 수와 트랜잭션, 매핑, 영속 상태, 스키마 이력을 학습합니다. H2에서 통과한 결과를 PostgreSQL·MySQL·Oracle의 잠금·격리·DDL 호환성 검증으로 확대하지 않습니다. +- **Security·OAuth2·OIDC·JWT**: 인증과 URL 역할 권한, 자원 소유권, CSRF, issuer/subject 식별, 토큰 수용 정책을 분리합니다. 예제의 계정·키는 테스트 전용이며 실제 자격증명을 요구하지 않습니다. +- **Redis·Kafka**: 조회 캐시와 원본 DB, 만료와 일관성, 전달과 업무 효과를 구분합니다. Redis 분산 락이나 Kafka와 임의 DB의 자동 exactly-once를 구현했다고 표기하지 않습니다. +- **Micrometer·Gradle·Docker·CI**: 지표 태그 비용, 실패 신호, 검증한 커밋과 산출물의 연결을 학습합니다. 원격 exporter·운영 배포·이미지 보안·실제 Gradle 실행은 이 Maven 자동 검사에서 인증하지 않습니다. + +## 완료 조건과 설명 루브릭 + +컴파일 성공, 테스트 통과, 설계 숙련은 서로 다른 상태로 표시합니다. 실행 성공만으로 수업을 완료하지 않습니다. + +| 단계 | 확인할 증거 | 자동화 범위 | +| --- | --- | --- | +| 준비 | 컴파일 대상·실제 JVM·빌드 도구·의존성·자원 확인 | 설치형 환경 검사 | +| 실행 | 지정 파일이 컴파일되고 실제 프로세스가 종료됨 | 프로세스 결과 | +| 행동 | 정상·경계·실패 후 상태·권한 계약 검사를 통과 | 공개 ContractTest | +| 설명 | 실패 경계와 책임, 대안의 비용, 검증하지 못한 범위를 설명 | 학습자 기록 또는 별도 검토 | +| 통합 | 같은 업무 불변식이 HTTP·DB·보안 경계에서 유지됨 | capstone 검사와 설계 설명 | + +설명 루브릭은 각 항목을 미제시 / 일부 근거 / 반례와 대안까지 설명으로 구분합니다. (1) 객체의 책임·상태 불변식, (2) 실패 후 남는 상태와 트랜잭션 소유권, (3) 의존 관계와 테스트 가능성, (4) 구현 대안의 비용, (5) 실제 실행 증거의 범위가 대상입니다. 이 루브릭을 자동 객관식 점수로 환산해 설계 능력을 보증하지 않습니다. + +간단한 알고리즘 문제는 입력과 반환의 수학적 계약을 검사할 수 있지만 객체 설계는 협력·상태·실패·변경 비용까지 설명해야 합니다. capstone의 정답 구조를 그대로 복사한 경우도 대안 비교와 실패 설명이 남습니다. + +## 소스와 실행 프로필 계약 + +정식 원본은 [catalog.json](../curriculum/professional/catalog.json), [profiles.json](../curriculum/professional/profiles.json), 각 lesson.json입니다. catalog의 exerciseId는 수업 ID에 -project를 붙인 값이고 모든 파일 경로는 curriculum/professional 기준 상대경로입니다. + +```text +curriculum/professional/ + pom.xml 공통 고정 빌드와 BOM + profiles.json Java·의존성·자원 요구 + catalog.json 수업 순서·선수·허용 파일 + lessons// + lesson.json 교재·퀴즈·과제 메타데이터 + reference/ 공개 기준 구현과 읽기 전용 설정 + starter/ 학습자의 출발 파일 + grader/src/test/java/ 같은 계약을 검사하는 테스트 + grader/src/test/resources/ 필요한 고정 테스트 설정 +``` + +프로젝트 다운로드와 설치형 준비는 공통 parent pom.xml과 해당 단원의 reference 또는 starter 및 grader를 함께 보존해야 합니다. reference/pom.xml의 parent 경로는 ../../../pom.xml이고 테스트 경로는 ../grader/src/test/java입니다. Java 파일만 분리하면 이 빌드 계약이 유지되지 않습니다. + +lesson의 example.files는 여러 기준 파일을 보여 주며 professionalExercise.files는 path·templatePath·referencePath·editable을 제공합니다. testFiles는 path·sourcePath를 갖습니다. editableFiles에 없는 파일을 임의로 편집하는 기능은 이 계약에 없습니다. 출판용 웹 코드와 실제 설치형 파일 로더는 별도 구현 담당자가 통합합니다. + +프로필은 19종이며 compilerTarget=17, runtimeRequirement.minimumJava=17, recommendedJava=21을 분리합니다. Spring Boot BOM은 3.5.16으로 고정합니다. 실제 해석된 라이브러리 버전과 실제 JVM은 실행 증거에서 확인합니다. 공식 Boot 3.5 요구 사항은 Java 17 이상을 명시합니다. [Spring Boot 시스템 요구 사항](https://docs.spring.io/spring-boot/3.5/system-requirements.html) + +dependencyDownload=explicit-toolchain-setup-only는 설치형의 명시적 환경 준비 단계에만 의존성 다운로드가 필요하다는 계약입니다. reference 테스트를 실행해 필요한 의존성·테스트 공급자를 준비한 뒤 학습자 테스트는 offline Maven 경로를 사용합니다. offline Maven은 Java 프로그램의 네트워크·파일 접근을 OS 차원에서 차단하는 샌드박스가 아닙니다. profiles.execution.network는 required와 enforcement=not-os-sandboxed를 구분합니다. + +## 외부 자원과 검증 범위 + +| 단원/능력 | 준비된 범위 | 별도 검증 또는 미지원 상태 | +| --- | --- | --- | +| 일반 Java·JUnit·Spring/H2 | 고정 멀티파일 reference·starter·grader | 실제 JDK/Maven 실행 기록과 설치형 통합 승인 필요 | +| MVC·Security | MockMvc 요청·응답·보안 필터 검사 | 실제 소켓·TLS·역방향 프록시·운영 인증 저장소 | +| OIDC | 이미 인증된 테스트 principal의 issuer/subject 처리 | redirect·state·nonce·PKCE·code 교환·JWKS 등 전체 프로토콜 | +| JWT | 실행 중 생성한 테스트 RSA 키와 고정 Clock | 운영 키 보관·회전·폐기·원격 키 서버 | +| Redis | TTL·키별 무효화, 전용 loopback 인스턴스 계약 | Docker 설치·이미지 실행·공유 서비스 접근은 자동 보장하지 않음 | +| Kafka | 전용 토픽, 중복 payload 전송, DB 멱등 효과 | 실제 장애 복구·고부하·다중 파티션·운영 브로커 | +| Observability | 메모리 지표·Observation 생명주기 | exporter·대시보드·분산 추적 인프라 | +| Gradle·Docker·CI | 비교용 설정 파일과 설명 과제 | 해당 도구 실행·CI 도입·배포 검증 | + +Redis 환경변수는 JAVA_EDU_REDIS_HOST와 JAVA_EDU_REDIS_PORT이며 예시 포트는 127.0.0.1:16379입니다. Kafka는 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:19092 형식의 단일 학습 브로커를 전제로 합니다. 실제 설치형이 자원을 지원하지 않으면 unsupported_resources로 알려야 하며 단위 테스트만 수행한 결과를 단원 통합 성공으로 바꾸지 않습니다. 외부 주소를 입력하는 범용 실행 서비스를 제공하지 않습니다. + +## 편집·출판 정책과 현재 상태 + +Java 언어·표준 라이브러리는 Oracle/OpenJDK 문서, 프레임워크·라이브러리는 해당 프로젝트 공식 문서를 기준으로 합니다. 버전이 있는 Spring 6.2·Security 6.5·Redis 3.5 문서를 사용하고, 변경 가능한 문서는 edition과 확인일을 기록합니다. checkedAt은 출처를 확인한 날짜이며 모든 예제의 실행 완료 일자가 아닙니다. + +jobTags는 수업 주제를 연결하는 태그입니다. 태그가 있다는 이유로 해당 기술이 실제 공고에서 조사되었다거나 요구 빈도가 높다고 주장하지 않습니다. 실제 공고 근거·조사 날짜·표본 한계는 별도의 채용 조사 자료와 연결합니다. + +현재 22개 수업·44개 개념 문항·22개 reference/starter/grader를 소스로 준비했습니다. 작성 담당자는 학습 Java/Maven 코드를 호스트에서 실행하거나 외부 실행기에 전송하지 않았습니다. 통합 담당자는 추가 서버가 없는20개 기준 프로젝트를 JDK21·Java17 컴파일 대상·Maven3.10.0에서 실제 실행해 JUnit54개 PASS와 미완성 starter 실패를 확인했습니다. 원본과 실행 복사본의 해시를 대조해 sourceFingerprint 및 executionVerified를20개에만 등록했습니다. Redis/Kafka 두 단원은 실제 서비스 연동 미검증이며 false를 유지합니다. 설치형의 실제 UI·출시 검증은 별도 보고서를 따릅니다. + +출판 게이트는 (1) 출처와 계약 검토, (2) JSON/경로/선수 관계 정합성, (3) 준비된 reference가 실제 테스트를 통과, (4) starter가 행동 기준을 아직 만족하지 않음, (5) 관련 잘못된 구현을 검사가 거절, (6) 설치형 파일 편집·실행·오류·중단과 웹의 실제 화면 검토입니다. 소스만 제공한 단계에서는 고정 교재와 다운로드를 표시하고 실제 실행 결과를 꾸며 내지 않습니다. diff --git a/docs/verification.md b/docs/verification.md index 61a13d3..3751265 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,6 +1,6 @@ # 프로토타입 검증 기록 -기준일: 2026-10-12. Java 코어 첫 12수업을 대상으로 한다. 전체 교육 로드맵 수료 가능성이나 실제 학습 효과는 아직 검증하지 않았다. +기준일: 2026-10-12. 아래 코어 프로토타입 기록은 M4 당시12수업 대상의 이력이다. 현재34수업 설치형 검증 상태는 마지막 M5–M7 절과 개발 인계를 따른다. 전체 교육 로드맵 수료 가능성이나 실제 학습 효과는 아직 검증하지 않았다. ## 소스와 마일스톤 @@ -39,3 +39,11 @@ 최종 출시 CI는 Content integrity, Java examples (8/11/17/21), Browser QA 모두 성공했다. Chromium 브라우저 35개 시나리오를 한 번의 전체 실행에서 35 PASS/0 FAIL로 확인했다. 로컬 Edge QA 기록의 선택 재검사와 구분한다. 출시 판정: **PASS — 코어 프로토타입 범위**. private Sites 배포가 `succeeded`와 운영 URL을 반환했다. 모든 GitHub 출시 파일과 Sites source tree의 Git blob을 비교해 호스팅 manifest/ignore 설정 외 동일함을 확인했다. [배포 기록](deployment.md)에 소스·버전·배포 ID를 기록했다. 배포된 인증 세션에서의 Java 제출은 미검증이며 위 제한은 유지한다. + +## M5–M7 현재 구현·검증과 미완료 gate + +코어12+실무22, 총34수업의 Windows 앱·웹 공용 교재를 구현했다. 독립 기능 QA의 개발 Electron9/9, 모의API 웹4/4, 기존 브라우저49개 PASS. Node35 PASS/제공자1 SKIP, Worker 정적 모듈·ZIP 검사 PASS, 로컬 계약16/16 PASS. 실제 JDK8/21 core와 Spring/H2 UI 실행은 [설치형 QA 기록](desktop-qa-report.md)을 따른다. + +JDK21·컴파일 대상17·Maven3.10.0에서 기본20실무프로젝트를 실제 실행해 reference54개 JUnit PASS와20개 미완성 starter 실패를 확인했다. 독립 리뷰는129소스 및 부모POM 해시·Surefire XML·메타데이터를 대조했다. Redis/Kafka는 reference/starter 컴파일만 확인했으며 실제 서비스 실행은 미검증이다. 독립 코드·교재 리뷰에는 미해결 P1/P2가 없다. + +최종 Windows x64 NSIS 빌드 성공은 설치·제거 또는 설치된 바이너리 실행 성공과 다르다. 최종 시각 검수와 설치 패키지 QA는 담당 에이전트의 서비스 사용량 한도로 끝내지 못했다. 새로운 Windows8/11/17/21·실무17/21 CI는 정의를 추가했으며 원격 결과를 별도 확인한다. 최종 출시 판정은 **미완료**다. [개발 인계](development-handoff.md)에 설치 파일 해시와 재개 절차를 남겼다. diff --git a/package-lock.json b/package-lock.json index a3dd308..0c4701a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "java-edu", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "java-edu", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@codemirror/commands": "6.11.1", "@codemirror/lang-java": "6.0.2", diff --git a/package.json b/package.json index 7998ee2..60bd03c 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,19 @@ { "name": "java-edu", - "version": "0.1.0", + "version": "0.2.0", + "main": "desktop/main.cjs", "private": true, "type": "module", "description": "Java 공식 문서 기반 전환 학습과 버전별 실무 심화", "scripts": { - "dev": "node scripts/build-editor.mjs && node scripts/serve.mjs", + "dev": "node scripts/build-editor.mjs && node scripts/build-professional.mjs && node scripts/serve.mjs", "build:editor": "node scripts/build-editor.mjs", + "build:professional": "node scripts/build-professional.mjs", + "desktop": "node scripts/build-editor.mjs && node scripts/build-professional.mjs && electron .", + "build:desktop": "node scripts/build-editor.mjs && node scripts/build-professional.mjs && electron-builder --config desktop/build-config.cjs --win --x64", + "test:local": "node --test tests/local/*.test.mjs", + "test:professional": "node scripts/check-professional.mjs", + "pretest": "node scripts/build-professional.mjs", "test": "node --test tests/*.test.mjs", "build": "node scripts/build-worker.mjs", "test:worker": "node scripts/check-worker.mjs", diff --git a/scripts/build-professional.mjs b/scripts/build-professional.mjs new file mode 100644 index 0000000..a0efdc9 --- /dev/null +++ b/scripts/build-professional.mjs @@ -0,0 +1,46 @@ +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { zipSync, strToU8 } from 'fflate'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const course = path.join(root, 'curriculum/professional'); +const catalog = JSON.parse(await readFile(path.join(course, 'catalog.json'), 'utf8')); +const lessons = [], exercises = []; +const partial = process.argv.includes('--partial'); +const safePath = (base, relative) => { + if (!relative || relative.includes('\\') || relative.split('/').some((v) => !v || v === '.' || v === '..') || path.isAbsolute(relative)) throw new Error(`Unsafe course path: ${relative}`); + const resolved = path.resolve(base, relative); + if (!resolved.startsWith(path.resolve(base) + path.sep)) throw new Error(`Course path escape: ${relative}`); + return resolved; +}; +const collect = async (directory, prefix, files) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name === 'target' || entry.name === 'node_modules') continue; + if (entry.isSymbolicLink()) throw new Error('Course symlinks are not allowed'); + const file = path.join(directory, entry.name); + if (entry.isDirectory()) await collect(file, `${prefix}${entry.name}/`, files); + else files[`${prefix}${entry.name}`] = new Uint8Array(await readFile(file)); + } +}; +await mkdir(path.join(root, 'dist/downloads'), { recursive: true }); +for (const entry of catalog.lessons) { + let lesson; + try { lesson = JSON.parse(await readFile(safePath(course, entry.lessonPath), 'utf8')); } + catch (error) { if (partial && error.code === 'ENOENT') continue; throw error; } + if (lesson.id !== entry.id || !lesson.professionalExercise || !Array.isArray(lesson.quiz) || lesson.quiz.length < 2) throw new Error(`Incomplete lesson ${entry.id}`); + const contract = lesson.professionalExercise; + const files = []; + for (const file of contract.files) files.push({ path: file.path, source: await readFile(safePath(course, file.templatePath), 'utf8'), editable: file.editable === true }); + const zipFiles = { 'pom.xml': new Uint8Array(await readFile(path.join(course, 'pom.xml'))) }; + const lessonDirectory = path.join(course, 'lessons', entry.id); + for (const kind of ['reference', 'starter', 'grader']) await collect(path.join(lessonDirectory, kind), `lessons/${entry.id}/${kind}/`, zipFiles); + zipFiles['README.txt'] = strToU8(`${lesson.title}\n\nJava 17+ 및 Maven 3.9+가 필요합니다.\n실습: lessons/${entry.id}/starter 폴더에서 mvn test\n기준 예제: lessons/${entry.id}/reference 폴더에서 mvn test\n부모 pom.xml과 grader의 상대 위치를 유지하세요.\n처음 실행 시 Maven 의존성을 내려받습니다. 테스트 판정은 교육 피드백입니다.\nRedis/Kafka 실제 연동 수업은 해당 단원의 선택 환경을 먼저 준비하세요.\n공식 출처: ${lesson.sources.map((s) => s.url).join('\n')}\n`); + await writeFile(path.join(root, 'dist/downloads', `${entry.id}.zip`), zipSync(zipFiles, { level: 6, mtime: new Date('2000-01-01T00:00:00Z') })); + const executionVerified = lesson.executionVerified === true && contract.executionVerified === true; + lessons.push(lesson); + exercises.push({ id: contract.id, lessonId: lesson.id, title: lesson.title, mode: 'project', minVersion: lesson.minVersion, profileId: contract.profileId, instructions: contract.instructions, template: contract.template, files, rubric: contract.rubric, goals: contract.behaviorCriteria, sampleDescription: '고정된 기준 구현과 공개 테스트로 업무 계약을 확인합니다.', sampleOutput: executionVerified ? '기준 테스트 실행 검증 완료' : '실제 실행 검증 전', requiresDesktop: true, executionVerified, downloadUrl: `/downloads/${entry.id}.zip` }); +} +await mkdir(path.join(root, 'dist/data'), { recursive: true }); +await writeFile(path.join(root, 'dist/data/professional.js'), `// Generated from reviewed curriculum/professional. Reference material is intentionally public.\nexport const professionalLessons = ${JSON.stringify(lessons, null, 2)};\nexport const professionalExercises = ${JSON.stringify(exercises, null, 2)};\n`); +console.log(`Professional curriculum: ${lessons.length}/${catalog.lessons.length} lessons and ZIP projects`); diff --git a/scripts/build-worker.mjs b/scripts/build-worker.mjs index 51a8d68..b99c132 100644 --- a/scripts/build-worker.mjs +++ b/scripts/build-worker.mjs @@ -1,14 +1,19 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; import { build } from 'esbuild'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('../', import.meta.url)); await import('./build-editor.mjs'); +await import('./build-professional.mjs'); const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; -const files = ['index.html', 'styles.css', 'app.js', 'assets/editor.bundle.js', 'data/curriculum.js', 'data/exercises.js', 'data/job-evidence.js']; +const files = ['index.html', 'styles.css', 'app.js', 'assets/editor.bundle.js', 'data/curriculum.js', 'data/exercises.js', 'data/job-evidence.js', 'data/professional.js']; const assets = {}; for (const file of files) assets[`/${file}`] = { body: await readFile(path.join(root, 'dist', file), 'utf8'), type: types[path.extname(file)] }; +for (const file of await readdir(path.join(root, 'dist/downloads'))) { + if (!/^[a-z0-9-]+\.zip$/.test(file)) continue; + assets[`/downloads/${file}`] = { body: (await readFile(path.join(root, 'dist/downloads', file))).toString('base64'), type: 'application/zip', encoding: 'base64', download: file }; +} await mkdir(path.join(root, 'worker'), { recursive: true }); await writeFile(path.join(root, 'worker/assets.generated.mjs'), `export const assets = ${JSON.stringify(assets)};\n`); await mkdir(path.join(root, 'dist/server'), { recursive: true }); diff --git a/scripts/check-professional.mjs b/scripts/check-professional.mjs new file mode 100644 index 0000000..4a71b70 --- /dev/null +++ b/scripts/check-professional.mjs @@ -0,0 +1,64 @@ +import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const course = path.join(root, 'curriculum/professional'); +const catalog = JSON.parse(await readFile(path.join(course, 'catalog.json'), 'utf8')); +const compileOnly = process.argv.includes('--compile-only'); +const selected = process.argv.slice(2).filter((arg) => arg !== '--compile-only'); +const externalLessons = new Set(['redis-cache', 'kafka-idempotency']); +const entries = selected.length ? catalog.lessons.filter((l) => selected.includes(l.id)) : catalog.lessons.filter((l) => !externalLessons.has(l.id)); +if (!selected.length) for (const id of externalLessons) console.log(`SKIP ${id}: optional Redis/Kafka services are required`); +if (selected.some((id) => !entries.some((e) => e.id === id))) throw new Error('Unknown lesson ID'); +const workspace = path.resolve(process.env.PROFESSIONAL_CHECK_DIR || path.join(root, 'work/professional-check', String(Date.now()))); +const javaHome = process.env.JAVA_HOME; +const mavenHome = process.env.MAVEN_HOME; +if (!javaHome || !mavenHome) throw new Error('JAVA_HOME and MAVEN_HOME are required'); +const java = path.join(javaHome, 'bin', process.platform === 'win32' ? 'java.exe' : 'java'); +const launcherName = (await readdir(path.join(mavenHome, 'boot'))).find((n) => /^plexus-classworlds-.*\.jar$/.test(n)); +if (!launcherName) throw new Error('Maven classworlds launcher is missing'); +await mkdir(workspace, { recursive: true }); +await cp(path.join(course, 'pom.xml'), path.join(workspace, 'pom.xml')); +const results = []; +for (const lesson of entries) { + const dir = path.join(workspace, 'lessons', lesson.id); + await mkdir(dir, { recursive: true }); + for (const kind of ['reference', 'starter', 'grader']) { + await cp(path.join(course, 'lessons', lesson.id, kind), path.join(dir, kind), { recursive: true, filter: (p) => !p.split(path.sep).includes('target') }); + } + const args = ['-Xmx768m', '-Dfile.encoding=UTF-8', `-Dmaven.home=${mavenHome}`, `-Dmaven.multiModuleProjectDirectory=${workspace}`, `-Dclassworlds.conf=${path.join(mavenHome, 'bin/m2.conf')}`, '-classpath', path.join(mavenHome, 'boot', launcherName), 'org.codehaus.plexus.classworlds.launcher.Launcher', '-B', '-ntp', `-Dmaven.repo.local=${path.resolve(process.env.PROFESSIONAL_M2 || path.join(workspace, 'm2'))}`, compileOnly ? 'test-compile' : 'test']; + const run = async (kind) => { + const log = []; + const env = { ...process.env, JAVA_HOME: javaHome }; + for (const name of ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', '_JAVA_OPTIONS', 'CLASSPATH', 'MAVEN_OPTS', 'MAVEN_ARGS']) delete env[name]; + const child = spawn(java, args, { cwd: path.join(dir, kind), env, windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout.on('data', (b) => log.push(b)); + child.stderr.on('data', (b) => log.push(b)); + const code = await new Promise((resolve, reject) => { child.on('error', reject); child.on('close', resolve); }); + const output = Buffer.concat(log).toString('utf8'); + await writeFile(path.join(dir, `${kind}.log`), output); + let tests = 0, failures = 0, errors = 0; + const reportsDir = path.join(dir, kind, 'target/surefire-reports'); + try { + for (const name of await readdir(reportsDir)) { + if (!/^TEST-.*\.xml$/.test(name)) continue; + const report = await readFile(path.join(reportsDir, name), 'utf8'); + const attrs = report.match(/]*>/)?.[0] || ''; + tests += Number(attrs.match(/\btests="(\d+)"/)?.[1] || 0); + failures += Number(attrs.match(/\bfailures="(\d+)"/)?.[1] || 0); + errors += Number(attrs.match(/\berrors="(\d+)"/)?.[1] || 0); + } + } catch (error) { if (error.code !== 'ENOENT') throw error; } + return { code, tests, failures, errors, log: path.join(dir, `${kind}.log`) }; + }; + const reference = await run('reference'); + const starter = reference.code === 0 && (compileOnly || reference.tests > 0) ? await run('starter') : null; + const passed = compileOnly ? reference.code === 0 && starter?.code === 0 : reference.code === 0 && reference.tests > 0 && reference.failures === 0 && reference.errors === 0 && starter?.code !== 0 && (starter?.failures + starter?.errors) > 0; + results.push({ id: lesson.id, mode: compileOnly ? 'compile-only' : 'test', passed, reference, starter }); + console.log(`${passed ? 'PASS' : 'FAIL'} ${lesson.id}: ${compileOnly ? 'compile only; service execution not verified' : `reference ${reference.tests} tests, starter ${starter?.failures ?? '-'} failures/${starter?.errors ?? '-'} errors`}`); +} +await writeFile(path.join(workspace, 'results.json'), JSON.stringify({ checkedAt: new Date().toISOString(), javaHome, mavenHome, results }, null, 2)); +console.log(`Evidence: ${workspace}`); +if (results.some((r) => !r.passed)) process.exitCode = 1; diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs index 0d5609b..e3a6a44 100644 --- a/scripts/check-worker.mjs +++ b/scripts/check-worker.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { unzipSync } from 'fflate'; import worker from '../dist/server/index.js'; const origin = 'https://java-edu.example'; @@ -16,6 +17,15 @@ assert.match(editor.headers.get('content-type'), /javascript/); const editorSource = await editor.text(); assert.match(editorSource, /Copyright/); assert.ok(editorSource.length > 100000, 'self-hosted editor bundle must be included'); +const professional = await worker.fetch(new Request(origin + '/data/professional.js'), {}); +assert.equal(professional.status, 200); +assert.ok((await professional.text()).includes('professionalLessons')); +const projectZip = await worker.fetch(new Request(origin + '/downloads/build-maven.zip'), {}); +assert.equal(projectZip.status, 200); +assert.equal(projectZip.headers.get('content-type'), 'application/zip'); +assert.match(projectZip.headers.get('content-disposition'), /attachment/); +const archive = unzipSync(new Uint8Array(await projectZip.arrayBuffer())); +assert.ok(archive['pom.xml'] && archive['lessons/build-maven/starter/pom.xml']); for (const file of ['/server/exercises.mjs', '/examples/solutions.json', '/.openai/hosting.json', '/missing']) { assert.equal((await worker.fetch(new Request(origin + file), {})).status, 404); } diff --git a/scripts/serve.mjs b/scripts/serve.mjs index 3064774..8b80125 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('../dist/', import.meta.url)); const port = Number(process.env.PORT || 4173); -const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json; charset=utf-8' }; +const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json; charset=utf-8', '.zip': 'application/zip' }; const server = http.createServer(async (request, response) => { try { const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); diff --git a/server/local/engine.mjs b/server/local/engine.mjs new file mode 100644 index 0000000..5ae0324 --- /dev/null +++ b/server/local/engine.mjs @@ -0,0 +1,176 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { exerciseDefinitions } from '../exercises.mjs'; +import { buildRunner, parseCheckOutput } from '../executor.mjs'; +import { LocalError, requireRecord } from './errors.mjs'; +import { runProcess, processEnvironment } from './process.mjs'; +import { createJobWorkspace } from './workspace.mjs'; +import { JAVA_VERSIONS, EXECUTION_NOTICE, DEPENDENCY_NOTICE } from './settings.mjs'; +import { describeProject, loadProfessionalProjects, validateSubmittedFiles, materializeProject, mavenCommand, readTestReports, readTestDiagnostics } from './projects.mjs'; +import { prepareServiceEnvironment } from './resources.mjs'; + +const REQUEST_ID = /^[A-Za-z0-9_-]{1,80}$/; +const CORE_TIMEOUT_MS = 5_000; +const COMPILE_TIMEOUT_MS = 20_000; +const MAVEN_TIMEOUT_MS = 120_000; +const PREPARE_TIMEOUT_MS = 300_000; + +function validateRequest(input) { + requireRecord(input); + if (typeof input.requestId !== 'string' || !REQUEST_ID.test(input.requestId)) throw new LocalError('invalid_request_id', '유효한 실행 요청 ID가 필요합니다.'); + if (typeof input.exerciseId !== 'string' || input.exerciseId.length > 100 || !['run', 'submit'].includes(input.action)) throw new LocalError('invalid_request', '과제 ID와 실행 종류를 확인하세요.'); + if (input.runtimeVersion !== undefined && !JAVA_VERSIONS.includes(input.runtimeVersion)) throw new LocalError('invalid_runtime', 'Java 8·11·17·21 중 등록한 실행 JDK를 선택하세요.'); +} + +function processFailure(result) { + if (result.reason === 'cancelled' || result.reason === 'parent_exited') return { status: 'runtime_error', error: { code: 'request_cancelled', message: '실행을 취소하고 자식 프로세스를 종료했습니다.' } }; + if (result.reason === 'timeout') return { status: 'timeout', error: { code: 'execution_timeout', message: '실행 시간 제한을 넘어 자식 프로세스를 종료했습니다.' } }; + if (result.reason === 'output_limit') return { status: 'runtime_error', error: { code: 'output_limit', message: '출력 제한을 넘어 실행을 종료했습니다.' } }; + return null; +} + +export async function createLocalEngine({ dataRoot, projectRoot, settings, runner = runProcess, definitions = exerciseDefinitions, projects: injectedProjects, now = Date.now }) { + await mkdir(dataRoot, { recursive: true }); + const projects = injectedProjects || await loadProfessionalProjects(projectRoot); + const prepared = new Map(); + const seenRequests = new Map(); + let active = null; + let closing = false; + + function chooseVersion(minimum, requested) { + const configured = settings.snapshot().jdks; + const version = requested ?? JAVA_VERSIONS.findLast((entry) => entry >= minimum && configured[entry]); + if (!version || !configured[version] || version < minimum) throw new LocalError('jdk_required', `Java ${minimum} 이상의 등록된 JDK가 필요합니다.`, 409); + return version; + } + + function checkConsent() { + if (!settings.snapshot().executionConsent) throw new LocalError('execution_consent_required', EXECUTION_NOTICE, 409); + } + + async function exclusive(requestId, signal, operation) { + if (closing) throw new LocalError('app_closing', '앱이 종료 중입니다.', 503); + for (const [id, timestamp] of seenRequests) if (now() - timestamp > 120_000) seenRequests.delete(id); + if (seenRequests.has(requestId)) throw new LocalError('duplicate_request', '이미 처리한 요청입니다. 새 실행 버튼으로 다시 시도하세요.', 409); + if (active) throw new LocalError('execution_busy', '한 번에 하나의 실습을 실행할 수 있습니다. 현재 실행을 끝내거나 취소하세요.', 429); + if (signal?.aborted) throw new LocalError('request_cancelled', '실행을 취소했습니다.', 499); + const controller = new AbortController(); + const abort = () => controller.abort(); + signal?.addEventListener('abort', abort, { once: true }); + seenRequests.set(requestId, now()); + const state = { requestId, controller, done: null }; + active = state; + state.done = (async () => { + try { return await operation(controller.signal); } + finally { + signal?.removeEventListener('abort', abort); + active = null; + } + })(); + return state.done; + } + + async function core(input, definition, signal) { + const started = now(); + const version = chooseVersion(definition.minVersion, input.runtimeVersion); + const jdk = await settings.verifyJdk(version); + const checks = input.action === 'run' ? definition.sampleChecks : definition.checks; + const nonce = randomBytes(16).toString('hex'); + const workspace = await createJobWorkspace(dataRoot); + const base = { requestId: input.requestId, provider: 'Local JDK', runtimeVersion: jdk.version, targetVersion: definition.minVersion, stdout: '', stderr: '', compilerOutput: '', checks: checks.map((check) => ({ name: check.name, passed: false })), passed: 0, total: checks.length }; + try { + await workspace.write('Main.java', buildRunner(input.source, checks, nonce)); + const env = processEnvironment(jdk.home, workspace.directory); + const compileArgs = ['-J-Xmx256m', '-J-Dfile.encoding=UTF-8', '-J-Dstdout.encoding=UTF-8', '-J-Dstderr.encoding=UTF-8', '-J-Dsun.stdout.encoding=UTF-8', '-J-Dsun.stderr.encoding=UTF-8', '-encoding', 'UTF-8', ...(jdk.version === 8 ? ['-source', '8', '-target', '8'] : ['--release', String(definition.minVersion)]), '-d', '.', 'Main.java']; + const compiled = await runner(jdk.javac, compileArgs, { cwd: workspace.directory, signal, env, timeoutMs: COMPILE_TIMEOUT_MS, memoryMiB: 512 }); + base.compilerOutput = compiled.stdout + compiled.stderr; + const compileFailure = processFailure(compiled); + if (compileFailure || compiled.exitCode !== 0) return { ...base, status: 'compile_error', ...compileFailure, durationMs: now() - started }; + const executed = await runner(jdk.java, ['-Xms16m', '-Xmx128m', '-Dfile.encoding=UTF-8', '-Dstdout.encoding=UTF-8', '-Dstderr.encoding=UTF-8', '-Dsun.stdout.encoding=UTF-8', '-Dsun.stderr.encoding=UTF-8', '-cp', '.', 'Main'], { cwd: workspace.directory, signal, env, timeoutMs: CORE_TIMEOUT_MS, memoryMiB: 512 }); + const failure = processFailure(executed); + const parsed = parseCheckOutput(executed.stdout, checks, nonce); + const complete = executed.exitCode === 0 && parsed.complete && !failure; + const verdicts = complete ? parsed.checks : base.checks; + return { ...base, status: complete ? (verdicts.every((check) => check.passed) ? 'passed' : 'failed') : 'runtime_error', + ...failure, stdout: parsed.stdout, stderr: executed.stderr, checks: verdicts, passed: verdicts.filter((check) => check.passed).length, durationMs: now() - started }; + } finally { await workspace.dispose(); } + } + + async function professional(input, project, signal, preparation = false) { + const started = now(); + const serviceEnvironment = await prepareServiceEnvironment(project, signal); + const version = chooseVersion(project.minVersion, input.runtimeVersion); + const jdk = await settings.verifyJdk(version); + const maven = settings.snapshot().maven; + if (!maven) throw new LocalError('maven_required', 'Apache Maven 3.9 이상을 먼저 등록하세요.', 409); + const key = JSON.stringify([project.exerciseId, version, jdk.home, maven.home, maven.version]); + if (!preparation && !prepared.has(key)) throw new LocalError('dependencies_required', '먼저 이 실습의 환경 준비 버튼으로 라이브러리와 기준 테스트를 준비하세요.', 409); + const workspace = await createJobWorkspace(dataRoot); + try { + const moduleDirectory = await materializeProject(workspace, projectRoot, project, input.files, { reference: preparation }); + const command = await mavenCommand({ workspace, moduleDirectory, dataRoot, jdk, maven, offline: !preparation }); + const executed = await runner(command.executable, command.args, { cwd: moduleDirectory, signal, env: { ...processEnvironment(jdk.home, workspace.directory), ...serviceEnvironment }, timeoutMs: preparation ? PREPARE_TIMEOUT_MS : MAVEN_TIMEOUT_MS, memoryMiB: 1536, maxProcesses: 16 }); + const failure = processFailure(executed); + const checks = await readTestReports(moduleDirectory); + const diagnostics = executed.exitCode !== 0 ? await readTestDiagnostics(moduleDirectory) : ''; + const passed = checks.filter((check) => check.passed).length; + const success = !failure && executed.exitCode === 0 && checks.length > 0 && passed === checks.length; + if (preparation && success) prepared.set(key, { exerciseId: project.exerciseId, runtimeVersion: version, jdkPath: jdk.home, mavenPath: maven.home, mavenVersion: maven.version }); + const result = { requestId: input.requestId, provider: 'Local Maven', runtimeVersion: jdk.version, targetVersion: project.minVersion, + status: success ? 'passed' : checks.length ? 'failed' : /COMPILATION ERROR|compilation failure/i.test(executed.stdout + executed.stderr) ? 'compile_error' : 'runtime_error', + ...failure, stdout: executed.stdout, stderr: [executed.stderr, diagnostics].filter(Boolean).join('\n'), compilerOutput: '', checks, passed, total: checks.length, durationMs: now() - started, + ...(preparation ? { prepared: success, notice: DEPENDENCY_NOTICE } : {}) }; + if (!success && !failure && !checks.length) result.error = { code: preparation ? 'dependency_setup_failed' : 'maven_execution_failed', message: preparation ? '환경 준비를 완료하지 못했습니다. 네트워크·도구 버전과 Maven 로그를 확인하세요.' : '테스트 결과가 생성되지 않았습니다. 컴파일 로그를 확인하거나 환경 준비를 다시 실행하세요.' }; + return result; + } finally { await workspace.dispose(); } + } + + return { + runtime() { + const value = settings.snapshot(); + const versions = JAVA_VERSIONS.filter((version) => value.jdks[version]); + return { mode: 'local', provider: 'Local JDK', available: versions.length > 0 && value.executionConsent, + runtime: { javaVersion: versions.at(-1) || null, compiler: 'registered-local-javac' }, + supportedTargets: JAVA_VERSIONS.filter((target) => versions.some((version) => version >= target)), + runtimes: JAVA_VERSIONS.map((version) => ({ javaVersion: version, available: Boolean(value.jdks[version]), path: value.jdks[version]?.home || null })), + maven: { available: Boolean(value.maven), version: value.maven?.version || null, path: value.maven?.home || null }, + consent: { accepted: value.executionConsent, notice: EXECUTION_NOTICE }, notice: EXECUTION_NOTICE, + dependencyNotice: DEPENDENCY_NOTICE, reason: !versions.length ? 'jdk_required' : !value.executionConsent ? 'execution_consent_required' : null, + preparedProjects: [...prepared.values()].filter((entry) => value.jdks[entry.runtimeVersion]?.home === entry.jdkPath && value.maven?.home === entry.mavenPath && value.maven?.version === entry.mavenVersion), + limits: { concurrentJobs: 1, coreTimeoutMs: CORE_TIMEOUT_MS, projectTimeoutMs: MAVEN_TIMEOUT_MS, preparationTimeoutMs: PREPARE_TIMEOUT_MS, outputBytes: 128 * 1024 } }; + }, + async project(exerciseId) { + const project = projects.find((item) => item.exerciseId === exerciseId); + if (!project) throw new LocalError('exercise_not_found', '등록되지 않은 실무 실습입니다.', 404); + return describeProject(projectRoot, project); + }, + async execute(input, signal) { + validateRequest(input); + checkConsent(); + const definition = definitions.find((item) => item.id === input.exerciseId); + if (definition) { + if (typeof input.source !== 'string' || !input.source.trim() || input.source.includes('\0') || Buffer.byteLength(input.source) > 32 * 1024) throw new LocalError('invalid_source', '실습 코드는 UTF-8 기준 32 KiB 이하의 텍스트여야 합니다.'); + return exclusive(input.requestId, signal, (operationSignal) => core(input, definition, operationSignal)); + } + const project = projects.find((item) => item.exerciseId === input.exerciseId); + if (!project) throw new LocalError('exercise_not_found', '등록되지 않은 실습입니다.', 404); + validateSubmittedFiles(project, input.files); + return exclusive(input.requestId, signal, (operationSignal) => professional(input, project, operationSignal)); + }, + async prepare(input, signal) { + validateRequest({ ...input, action: 'run' }); + checkConsent(); + const project = projects.find((item) => item.exerciseId === input.exerciseId); + if (!project) throw new LocalError('exercise_not_found', '등록되지 않은 실무 실습입니다.', 404); + return exclusive(input.requestId, signal, (operationSignal) => professional(input, project, operationSignal, true)); + }, + cancel(requestId) { + if (typeof requestId !== 'string' || !REQUEST_ID.test(requestId)) throw new LocalError('invalid_request_id', '실행 요청 ID를 확인하세요.'); + if (active?.requestId === requestId) { active.controller.abort(); return { cancelled: true }; } + return { cancelled: false }; + }, + async cancelActive() { const current = active; current?.controller.abort(); await current?.done?.catch(() => {}); }, + async close() { closing = true; const current = active; current?.controller.abort(); await current?.done?.catch(() => {}); }, + }; +} diff --git a/server/local/errors.mjs b/server/local/errors.mjs new file mode 100644 index 0000000..1a151bc --- /dev/null +++ b/server/local/errors.mjs @@ -0,0 +1,13 @@ +export class LocalError extends Error { + constructor(code, message, httpStatus = 400) { + super(message); + this.name = 'LocalError'; + this.code = code; + this.httpStatus = httpStatus; + } +} + +export function requireRecord(value, message = '요청 객체를 확인하세요.') { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new LocalError('invalid_request', message); + return value; +} diff --git a/server/local/http.mjs b/server/local/http.mjs new file mode 100644 index 0000000..f2b3366 --- /dev/null +++ b/server/local/http.mjs @@ -0,0 +1,101 @@ +import http from 'node:http'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { readFile, realpath, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { LocalError } from './errors.mjs'; + +export const SESSION_COOKIE = 'java_edu_session'; +export const CSRF_HEADER = 'x-java-edu-csrf'; +const BODY_LIMIT = 384 * 1024; +const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff2': 'font/woff2' }; + +function sameSecret(value, expected) { + return typeof value === 'string' && Buffer.byteLength(value) === Buffer.byteLength(expected) && timingSafeEqual(Buffer.from(value), Buffer.from(expected)); +} + +function writeJson(response, status, value) { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Cross-Origin-Resource-Policy': 'same-origin' }); + response.end(JSON.stringify(value)); +} + +async function readJson(request) { + if (request.headers['content-type']?.split(';')[0].trim() !== 'application/json' || (request.headers['content-encoding'] && request.headers['content-encoding'] !== 'identity')) throw new LocalError('json_required', 'application/json 요청이 필요합니다.', 415); + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > BODY_LIMIT) throw new LocalError('request_too_large', '실행 요청 크기 제한을 넘었습니다.', 413); + chunks.push(chunk); + } + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } + catch { throw new LocalError('invalid_json', 'JSON 요청을 읽을 수 없습니다.'); } +} + +export async function startLocalServer({ distRoot, engine }) { + const root = await realpath(distRoot); + const sessionToken = randomBytes(32).toString('hex'); + const csrfToken = randomBytes(32).toString('hex'); + let origin; + let host; + const server = http.createServer(async (request, response) => { + try { + if (request.headers.host !== host || request.socket.remoteAddress !== '127.0.0.1') throw new LocalError('host_not_allowed', '설치형 앱에서만 접근할 수 있습니다.', 403); + const cookie = request.headers.cookie?.split(';').map((entry) => entry.trim()).find((entry) => entry.startsWith(`${SESSION_COOKIE}=`))?.slice(SESSION_COOKIE.length + 1); + if (!sameSecret(cookie, sessionToken)) throw new LocalError('authentication_required', '설치형 앱 세션이 필요합니다.', 401); + if (request.headers.origin && request.headers.origin !== origin) throw new LocalError('origin_not_allowed', '앱 내부 요청만 허용합니다.', 403); + if (request.headers['sec-fetch-site'] === 'cross-site') throw new LocalError('origin_not_allowed', '다른 사이트의 요청은 허용하지 않습니다.', 403); + const url = new URL(request.url, origin); + if (url.origin !== origin) throw new LocalError('origin_not_allowed', '앱 내부 경로만 허용합니다.', 403); + if (!['GET', 'HEAD', 'POST'].includes(request.method)) throw new LocalError('method_not_allowed', '지원하지 않는 요청 방식입니다.', 405); + if (request.method === 'POST' && (request.headers.origin !== origin || !sameSecret(request.headers[CSRF_HEADER], csrfToken))) throw new LocalError('csrf_required', '앱 내부 실행 요청인지 확인할 수 없습니다.', 403); + if (url.pathname.startsWith('/api/')) { + response.setHeader('Cache-Control', 'no-store'); + if (request.method === 'GET' && url.pathname === '/api/runtime') return writeJson(response, 200, engine.runtime()); + const project = /^\/api\/local\/projects\/([a-z0-9-]{1,100})$/.exec(url.pathname); + if (request.method === 'GET' && project) return writeJson(response, 200, await engine.project(project[1])); + if (request.method !== 'POST') throw new LocalError('not_found', 'API 경로를 찾을 수 없습니다.', 404); + const input = await readJson(request); + if (url.pathname === '/api/local/cancel') return writeJson(response, 200, engine.cancel(input.requestId)); + if (!['/api/execute', '/api/local/prepare'].includes(url.pathname)) throw new LocalError('not_found', 'API 경로를 찾을 수 없습니다.', 404); + const controller = new AbortController(); + const abort = () => { if (!response.writableEnded) controller.abort(); }; + response.once('close', abort); + try { + const result = url.pathname === '/api/execute' ? await engine.execute(input, controller.signal) : await engine.prepare(input, controller.signal); + if (!response.destroyed) writeJson(response, 200, result); + } finally { response.removeListener('close', abort); } + return; + } + if (!['GET', 'HEAD'].includes(request.method)) throw new LocalError('method_not_allowed', '정적 교재는 읽기만 가능합니다.', 405); + let relative; + try { relative = decodeURIComponent(url.pathname).slice(1) || 'index.html'; } + catch { throw new LocalError('invalid_path', '경로를 읽을 수 없습니다.'); } + if (relative.includes('\\') || relative.split('/').some((part) => part.startsWith('.')) || relative.startsWith('server/') || relative.startsWith('desktop/')) throw new LocalError('not_found', '파일을 찾을 수 없습니다.', 404); + const extension = path.extname(relative); + if (!MIME[extension]) throw new LocalError('not_found', '파일을 찾을 수 없습니다.', 404); + const filename = await realpath(path.join(root, relative)).catch(() => null); + if (!filename || !filename.startsWith(`${root}${path.sep}`) || !(await stat(filename)).isFile()) throw new LocalError('not_found', '파일을 찾을 수 없습니다.', 404); + const body = await readFile(filename); + response.writeHead(200, { + 'Content-Type': MIME[extension], 'Content-Length': body.length, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + 'Referrer-Policy': 'no-referrer', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Resource-Policy': 'same-origin', + }); + response.end(request.method === 'HEAD' ? undefined : body); + } catch (error) { + if (response.destroyed || response.headersSent) return; + const failure = error instanceof LocalError ? error : new LocalError('internal_error', '로컬 실행을 처리하지 못했습니다. 앱을 다시 시작하고 도구 설정을 확인하세요.', 500); + writeJson(response, failure.httpStatus, { error: { code: failure.code, message: failure.message } }); + } + }); + server.requestTimeout = 10_000; + server.headersTimeout = 5_000; + server.keepAliveTimeout = 2_000; + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); }); + }); + host = `127.0.0.1:${server.address().port}`; + origin = `http://${host}`; + return { origin, sessionToken, csrfToken, async close() { await engine.close(); server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); } }; +} diff --git a/server/local/process.mjs b/server/local/process.mjs new file mode 100644 index 0000000..a20fa78 --- /dev/null +++ b/server/local/process.mjs @@ -0,0 +1,106 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { readFile, writeFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { LocalError } from './errors.mjs'; + +const windowsRunner = fileURLToPath(new URL('../../desktop/job-runner.ps1', import.meta.url)).replace(/([\\/])app\.asar([\\/])/, '$1app.asar.unpacked$2'); +export const OUTPUT_LIMIT = 128 * 1024; + +/** Deliberately excludes inherited Java/Maven options, proxy credentials and app secrets. */ +export function processEnvironment(javaHome, temporaryDirectory) { + const env = {}; + for (const name of ['SystemRoot', 'WINDIR', 'COMSPEC', 'LANG', 'LC_ALL']) { + if (process.env[name]) env[name] = process.env[name]; + } + env.TEMP = temporaryDirectory; + env.TMP = temporaryDirectory; + env.HOME = temporaryDirectory; + env.USERPROFILE = temporaryDirectory; + if (process.platform === 'win32') { + env.SystemDrive = path.parse(process.env.SystemRoot || 'C:\\Windows').root.replace(/[\\/]+$/, ''); + env.PATHEXT = '.EXE;.COM'; // Surefire's fixed Windows launcher resolves java without an extension. + } + if (javaHome) env.JAVA_HOME = javaHome; + env.PATH = [javaHome && path.join(javaHome, 'bin'), process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32') : '/usr/bin:/bin'].filter(Boolean).join(path.delimiter); + return env; +} + +/** Each Windows command starts suspended inside a kernel Job Object, before Java can create children. */ +export async function runProcess(executable, args, { + cwd, signal, timeoutMs = 15_000, outputLimit = OUTPUT_LIMIT, memoryMiB = 384, + maxProcesses = 8, env = processEnvironment(undefined, cwd), spawnImpl = spawn, +} = {}) { + if (!path.isAbsolute(executable) || !path.isAbsolute(cwd) || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string' || arg.includes('\0'))) { + throw new LocalError('invalid_command', '실행 경로와 인수를 확인할 수 없습니다.', 500); + } + if (signal?.aborted) throw new LocalError('request_cancelled', '실행을 취소했습니다.', 499); + const invocationId = randomUUID(); + const specPath = path.join(cwd, `.command-${invocationId}.json`); + const resultPath = path.join(cwd, `.command-${invocationId}.result.json`); + let command = executable; + let commandArgs = args; + if (process.platform === 'win32') { + await writeFile(specPath, JSON.stringify({ executable, args, cwd, timeoutMs, memoryMiB, maxProcesses, parentPid: process.pid, resultPath }), { flag: 'wx' }); + command = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + commandArgs = ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', windowsRunner, '-SpecPath', specPath]; + } + try { + return await new Promise((resolve, reject) => { + let child; + let stdout = ''; + let stderr = ''; + let bytes = 0; + let stopped; + let settled = false; + const started = Date.now(); + const stop = (reason) => { + stopped ||= reason; + if (!child?.pid) return; + if (process.platform === 'win32') child.kill(); // Closing the helper closes its Job handle. + else { + try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); } + } + }; + const onAbort = () => stop('cancelled'); + const finish = async (exitCode, spawnError) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + if (process.platform !== 'win32' && child?.pid) { + try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already reaped */ } + } + let report; + if (process.platform === 'win32') { + try { report = JSON.parse(await readFile(resultPath, 'utf8')); } catch { /* Helper can be killed before reporting. */ } + } + if (spawnError) return reject(new LocalError('process_start_failed', '실행 파일을 시작할 수 없습니다. 도구 경로와 권한을 확인하세요.', 503)); + const reason = stopped || report?.reason || 'exited'; + resolve({ exitCode: report?.exitCode ?? exitCode ?? -1, reason, stdout, stderr, durationMs: Date.now() - started }); + }; + const timer = setTimeout(() => stop('timeout'), timeoutMs + (process.platform === 'win32' ? 15_000 : 0)); + signal?.addEventListener('abort', onAbort, { once: true }); + try { + child = spawnImpl(command, commandArgs, { cwd, env, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (error) { void finish(-1, error); return; } + for (const [stream, key] of [[child.stdout, 'stdout'], [child.stderr, 'stderr']]) { + stream.setEncoding('utf8'); + stream.on('data', (chunk) => { + bytes += Buffer.byteLength(chunk); + const available = Math.max(0, outputLimit - Buffer.byteLength(stdout) - Buffer.byteLength(stderr)); + const part = new TextDecoder().decode(Buffer.from(chunk).subarray(0, available), { stream: true }); + if (key === 'stdout') stdout += part; + else stderr += part; + if (bytes > outputLimit) stop('output_limit'); + }); + } + child.once('error', (error) => void finish(-1, error)); + child.once('close', (code) => void finish(code)); + if (signal?.aborted) stop('cancelled'); + }); + } finally { + if (process.platform === 'win32') await Promise.all([rm(specPath, { force: true }), rm(resultPath, { force: true })]); + } +} diff --git a/server/local/projects.mjs b/server/local/projects.mjs new file mode 100644 index 0000000..33d3b8a --- /dev/null +++ b/server/local/projects.mjs @@ -0,0 +1,131 @@ +import { readFile, readdir, stat, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { LocalError, requireRecord } from './errors.mjs'; +import { safeRelativePath, readTrustedFile } from './workspace.mjs'; +import { requiredServices } from './resources.mjs'; + +const xmlUnescape = (value) => value.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); +const xmlEscape = (value) => value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +export async function loadProfessionalProjects(projectRoot) { + try { + const catalog = JSON.parse(await readFile(path.join(projectRoot, 'catalog.json'), 'utf8')); + const profiles = JSON.parse(await readFile(path.join(projectRoot, 'profiles.json'), 'utf8')); + if (catalog.schemaVersion !== 1 || profiles.schemaVersion !== 1) throw new Error('Unsupported schema'); + return catalog.lessons.map((lesson) => { + const profile = profiles.profiles.find((entry) => entry.id === lesson.profileId); + if (!profile) throw new Error('Missing profile'); + return { ...lesson, exerciseId: lesson.exerciseId || `${lesson.id}-project`, minVersion: profile.compilerTarget, profile }; + }); + } catch (error) { + if (error.code === 'ENOENT') return []; + throw new LocalError('project_configuration', '실무 교재 카탈로그를 읽을 수 없습니다.', 500); + } +} + +export function validateSubmittedFiles(project, supplied) { + requireRecord(supplied, '실습 파일 목록을 확인하세요.'); + const allowed = new Set(project.editableFiles || []); + if (!allowed.size || !Array.isArray(project.files) || !Array.isArray(project.testFiles)) throw new LocalError('project_not_ready', '이 실습의 로컬 실행 파일을 아직 준비 중입니다.', 409); + let total = 0; + for (const [file, source] of Object.entries(supplied)) { + safeRelativePath(file); + if (!allowed.has(file)) throw new LocalError('file_not_editable', '이 실습에서 편집할 수 없는 파일입니다.'); + if (typeof source !== 'string' || source.includes('\0') || Buffer.byteLength(source) > 64 * 1024) throw new LocalError('source_too_large', '각 파일은 UTF-8 64 KiB 이하의 텍스트여야 합니다.', 413); + total += Buffer.byteLength(source); + } + if (Object.keys(supplied).length !== allowed.size || [...allowed].some((file) => !Object.hasOwn(supplied, file))) throw new LocalError('missing_file', '편집 가능한 모든 실습 파일을 제출하세요.'); + if (total > 256 * 1024) throw new LocalError('source_too_large', '실습 파일 합계는 UTF-8 256 KiB 이하여야 합니다.', 413); + return supplied; +} + +export async function describeProject(projectRoot, project) { + if (!Array.isArray(project.files) || !project.files.length) throw new LocalError('project_not_ready', '이 실습의 프로젝트 파일을 준비 중입니다.', 409); + return { + exerciseId: project.exerciseId, lessonId: project.id, title: project.title, profileId: project.profileId, + runtimeRequirements: { minimumJava: project.minVersion, recommendedJava: 21, maven: '3.9+', dependencySetupRequired: true, services: requiredServices(project).map(({ type, host, port, label }) => ({ type, host, port, label })) }, + files: await Promise.all(project.files.map(async (file) => ({ path: safeRelativePath(file.path), source: await readTrustedFile(projectRoot, file.templatePath), editable: file.editable === true }))), + notice: '실행과 제출 모두 교재에 고정된 테스트를 실행합니다. 외부 Redis·Kafka·OAuth 서비스와 실제 통신하는 운영 실습은 별도 환경이 필요합니다.', + }; +} + +export async function materializeProject(workspace, projectRoot, project, supplied, { reference = false } = {}) { + if (!Array.isArray(project.files) || !Array.isArray(project.testFiles)) throw new LocalError('project_not_ready', '실무 프로젝트 파일을 준비 중입니다.', 409); + const base = `professional/lessons/${safeRelativePath(project.id)}`; + const moduleName = reference ? 'reference' : 'starter'; + const module = `${base}/${moduleName}`; + await workspace.write('professional/pom.xml', await readTrustedFile(projectRoot, 'pom.xml')); + let hasPom = false; + for (const file of project.files) { + safeRelativePath(file.path); + if (file.path === 'pom.xml') hasPom = true; + const source = !reference && file.editable ? supplied[file.path] : await readTrustedFile(projectRoot, reference ? file.referencePath : file.templatePath); + await workspace.write(`${module}/${file.path}`, source); + } + if (!hasPom) await workspace.write(`${module}/pom.xml`, await readTrustedFile(projectRoot, `${reference ? project.referenceDir : project.starterDir}/pom.xml`)); + for (const file of project.testFiles) { + const relative = safeRelativePath(file.path); + // Graders are siblings of starter/reference, matching the authored parent POM. + await workspace.write(`${base}/grader/${relative}`, await readTrustedFile(projectRoot, file.sourcePath)); + } + return path.join(workspace.directory, module); +} + +/** Invoke Maven's Java entry point directly: no cmd.exe, learner commands, wrappers or user settings. */ +export async function mavenCommand({ workspace, moduleDirectory, dataRoot, jdk, maven, offline = true }) { + const repository = path.join(dataRoot, 'maven-repository'); + await mkdir(repository, { recursive: true }); + const settings = `${xmlEscape(repository)}falsejava-edu-central*https://repo.maven.apache.org/maven2`; + const settingsPath = await workspace.write('maven-settings.xml', settings); + return { + executable: jdk.java, + args: [ + '-Xmx512m', '-Dfile.encoding=UTF-8', '-Dstdout.encoding=UTF-8', '-Dstderr.encoding=UTF-8', '-Dsun.stdout.encoding=UTF-8', '-Dsun.stderr.encoding=UTF-8', ...(process.platform === 'win32' ? ['-Djavax.net.ssl.trustStoreType=Windows-ROOT', '-Djavax.net.ssl.trustStore=NONE'] : []), `-Duser.home=${workspace.directory}`, `-Dmaven.home=${maven.home}`, + `-Dclassworlds.conf=${maven.configuration}`, `-Dmaven.multiModuleProjectDirectory=${moduleDirectory}`, + '-classpath', maven.launcher, 'org.codehaus.plexus.classworlds.launcher.Launcher', + '--batch-mode', '--no-transfer-progress', ...(offline ? ['--offline'] : []), + '--settings', settingsPath, '--global-settings', settingsPath, '--file', path.join(moduleDirectory, 'pom.xml'), + // A fresh Maven JVM already exists per job. Avoid Surefire's internal cmd.exe launcher, + // which expands literal %VAR% segments in valid Windows profile paths. + '-Dstyle.color=never', '-DforkCount=0', 'test', + ], + }; +} + +/** Minimal, bounded parsing of Surefire's own report format; XML entities and external resources are never evaluated. */ +export async function readTestReports(moduleDirectory) { + const directory = path.join(moduleDirectory, 'target', 'surefire-reports'); + const names = (await readdir(directory).catch(() => [])).filter((name) => /^TEST-[A-Za-z0-9_.$-]+\.xml$/.test(name)).sort(); + if (names.length > 64) throw new LocalError('report_limit', '테스트 결과 파일이 너무 많습니다.', 422); + const checks = []; + let bytes = 0; + for (const name of names) { + const file = path.join(directory, name); + const info = await stat(file); + bytes += info.size; + if (!info.isFile() || info.size > 512 * 1024 || bytes > 2 * 1024 * 1024) throw new LocalError('report_limit', '테스트 결과가 크기 제한을 넘었습니다.', 422); + const text = await readFile(file, 'utf8'); + if (/]*?)(?:\/\s*>|>([\s\S]*?)<\/testcase>)/g)) { + const nameMatch = /\bname="([^"]*)"/.exec(match[1]); + const classMatch = /\bclassname="([^"]*)"/.exec(match[1]); + if (!nameMatch) continue; + const body = match[2] || ''; + const skipped = / [])).filter((name) => /^[A-Za-z0-9_.-]+\.(?:dump|dumpstream)$/.test(name)).slice(0, 8); + const output = []; + for (const name of names) { + const file = path.join(directory, name); + const info = await stat(file); + if (info.isFile() && info.size <= 16 * 1024) output.push(`${name}\n${await readFile(file, 'utf8')}`); + } + return output.join('\n').slice(0, 16 * 1024); +} diff --git a/server/local/resources.mjs b/server/local/resources.mjs new file mode 100644 index 0000000..99c9281 --- /dev/null +++ b/server/local/resources.mjs @@ -0,0 +1,36 @@ +import net from 'node:net'; +import { LocalError } from './errors.mjs'; + +export const OPTIONAL_SERVICES = Object.freeze({ + redis: { host: '127.0.0.1', port: 16379, label: '학습 전용 Redis', environment: { JAVA_EDU_REDIS_HOST: '127.0.0.1', JAVA_EDU_REDIS_PORT: '16379' } }, + kafka: { host: '127.0.0.1', port: 19092, label: '학습 전용 Kafka', environment: { JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS: '127.0.0.1:19092' } }, +}); + +export function requiredServices(project) { + return (project.profile?.resources || []).filter((resource) => Object.hasOwn(OPTIONAL_SERVICES, resource.type)).map((resource) => ({ type: resource.type, ...OPTIONAL_SERVICES[resource.type] })); +} + +export function canConnect({ host, port }, signal, connect = net.createConnection) { + return new Promise((resolve) => { + if (signal?.aborted) return resolve(false); + let socket; + let complete = false; + const done = (success) => { if (complete) return; complete = true; signal?.removeEventListener('abort', cancel); socket?.destroy(); resolve(success); }; + const cancel = () => done(false); + try { socket = connect({ host, port }); } catch { return done(false); } + socket.once('connect', () => done(true)); + socket.once('error', () => done(false)); + socket.setTimeout(750, () => done(false)); + signal?.addEventListener('abort', cancel, { once: true }); + if (signal?.aborted) done(false); + }); +} + +export async function prepareServiceEnvironment(project, signal, probe = canConnect) { + const environment = {}; + for (const resource of requiredServices(project)) { + if (!await probe(resource, signal)) throw new LocalError('unsupported_resources', `${resource.label}가 ${resource.host}:${resource.port}에서 준비되지 않았습니다. 이 선택 실습은 전용 로컬 서비스를 직접 준비한 뒤 실행하세요. 다른 실무 수업은 계속 학습할 수 있습니다.`, 409); + Object.assign(environment, resource.environment); + } + return environment; +} diff --git a/server/local/settings.mjs b/server/local/settings.mjs new file mode 100644 index 0000000..b383675 --- /dev/null +++ b/server/local/settings.mjs @@ -0,0 +1,116 @@ +import { mkdir, readFile, writeFile, rename, stat, realpath, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { LocalError } from './errors.mjs'; +import { runProcess, processEnvironment } from './process.mjs'; + +export const JAVA_VERSIONS = Object.freeze([8, 11, 17, 21]); +export const EXECUTION_NOTICE = '코드는 이 PC의 사용자 권한으로 실행되며 파일과 네트워크에 접근할 수 있습니다. 보안 샌드박스가 아닙니다. 직접 작성했거나 신뢰하는 수업 코드만 실행하세요. 실행 버튼을 누르기 전에는 학습 코드를 실행하지 않습니다.'; +export const DEPENDENCY_NOTICE = '실무 환경 준비는 Maven Central에서 고정된 라이브러리를 내려받고 교재의 기준 답안 테스트를 실행합니다. 사용자 코드는 전송하지 않습니다. 준비 뒤 학습자 코드의 Maven 빌드는 오프라인 모드로 실행합니다.'; +const executableName = (name) => process.platform === 'win32' ? `${name}.exe` : name; + +export function parseJavaVersion(text) { + const match = text.match(/(?:java|openjdk|javac)(?:\s+version)?\s+"?(1\.)?(\d+)(?:[._+"\s-]|$)/i); + return match ? Number(match[2]) : null; +} + +async function ordinaryFile(file) { + const info = await stat(file).catch(() => null); + if (!info?.isFile()) throw new LocalError('tool_not_found', '선택한 폴더에 필요한 실행 파일이 없습니다.'); + return realpath(file); +} + +export async function inspectJdk(home, expectedVersion, { cwd, runner = runProcess } = {}) { + if (typeof home !== 'string' || !path.isAbsolute(home) || !JAVA_VERSIONS.includes(expectedVersion)) throw new LocalError('invalid_jdk', 'Java 8·11·17·21 중 설치된 JDK 폴더를 선택하세요.'); + const javaHome = await realpath(home).catch(() => null); + if (!javaHome) throw new LocalError('tool_not_found', 'JDK 폴더를 찾을 수 없습니다.'); + const java = await ordinaryFile(path.join(javaHome, 'bin', executableName('java'))); + const javac = await ordinaryFile(path.join(javaHome, 'bin', executableName('javac'))); + // Resolving both binaries prevents a selected folder from redirecting only the compiler elsewhere. + if (path.dirname(java) !== path.dirname(javac)) throw new LocalError('jdk_mismatch', 'java와 javac는 같은 JDK의 bin 폴더에 있어야 합니다.'); + const options = { cwd, timeoutMs: 10_000, env: processEnvironment(javaHome, cwd) }; + const [runtime, compiler] = await Promise.all([runner(java, ['-Xms16m', '-Xmx96m', '-version'], options), runner(javac, ['-J-Xmx128m', '-version'], options)]); + const runtimeVersion = parseJavaVersion(runtime.stdout + runtime.stderr); + const compilerVersion = parseJavaVersion(compiler.stdout + compiler.stderr); + if (runtime.reason !== 'exited' || compiler.reason !== 'exited' || runtime.exitCode !== 0 || compiler.exitCode !== 0 + || runtimeVersion !== expectedVersion || compilerVersion !== expectedVersion) { + throw new LocalError('jdk_mismatch', `선택한 폴더의 java와 javac가 모두 Java ${expectedVersion}인지 확인하세요. ${[runtime.stdout, runtime.stderr, compiler.stdout, compiler.stderr].join('\n').trim().slice(0, 1200)}`); + } + return { home: javaHome, java, javac, version: expectedVersion, verifiedAt: new Date().toISOString(), description: (runtime.stdout + runtime.stderr).trim().slice(0, 500) }; +} + +export async function inspectMaven(home) { + if (typeof home !== 'string' || !path.isAbsolute(home)) throw new LocalError('invalid_maven', 'Maven 배포본의 폴더를 선택하세요.'); + const mavenHome = await realpath(home).catch(() => null); + if (!mavenHome) throw new LocalError('tool_not_found', 'Maven 폴더를 찾을 수 없습니다.'); + const names = await readdir(path.join(mavenHome, 'boot')).catch(() => []); + const classworlds = names.filter((name) => /^plexus-classworlds-[A-Za-z0-9.-]+\.jar$/.test(name)); + if (classworlds.length !== 1) throw new LocalError('invalid_maven', 'Apache Maven 3 배포본의 boot 폴더를 확인하세요.'); + const launcher = await ordinaryFile(path.join(mavenHome, 'boot', classworlds[0])); + const configuration = await ordinaryFile(path.join(mavenHome, 'bin', 'm2.conf')); + const lib = await readdir(path.join(mavenHome, 'lib')).catch(() => []); + const version = lib.map((name) => /^maven-core-(3\.\d+\.\d+)\.jar$/.exec(name)?.[1]).find(Boolean); + if (!version || Number(version.split('.')[1]) < 9) throw new LocalError('maven_version', 'Apache Maven 3.9 이상의 Maven 3 배포본을 선택하세요.'); + return { home: mavenHome, launcher, configuration, version, verifiedAt: new Date().toISOString() }; +} + +export function createSettingsStore(dataRoot, { runner = runProcess } = {}) { + if (!path.isAbsolute(dataRoot)) throw new Error('An absolute app-owned data root is required.'); + const settingsFile = path.join(dataRoot, 'settings.json'); + const inspectionRoot = path.join(dataRoot, 'tool-checks'); + let settings = { schema: 1, jdks: {}, maven: null, executionConsent: false }; + let writeQueue = Promise.resolve(); + async function save() { + const snapshot = JSON.stringify(settings, null, 2); + writeQueue = writeQueue.catch(() => {}).then(async () => { + const temporary = `${settingsFile}.tmp`; + await writeFile(temporary, snapshot, { mode: 0o600 }); + await rename(temporary, settingsFile); + }); + await writeQueue; + } + return { + async initialize() { + await mkdir(inspectionRoot, { recursive: true }); + try { + const stored = JSON.parse(await readFile(settingsFile, 'utf8')); + if (stored.schema === 1 && stored.jdks && typeof stored.jdks === 'object') { + settings.executionConsent = stored.executionConsent === true; + for (const version of JAVA_VERSIONS) { + const entry = stored.jdks[version]; + // Startup reads only metadata. Executables are probed after the user's selection or execution action. + if (entry && entry.version === version && typeof entry.home === 'string' && path.isAbsolute(entry.home)) { + settings.jdks[version] = { home: entry.home, version, java: path.join(entry.home, 'bin', executableName('java')), javac: path.join(entry.home, 'bin', executableName('javac')), verifiedAt: entry.verifiedAt, description: entry.description }; + } + } + if (stored.maven?.home && path.isAbsolute(stored.maven.home)) settings.maven = await inspectMaven(stored.maven.home).catch(() => null); + } + } catch { /* An absent or malformed settings file starts with no configured tools. */ } + return this.snapshot(); + }, + snapshot() { return structuredClone(settings); }, + async chooseJdk(home, version) { + const result = await inspectJdk(home, version, { cwd: inspectionRoot, runner }); + settings.jdks[version] = result; + await save(); + return this.snapshot(); + }, + async chooseMaven(home) { + settings.maven = await inspectMaven(home); + await save(); + return this.snapshot(); + }, + async setConsent(accepted) { + if (typeof accepted !== 'boolean') throw new LocalError('invalid_consent', '실행 동의 값을 확인하세요.'); + settings.executionConsent = accepted; + await save(); + return this.snapshot(); + }, + async verifyJdk(version) { + const entry = settings.jdks[version]; + if (!entry) throw new LocalError('jdk_required', `실행할 Java ${version} JDK를 먼저 등록하세요.`, 409); + const verified = await inspectJdk(entry.home, version, { cwd: inspectionRoot, runner }); + settings.jdks[version] = verified; + return verified; + }, + }; +} diff --git a/server/local/workspace.mjs b/server/local/workspace.mjs new file mode 100644 index 0000000..5c981bf --- /dev/null +++ b/server/local/workspace.mjs @@ -0,0 +1,49 @@ +import { mkdir, mkdtemp, rm, realpath, lstat, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { LocalError } from './errors.mjs'; + +export function safeRelativePath(value) { + if (typeof value !== 'string' || value.length > 240 || !/^[A-Za-z0-9_.\-/]+$/.test(value) + || value.startsWith('/') || value.includes('\\') || value.split('/').some((part) => !part || part === '.' || part === '..') + || value.split('/').some((part) => /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) + || path.isAbsolute(value)) throw new LocalError('invalid_file_path', '교재에 등록된 상대 경로만 사용할 수 있습니다.'); + return value; +} + +export async function readTrustedFile(root, relative) { + safeRelativePath(relative); + const base = await realpath(root); + const target = await realpath(path.join(base, relative)).catch(() => null); + if (!target || !target.startsWith(`${base}${path.sep}`)) throw new LocalError('project_configuration', '교재 파일 경로를 확인할 수 없습니다.', 500); + const info = await lstat(target); + if (!info.isFile() || info.size > 256 * 1024) throw new LocalError('project_configuration', '교재 파일 형식이나 크기를 확인할 수 없습니다.', 500); + return readFile(target, 'utf8'); +} + +export async function createJobWorkspace(dataRoot) { + const jobsRoot = path.join(dataRoot, 'jobs'); + await mkdir(jobsRoot, { recursive: true }); + const verifiedRoot = await realpath(jobsRoot); + const directory = await mkdtemp(path.join(verifiedRoot, 'job-')); + const files = new Set(); + return { + directory, + async write(relative, content) { + safeRelativePath(relative); + if (files.has(relative)) throw new LocalError('duplicate_file', '실습 파일 경로가 중복되었습니다.', 500); + files.add(relative); + const target = path.resolve(directory, relative); + if (!target.startsWith(`${directory}${path.sep}`)) throw new Error('Workspace escape.'); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content, { flag: 'wx', mode: 0o600 }); + return target; + }, + async dispose() { + // Only this invocation's verified, directly owned job-* directory is eligible for recursive cleanup. + const current = await realpath(directory).catch(() => null); + if (current === directory && path.dirname(current) === verifiedRoot && path.basename(current).startsWith('job-')) { + await rm(current, { recursive: true, force: true, maxRetries: 3 }); + } + }, + }; +} diff --git a/tests/browser.mjs b/tests/browser.mjs index 5dc35d0..7214f9b 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -288,8 +288,11 @@ async function run(browser, name, test, options = {}) { async function main() { await waitForServer(); await mkdir(artifactRoot, { recursive: true }); - const { lessons } = await import('../dist/data/curriculum.js'); - const { exercises } = await import('../dist/data/exercises.js'); + const { lessons: coreLessons } = await import('../dist/data/curriculum.js'); + const { exercises: coreExercises } = await import('../dist/data/exercises.js'); + const { professionalLessons, professionalExercises } = await import('../dist/data/professional.js'); + const lessons = [...coreLessons, ...professionalLessons]; + const exercises = [...coreExercises, ...professionalExercises]; assert.ok(lessons.length >= 2, 'real lessons are present'); const first = lessons.find(lesson => lesson.minVersion === 8); const other = lessons.find(lesson => lesson.id !== first.id && lesson.minVersion === 8); @@ -337,7 +340,8 @@ async function main() { } const exercise = exercises.find(item => item.lessonId === lesson.id); assert.ok(exercise, `${lesson.id} has a coding task`); - assert.equal(await readEditorSource(page), exercise.template); + const initialFile = exercise.files?.find(file => file.editable) || exercise.files?.[0]; + assert.equal(await readEditorSource(page), initialFile ? initialFile.source : exercise.template); } assert.equal(api.requests.length, 0); }); @@ -1204,7 +1208,7 @@ async function main() { assert.equal(await page.locator('#editor-theme-select').inputValue(), 'dark'); await page.evaluate(() => { Storage.prototype.setItem = function () { throw new DOMException('QA blocked preferences', 'SecurityError'); }; }); await chooseTheme(page, 'light'); - assert.match(await page.locator('#storage-label').textContent(), /이번 방문/); + assert.match(await page.locator('#storage-label').textContent(), /이번 방문|현재 화면/); const source = `${firstExercise.template}\n// memory-only preferences`; await editorInput(page).fill(source); await stepTo(page, 'example'); diff --git a/tests/desktop.mjs b/tests/desktop.mjs new file mode 100644 index 0000000..4471a34 --- /dev/null +++ b/tests/desktop.mjs @@ -0,0 +1,562 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, readdir, access, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { _electron as electron, chromium } from 'playwright'; +import { unzipSync, strFromU8 } from 'fflate'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { exercises as coreExercises } from '../dist/data/exercises.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; + +// Web APIs are mocked. Electron uses its actual authenticated local server and +// registered JDKs. No test sends learner source to a remote execution provider. +const projectRoot = fileURLToPath(new URL('../', import.meta.url)); +const artifactRoot = path.resolve(process.env.QA_ARTIFACT_DIR || path.join(projectRoot, '../../work/java-edu-desktop-qa')); +const baseUrl = process.env.QA_BASE_URL || 'http://127.0.0.1:4173'; +const mode = process.env.JAVA_EDU_QA_MODE || 'all'; +const results = []; +const lessons = [...coreLessons, ...professionalLessons]; +const first = coreLessons.find(lesson => lesson.id === 'java-basics'); +const classLesson = coreLessons.find(lesson => coreExercises.find(exercise => exercise.id === lesson.exerciseId)?.mode === 'class'); +const project = professionalLessons.find(lesson => lesson.id === 'tx-proxy'); +const projectExercise = professionalExercises.find(exercise => exercise.lessonId === project.id); +const firstProject = professionalLessons[0]; +const firstProjectExercise = professionalExercises[0]; +const storageKey = 'java-edu:progress:v1'; +const preferencesKey = 'java-edu:editor-preferences:v1'; +const editor = page => page.locator('#code-editor [data-testid="code-editor-input"]'); + +async function eventually(read, expected, message, timeout = 8000) { + const deadline = Date.now() + timeout; + let value; + while (Date.now() < deadline) { + value = await read(); + if (typeof expected === 'function' ? expected(value) : value === expected) return value; + await new Promise(resolve => setTimeout(resolve, 80)); + } + assert.fail(`${message}: ${JSON.stringify(value)}`); +} + +async function capture(page, name, fullPage = false) { + await page.screenshot({ path: path.join(artifactRoot, `${name}.png`), fullPage, caret: 'initial' }); +} + +async function caseOf(name, page, test) { + const started = Date.now(); + console.log(`START ${name}`); + try { + await test(); + results.push({ name, status: 'PASS', durationMs: Date.now() - started }); + console.log(`PASS ${name}`); + return true; + } catch (error) { + await capture(page, `failure-${name}`, true).catch(() => {}); + results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - started }); + console.error(`FAIL ${name}: ${error.stack}`); + return false; + } +} + +async function step(page, value) { + await page.locator(`[data-step="${value}"]`).click(); + await eventually(() => page.locator(`[data-step="${value}"]`).getAttribute('aria-selected'), 'true', 'selected learning stage'); +} + +async function openLesson(page, lesson, stage = 'lab') { + // Hash navigation stays inside the authenticated Electron document. + await page.evaluate(hash => { location.hash = hash; }, `lesson=${lesson.id}&step=${stage}&version=21`); + await eventually(() => page.locator('#lesson-title').textContent(), text => text?.includes(lesson.title), 'requested lesson title'); + await eventually(() => page.locator(`[data-step="${stage}"]`).getAttribute('aria-selected'), 'true', 'requested lesson stage'); +} + +async function clipboardMock(page) { + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async value => { window.qaClipboardText = value; } } }); + }); +} + +async function source(page) { + await page.evaluate(() => { window.qaClipboardText = undefined; }); + await page.locator('#copy-code').click(); + return eventually(() => page.evaluate(() => window.qaClipboardText), value => typeof value === 'string', 'complete source copy'); +} + +async function quiz(page, lesson) { + await step(page, 'quiz'); + for (const question of lesson.quiz) await page.locator(`#quiz-form [name="quiz-${question.id}"][value="${question.correctIndex}"]`).check(); + await page.locator('#quiz-submit').click(); + await eventually(() => badge(page, 'quiz'), 'true', 'quiz achievement'); +} + +const badge = (page, type) => page.locator(`#lesson-achievements [data-achievement="${type}"]`).getAttribute('data-complete'); +async function noOverflow(page) { + const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) })); + assert.ok(dimensions.scroll <= dimensions.width + 1, `page overflow ${JSON.stringify(dimensions)}`); +} + +async function chooseFile(page, file, example = false) { + await page.locator(`${example ? '#lesson-panel' : '#project-file-tabs'} [data-file-path="${file}"]`).click(); +} + +async function readonlyPom(page, exercise) { + const expected = exercise.files.find(file => file.path === 'pom.xml').source; + await chooseFile(page, 'pom.xml'); + assert.equal(await editor(page).getAttribute('aria-readonly'), 'true'); + assert.equal(await page.locator('#indent-code').isDisabled(), true); + assert.equal(await page.locator('#lab-reset').isDisabled(), true); + await editor(page).focus(); + await page.keyboard.press('ControlOrMeta+Home'); + await page.keyboard.insertText('QA_FORBIDDEN_EDIT'); + assert.equal(await source(page), expected, 'readonly POM rejects actual keyboard insertion'); +} + +async function webSuite() { + const browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}) }); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true }); + const requests = []; + await context.route('**/api/**', async route => { + const url = new URL(route.request().url()); + requests.push({ path: url.pathname, method: route.request().method() }); + if (url.pathname === '/api/runtime') return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ provider: 'Wandbox', available: true, runtime: { javaVersion: 21 }, supportedTargets: [8, 11, 17, 21], notice: 'QA mock runtime only' }) }); + return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { code: 'qa_forbidden_execution', message: 'Web professional QA must not submit source.' } }) }); + }); + const page = await context.newPage(); + page.setDefaultTimeout(8000); + const pageErrors = []; + page.on('pageerror', error => pageErrors.push(error.message)); + try { + await page.goto(baseUrl); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + await clipboardMock(page); + await caseOf('web-combined-catalog-version-gates', page, async () => { + assert.equal(lessons.length, 34); + assert.equal(await page.evaluate(() => typeof window.javaEduDesktop), 'undefined'); + for (const version of [8, 11, 17, 21]) { + await page.locator('#version-select').selectOption(String(version)); + const visible = await page.locator('#lesson-list [data-lesson-id]').evaluateAll(nodes => nodes.map(node => node.dataset.lessonId).sort()); + assert.deepEqual(visible, lessons.filter(lesson => lesson.minVersion <= version).map(lesson => lesson.id).sort()); + if (version < 17) assert.equal(visible.some(id => professionalLessons.some(lesson => lesson.id === id)), false); + } + await page.locator('[data-track="project"]').click(); + assert.deepEqual(await page.locator('#lesson-list [data-lesson-id]').evaluateAll(nodes => nodes.map(node => node.dataset.lessonId).sort()), professionalLessons.map(lesson => lesson.id).sort()); + await page.locator('#version-select').selectOption('8'); + assert.equal(await page.locator('#lesson-list [data-lesson-id]').count(), 0); + await page.locator('#version-select').selectOption('21'); + await page.locator('[data-track="all"]').click(); + await openLesson(page, firstProject, 'theory'); + await capture(page, 'web-professional-theory'); + }); + + await caseOf('web-all-professional-stages-quizzes-and-disabled-execution', page, async () => { + for (const lesson of professionalLessons) { + await openLesson(page, lesson, 'theory'); + assert.ok(await page.locator('#source-list a[href^="https://"]').count() > 0, `${lesson.id} official references`); + await step(page, 'example'); + for (const file of lesson.example.files) { + await chooseFile(page, file.path, true); + assert.equal(await page.locator('#example-file-panel code').textContent(), file.code, `${lesson.id}/${file.path} visible example`); + } + await quiz(page, lesson); + assert.equal(await badge(page, 'lab'), 'false', 'quiz alone does not certify an unexecuted project'); + await step(page, 'practice'); + assert.ok((await page.locator('#lesson-panel').innerText()).length > 100); + await step(page, 'lab'); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + assert.equal(await page.locator('#prepare-project').count(), 0, 'web cannot prepare local Maven'); + assert.match(await page.locator('#runtime-info').textContent(), /Windows/); + assert.equal(await page.locator('#project-download').getAttribute('href'), `/downloads/${lesson.id}.zip`); + } + assert.deepEqual(requests.filter(request => request.path !== '/api/runtime'), [], 'web project browsing does not call execute or local APIs'); + }); + + await caseOf('web-files-readonly-draft-isolation-and-download', page, async () => { + await openLesson(page, firstProject); + const file = firstProjectExercise.files.find(item => item.editable); + const draft = `${file.source}\n// 웹 초안 보존\n\n`; + await chooseFile(page, file.path); + await editor(page).fill(draft); + await readonlyPom(page, firstProjectExercise); + await chooseFile(page, file.path); + assert.equal(await source(page), draft); + await openLesson(page, project); + await openLesson(page, firstProject); + assert.equal(await source(page), draft, 'draft survives another project'); + await page.locator('#editor-theme-select').selectOption('light'); + await page.reload(); + await editor(page).waitFor({ state: 'visible' }); + await clipboardMock(page); + assert.equal(await source(page), draft); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'light'); + const pending = page.waitForEvent('download'); + await page.locator('#project-download').click(); + const download = await pending; + const filename = path.join(artifactRoot, `download-${firstProject.id}.zip`); + await download.saveAs(filename); + const archive = unzipSync(await readFile(filename)); + assert.match(strFromU8(archive['README.txt']), /Maven/); + assert.equal(strFromU8(archive[`lessons/${firstProject.id}/starter/${file.path}`]), file.source, 'download remains the trusted starter, not an implicitly uploaded draft'); + assert.equal(await source(page), draft, 'download leaves the draft untouched'); + await page.locator('.editor-shell').scrollIntoViewIfNeeded(); + await capture(page, 'web-project-editor'); + await readonlyPom(page, firstProjectExercise); + await capture(page, 'web-project-readonly-pom'); + assert.deepEqual(requests.filter(request => request.path !== '/api/runtime'), []); + }); + + await caseOf('web-professional-responsive-and-text-enlargement', page, async () => { + const capstone = professionalLessons.find(lesson => lesson.id === 'orders-capstone'); + await openLesson(page, capstone); + const capstoneFiles = professionalExercises.find(exercise => exercise.lessonId === capstone.id).files; + for (const theme of ['dark', 'light', 'contrast']) { + await page.locator('#editor-theme-select').selectOption(theme); + for (const [suffix, file] of [['java', capstoneFiles.find(file => file.editable)], ['xml', capstoneFiles.find(file => file.path === 'pom.xml')], ['sql', capstoneFiles.find(file => file.path.endsWith('.sql'))]]) { + await chooseFile(page, file.path); + await page.locator('#project-file-tabs').scrollIntoViewIfNeeded(); + await capture(page, `web-project-${theme}-${suffix}`); + } + } + for (const viewport of [{ width: 320, height: 720 }, { width: 390, height: 844 }, { width: 768, height: 1024 }, { width: 1440, height: 900 }, { width: 844, height: 390 }]) { + await page.setViewportSize(viewport); + await noOverflow(page); + for (const file of capstoneFiles) { + await chooseFile(page, file.path); + await noOverflow(page); + } + await page.locator('#project-file-tabs').scrollIntoViewIfNeeded(); + await capture(page, `web-project-${viewport.width}x${viewport.height}`); + } + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + await noOverflow(page); + await page.locator('#project-file-tabs').scrollIntoViewIfNeeded(); + await capture(page, 'web-project-mobile-200-files'); + await page.locator('#runtime-info').scrollIntoViewIfNeeded(); + await capture(page, 'web-project-mobile-200-runtime'); + await page.setViewportSize({ width: 320, height: 720 }); + await noOverflow(page); + await page.locator('#project-file-tabs').scrollIntoViewIfNeeded(); + await capture(page, 'web-project-320-200'); + await page.locator('#desktop-install-open').click(); + await page.locator('#install-dialog').waitFor({ state: 'visible' }); + await noOverflow(page); + await capture(page, 'web-install-dialog-320-200'); + await page.keyboard.press('Escape'); + assert.deepEqual(pageErrors, []); + }); + } finally { await context.close(); await browser.close(); } +} + +async function launchDesktop(userData) { + const executablePath = process.env.JAVA_EDU_ELECTRON_PATH || path.join(projectRoot, 'node_modules/electron/dist/electron.exe'); + await access(executablePath); // Fail explicitly; never let electron's package import download a missing binary. + const env = { ...process.env, JAVA_EDU_QA_DATA: userData }; + env.NODE_TLS_REJECT_UNAUTHORIZED = '1'; + delete env.ELECTRON_RUN_AS_NODE; + const app = await electron.launch({ executablePath, args: [path.resolve(projectRoot)], env, timeout: 60000 }); + const page = await app.firstWindow(); + page.setDefaultTimeout(10000); + await page.locator('#workspace-grid').waitFor({ state: 'visible', timeout: 45000 }); + await clipboardMock(page); + return { app, page }; +} + +async function mockDirectoryDialog(app, chosenPath, canceled = false) { + await app.evaluate(({ dialog }, value) => { + dialog.showOpenDialog = async () => ({ canceled: value.canceled, filePaths: value.canceled ? [] : [value.chosenPath] }); + }, { chosenPath, canceled }); +} + +async function settingsDialog(page) { + await page.locator('#desktop-settings-open').click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'visible' }); + await eventually(() => page.locator('#execution-consent').isEnabled(), true, 'settings metadata loaded'); +} + +async function closeSettings(page) { + await page.locator('#desktop-settings-dialog [data-close-dialog="desktop-settings-dialog"]').first().click(); +} + +async function resultAfter(page, button, timeout = 45000, runningCapture) { + const response = page.waitForResponse(response => new URL(response.url()).pathname === (button === '#prepare-project' ? '/api/local/prepare' : '/api/execute') && response.request().method() === 'POST', { timeout }); + void response.catch(() => {}); + await page.locator(button).click(); + if (runningCapture) { + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, runningCapture); + } + const received = await response; + const body = await received.json(); + console.log('LOCAL_RESULT', JSON.stringify({ exerciseId: received.request().postDataJSON().exerciseId, status: body.status, runtimeVersion: body.runtimeVersion, targetVersion: body.targetVersion, passed: body.passed, total: body.total, error: body.error })); + await eventually(() => page.locator('#execution-output').getAttribute('data-status'), value => value !== 'running', 'UI settles after local response'); + return { body, request: received.request().postDataJSON(), httpStatus: received.status() }; +} + +async function desktopSuite() { + const jdk8 = process.env.JAVA_EDU_JDK_8; + const jdk21 = process.env.JAVA_EDU_JDK_21; + const maven = process.env.JAVA_EDU_MAVEN_HOME; + if (!jdk8 || !jdk21 || !maven) throw new Error('Desktop QA requires JAVA_EDU_JDK_8, JAVA_EDU_JDK_21 and JAVA_EDU_MAVEN_HOME pointing to approved installed tools.'); + const userData = await mkdtemp(path.join(artifactRoot, 'user-data-한글 & 25%-')); + console.log(`ISOLATED_USER_DATA ${userData}`); + let { app, page } = await launchDesktop(userData); + const solutions = JSON.parse(await readFile(path.join(projectRoot, 'examples/solutions.json'), 'utf8')); + const initialOrigin = new URL(page.url()).origin; + const executionRequests = []; + const observe = currentPage => currentPage.on('request', request => { if (new URL(request.url()).pathname === '/api/execute') executionRequests.push(request.postDataJSON()); }); + observe(page); + let savedCore; + let savedProject; + try { + const initial = await caseOf('desktop-first-open-tools-consent-and-isolation', page, async () => { + assert.equal(await page.evaluate(() => window.javaEduDesktop.platform), 'win32'); + assert.equal(await app.evaluate(({ app }) => app.getPath('userData')), userData); + const preferences = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.getLastWebPreferences()); + assert.equal(preferences.contextIsolation, true); + assert.equal(preferences.nodeIntegration, false); + assert.equal(preferences.sandbox, true); + await openLesson(page, first); + const runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); + assert.equal(runtime.mode, 'local'); + assert.equal(runtime.available, false); + assert.equal(runtime.consent.accepted, false); + assert.equal(runtime.runtimes.some(item => item.available), false); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.match(await page.locator('#runtime-info').textContent(), /JDK/); + await settingsDialog(page); + assert.equal(await page.locator('#execution-consent').isChecked(), false); + await capture(page, 'desktop-first-settings'); + await mockDirectoryDialog(app, '', true); + await page.locator('#jdk-register-8').click(); + await eventually(() => page.locator('#jdk-register-8').isEnabled(), true, 'cancelled folder selection settles'); + assert.equal((await page.evaluate(() => window.javaEduDesktop.getSettings())).runtimes.some(item => item.available), false); + assert.equal(executionRequests.length, 0, 'startup and cancelled tool selection run no student code'); + }); + if (!initial) return; + + const configured = await caseOf('desktop-tool-registration-real-probes-and-explicit-consent', page, async () => { + await mockDirectoryDialog(app, jdk21); + await page.locator('#jdk-register-8').click(); + await eventually(() => page.locator('#jdk-register-8').isEnabled(), true, 'wrong-version tool inspection settles', 30000); + assert.equal((await page.evaluate(() => window.javaEduDesktop.getSettings())).runtimes.find(item => item.javaVersion === 8).available, false); + assert.match(await page.locator('#desktop-settings-status').textContent(), /8/); + for (const [version, directory] of [[8, jdk8], [21, jdk21]]) { + await mockDirectoryDialog(app, directory); + await page.locator(`#jdk-register-${version}`).click(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).runtimes.find(item => item.javaVersion === version).available, true, `JDK ${version} is actually inspected`, 30000); + await eventually(() => page.locator(`#jdk-register-${version}`).isEnabled(), true, 'settings controls settle'); + } + await mockDirectoryDialog(app, maven); + await page.locator('#maven-register').click(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).maven.available, true, 'Maven distribution registered'); + assert.equal((await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, false); + await page.locator('#execution-consent').check(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, true, 'explicit consent persisted'); + await eventually(() => page.locator('#execution-consent').isEnabled(), true, 'consent saving settles'); + await capture(page, 'desktop-configured-settings'); + await closeSettings(page); + await eventually(() => page.locator('#run-code').isEnabled(), true, 'registered tools and consent enable core execution'); + assert.equal(executionRequests.length, 0, 'settings do not execute learner code'); + }); + if (!configured) return; + + await caseOf('desktop-core-method-real-jvm8-and21-run-submit', page, async () => { + await editor(page).fill(solutions[first.exerciseId]); + await page.locator('#runtime-version-select').selectOption('8'); + const sample = await resultAfter(page, '#run-code', 60000, 'desktop-core-running'); + assert.equal(sample.body.status, 'passed'); + assert.equal(sample.body.runtimeVersion, 8); + assert.equal(sample.body.targetVersion, 8); + assert.equal(await badge(page, 'lab'), 'false', 'run is not submit'); + await quiz(page, first); + await step(page, 'lab'); + const submission8 = await resultAfter(page, '#submit-code'); + assert.equal(submission8.body.status, 'passed'); + assert.equal(submission8.body.runtimeVersion, 8); + assert.ok(submission8.body.total > sample.body.total); + assert.equal(await badge(page, 'lab'), 'true'); + await page.locator('#runtime-version-select').selectOption('21'); + const submission21 = await resultAfter(page, '#submit-code'); + assert.equal(submission21.body.status, 'passed'); + assert.equal(submission21.body.runtimeVersion, 21); + assert.equal(submission21.request.source, solutions[first.exerciseId]); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-core-passed-jvm21'); + }); + + await caseOf('desktop-core-class-and-real-failure-results', page, async () => { + await openLesson(page, classLesson); + await editor(page).fill(solutions[classLesson.exerciseId]); + const classResult = await resultAfter(page, '#submit-code'); + assert.equal(classResult.body.status, 'passed'); + assert.equal(classResult.body.runtimeVersion, 21); + await openLesson(page, first); + await editor(page).fill('class Solution { public boolean sameCode(String a,String b) { return 없는변수; } }'); + const compile = await resultAfter(page, '#submit-code'); + assert.equal(compile.body.status, 'compile_error'); + assert.match(compile.body.compilerOutput, /없는변수/); + assert.equal(await badge(page, 'lab'), 'false'); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-korean-compiler-error'); + await editor(page).fill(coreExercises.find(item => item.id === first.exerciseId).template); + const failed = await resultAfter(page, '#submit-code'); + assert.equal(failed.body.status, 'failed'); + assert.ok(failed.body.passed < failed.body.total); + await editor(page).fill('class Solution { public boolean sameCode(String a,String b) { System.exit(7); return false; } }'); + const runtime = await resultAfter(page, '#run-code'); + assert.equal(runtime.body.status, 'runtime_error'); + }); + + await caseOf('desktop-consent-revocation-blocks-code-without-losing-draft', page, async () => { + const before = executionRequests.length; + const draft = `${solutions[first.exerciseId]}\n// 동의 철회 중에도 유지`; + await editor(page).fill(draft); + await settingsDialog(page); + await page.locator('#execution-consent').uncheck(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, false, 'consent revocation is saved'); + await closeSettings(page); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + assert.equal(await source(page), draft); + await settingsDialog(page); + await page.locator('#execution-consent').check(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, true, 'consent can be granted again'); + await closeSettings(page); + await eventually(() => page.locator('#run-code').isEnabled(), true, 'run becomes available after explicit consent'); + assert.equal(executionRequests.length, before, 'consent changes never run learner code automatically'); + }); + + await caseOf('desktop-running-java-cancel-and-draft-preservation', page, async () => { + const cancellable = 'class Solution { public boolean sameCode(String a,String b) { try { new java.io.File("qa-cancel-started.txt").createNewFile(); } catch(java.io.IOException e) { throw new RuntimeException(e); } while(true) {} } }'; + await editor(page).fill(cancellable); + const before = executionRequests.length; + await page.locator('#run-code').click(); + await eventually(() => executionRequests.length, before + 1, 'actual local execution requested'); + const jobsRoot = path.join(userData, 'java-edu/jobs'); + const job = await eventually(async () => { + for (const entry of await readdir(jobsRoot).catch(() => [])) { + const directory = path.join(jobsRoot, entry); + if (entry.startsWith('job-') && await access(path.join(directory, 'qa-cancel-started.txt')).then(() => true, () => false)) return directory; + } + return null; + }, value => Boolean(value), 'Java itself writes a marker before cancellation', 30000); + await page.locator('#cancel-execution').click(); + await eventually(() => page.locator('#execution-output').getAttribute('data-status'), 'cancelled', 'user cancellation is visible'); + await eventually(() => access(job).then(() => true, () => false), false, 'cancelled job workspace is cleaned', 10000); + assert.equal(await source(page), cancellable); + assert.equal(await badge(page, 'lab'), 'false'); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-cancelled'); + }); + + const prepared = await caseOf('desktop-spring-h2-prepare-is-required-and-not-achievement', page, async () => { + await openLesson(page, project); + await eventually(() => page.locator('#prepare-project').isEnabled(), true, 'project metadata ready'); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + assert.match(await page.locator('#execution-state').textContent(), /준비/); + const before = await source(page); + await readonlyPom(page, projectExercise); + await chooseFile(page, projectExercise.files.find(file => file.editable).path); + const preparation = await resultAfter(page, '#prepare-project', 360000, 'desktop-spring-h2-preparing'); + assert.equal(preparation.body.status, 'passed', preparation.body.stderr || preparation.body.stdout); + assert.equal(preparation.body.prepared, true); + assert.equal(preparation.body.runtimeVersion, 21); + assert.equal(preparation.body.targetVersion, 17); + assert.equal(preparation.request.files, undefined, 'prepare does not submit the learner draft'); + assert.equal(preparation.request.source, undefined); + assert.equal(await badge(page, 'lab'), 'false', 'reference preparation is not learner completion'); + assert.equal(await source(page), before, 'preparation leaves learner source untouched'); + await eventually(() => page.locator('#run-code').isEnabled(), true, 'successful preparation enables project execution'); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-spring-h2-prepared'); + }); + + if (prepared) await caseOf('desktop-spring-h2-reference-files-real-submit', page, async () => { + const expectedFiles = {}; + for (const file of project.professionalExercise.files.filter(file => file.editable)) { + const text = await readFile(path.join(projectRoot, 'curriculum/professional', file.referencePath), 'utf8'); + expectedFiles[file.path] = text; + await chooseFile(page, file.path); + await editor(page).fill(text); + } + await quiz(page, project); + await step(page, 'lab'); + const submitted = await resultAfter(page, '#submit-code', 150000); + assert.equal(submitted.body.status, 'passed', submitted.body.stderr || submitted.body.stdout); + assert.equal(submitted.body.runtimeVersion, 21); + assert.equal(submitted.body.targetVersion, 17); + assert.ok(submitted.body.checks.length > 0 && submitted.body.checks.every(check => check.passed)); + assert.deepEqual(submitted.request.files, expectedFiles); + assert.equal(Object.hasOwn(submitted.request.files, 'pom.xml'), false); + assert.equal(await badge(page, 'lab'), 'true'); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-spring-h2-submitted'); + }); + + await caseOf('desktop-restart-new-port-preserves-progress-theme-and-drafts', page, async () => { + await openLesson(page, first); + savedCore = `${solutions[first.exerciseId]}\n// 재시작 초안\n\n`; + await editor(page).fill(savedCore); + await page.locator('#editor-theme-select').selectOption('contrast'); + await openLesson(page, project); + const editable = projectExercise.files.find(file => file.editable); + await chooseFile(page, editable.path); + savedProject = `${editable.source}\n// 프로젝트 재시작 초안\n`; + await editor(page).fill(savedProject); + await chooseFile(page, 'pom.xml'); + const before = await page.evaluate(keys => Object.fromEntries(keys.map(key => [key, window.javaEduDesktop.storage.getItem(key)])), [storageKey, preferencesKey]); + assert.ok(before[storageKey].includes('재시작 초안')); + await app.close(); + ({ app, page } = await launchDesktop(userData)); + observe(page); + const newOrigin = new URL(page.url()).origin; + assert.notEqual(newOrigin, initialOrigin, 'actual app restart binds a different port'); + const restored = await page.evaluate(keys => Object.fromEntries(keys.map(key => [key, window.javaEduDesktop.storage.getItem(key)])), [storageKey, preferencesKey]); + assert.equal(restored[preferencesKey], before[preferencesKey]); + const beforeProgress = JSON.parse(before[storageKey]); + const afterProgress = JSON.parse(restored[storageKey]); + for (const key of ['quizPassed', 'drafts', 'fileDrafts']) assert.deepEqual(afterProgress[key], beforeProgress[key], `${key} survives port change`); + await openLesson(page, first); + assert.equal(await source(page), savedCore); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'contrast'); + assert.equal(await badge(page, 'quiz'), 'true'); + await openLesson(page, project); + await chooseFile(page, editable.path); + assert.equal(await source(page), savedProject); + assert.equal(await page.locator('#run-code').isDisabled(), true, 'new app session requires fresh project preparation'); + assert.equal(await page.locator('#prepare-project').getAttribute('data-prepared'), 'false'); + await page.locator('#runtime-info').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-restarted-project'); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + await noOverflow(page); + await settingsDialog(page); + await capture(page, 'desktop-settings-200'); + await noOverflow(page); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(720, 560)); + await eventually(() => page.evaluate(() => innerWidth), width => width < 800, 'native app minimum window size'); + await noOverflow(page); + await capture(page, 'desktop-settings-minimum-200'); + await page.locator('#execution-consent').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-settings-minimum-200-consent'); + console.log('APP_VIEWPORT', JSON.stringify(await page.evaluate(() => ({ width: innerWidth, height: innerHeight, rootFont: getComputedStyle(document.documentElement).fontSize })))); + await closeSettings(page); + await page.locator('#project-file-tabs').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-project-200'); + }); + } finally { await app.close().catch(() => {}); } +} + +await mkdir(artifactRoot, { recursive: true }); +try { + if (!['all', 'web', 'desktop'].includes(mode)) throw new Error('JAVA_EDU_QA_MODE must be all, web, or desktop.'); + if (mode !== 'desktop') await webSuite(); + if (mode !== 'web') await desktopSuite(); +} catch (error) { + results.push({ name: 'environment', status: 'BLOCKED', message: error.message }); + console.error(error.stack); +} +const report = { recordedAt: new Date().toISOString(), mode, node: process.version, artifacts: artifactRoot, results }; +await writeFile(path.join(artifactRoot, `results-${mode}-${Date.now()}.json`), JSON.stringify(report, null, 2)); +console.log(JSON.stringify(report, null, 2)); +if (!results.length || results.some(result => result.status !== 'PASS')) process.exitCode = 1; diff --git a/tests/local/contracts.test.mjs b/tests/local/contracts.test.mjs new file mode 100644 index 0000000..664e2d4 --- /dev/null +++ b/tests/local/contracts.test.mjs @@ -0,0 +1,153 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, rm, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createLocalEngine } from '../../server/local/engine.mjs'; +import { createJobWorkspace, safeRelativePath } from '../../server/local/workspace.mjs'; +import { processEnvironment } from '../../server/local/process.mjs'; +import { parseJavaVersion } from '../../server/local/settings.mjs'; +import { validateSubmittedFiles, mavenCommand, readTestReports } from '../../server/local/projects.mjs'; +import { createDesktopStorage } from '../../desktop/storage.cjs'; +import { prepareServiceEnvironment, requiredServices } from '../../server/local/resources.mjs'; + +async function scratch(t) { + const parent = await realpath(tmpdir()); + const root = await mkdtemp(path.join(parent, 'java-edu-local-test-')); + t.after(async () => { const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-local-test-')); await rm(resolved, { recursive: true, force: true }); }); + return root; +} + +const definition = { id: 'unit-lab', lessonId: 'unit', minVersion: 8, checks: [{ name: 'contract', expression: 'new Solution().value() == 7' }], sampleChecks: [{ name: 'sample', expression: 'new Solution().value() == 7' }] }; +function mockSettings({ consent = true, versions = [8, 21] } = {}) { + const jdks = Object.fromEntries(versions.map((version) => [version, { version, home: path.resolve(`jdk${version}`), java: path.resolve(`jdk${version}/bin/java.exe`), javac: path.resolve(`jdk${version}/bin/javac.exe`) }])); + return { snapshot: () => ({ executionConsent: consent, jdks, maven: null }), verifyJdk: async (version) => jdks[version] }; +} + +test('path policy rejects traversal, drive paths, ADS and Windows device names', () => { + for (const name of ['../answer.java', 'src/../../Main.java', 'C:/x.java', 'src\\x.java', '/tmp/x', 'src/x.java:stream', 'src/NUL.java', 'src/CON', 'src//X.java']) assert.throws(() => safeRelativePath(name)); + assert.equal(safeRelativePath('src/main/resources/mappers/OrderMapper.xml'), 'src/main/resources/mappers/OrderMapper.xml'); +}); + +test('registered exercise allowlist rejects added/omitted files and learner POM', () => { + const project = { editableFiles: ['src/main/java/X.java'], files: [{}], testFiles: [{}] }; + for (const supplied of [{}, { 'pom.xml': '' }, { 'src/main/java/X.java': '', 'evil.java': 'x' }, { 'src/main/java/X.java': '\0' }]) assert.throws(() => validateSubmittedFiles(project, supplied)); + assert.deepEqual(validateSubmittedFiles(project, { 'src/main/java/X.java': 'class X {}' }), { 'src/main/java/X.java': 'class X {}' }); +}); + +test('core executes the trusted checks on the actual chosen JVM and removes workspace', async (t) => { + const root = await scratch(t); + const calls = []; + let workspace; + const runner = async (file, args, options) => { + calls.push({ file, args, options }); + workspace = options.cwd; + if (file.endsWith('javac.exe')) return { exitCode: 0, reason: 'exited', stdout: '', stderr: 'warning' }; + const source = await readFile(path.join(options.cwd, 'Main.java'), 'utf8'); + const nonce = source.match(/__JAVA_EDU_([a-f0-9]{32})__/)[1]; + return { exitCode: 0, reason: 'exited', stdout: `__JAVA_EDU_${nonce}__START\n__JAVA_EDU_${nonce}__CHECK:0:true\n__JAVA_EDU_${nonce}__END\n`, stderr: '' }; + }; + const engine = await createLocalEngine({ dataRoot: root, settings: mockSettings(), runner, definitions: [definition], projects: [] }); + const result = await engine.execute({ exerciseId: 'unit-lab', source: 'class Solution { int value() { return 7; } }', requestId: 'core-pass', action: 'submit' }); + assert.equal(result.status, 'passed'); + assert.equal(result.runtimeVersion, 21); + assert.equal(result.targetVersion, 8); + assert.equal(result.passed, 1); + assert.ok(calls[0].args.includes('--release')); + assert.equal(calls[0].args[calls[0].args.indexOf('--release') + 1], '8'); + await assert.rejects(readFile(path.join(workspace, 'Main.java')), /ENOENT/); +}); + +test('consent, unavailable JDK, unknown exercise and oversized code fail before process execution', async (t) => { + const root = await scratch(t); + let calls = 0; + const runner = () => { calls++; throw new Error('Must not spawn'); }; + const input = { exerciseId: 'unit-lab', source: 'class Solution {}', action: 'run', requestId: 'invalid-case' }; + const noConsent = await createLocalEngine({ dataRoot: root, settings: mockSettings({ consent: false }), runner, definitions: [definition], projects: [] }); + await assert.rejects(noConsent.execute(input), { code: 'execution_consent_required' }); + assert.equal(noConsent.runtime().available, false); + const noJdk = await createLocalEngine({ dataRoot: root, settings: mockSettings({ versions: [] }), runner, definitions: [definition], projects: [] }); + await assert.rejects(noJdk.execute(input), { code: 'jdk_required' }); + const engine = await createLocalEngine({ dataRoot: root, settings: mockSettings(), runner, definitions: [definition], projects: [] }); + await assert.rejects(engine.execute({ ...input, exerciseId: 'unknown' }), { code: 'exercise_not_found' }); + await assert.rejects(engine.execute({ ...input, source: '가'.repeat(12_000) }), { code: 'invalid_source' }); + await assert.rejects(engine.execute({ ...input, runtimeVersion: 22 }), { code: 'invalid_runtime' }); + assert.equal(calls, 0); +}); + +test('active work is bounded to one, duplicate requests reject and cancellation reaches child supervisor', async (t) => { + const root = await scratch(t); + let started; + const ready = new Promise((resolve) => { started = resolve; }); + const runner = (_file, _args, { signal }) => new Promise((resolve) => { started(); signal.addEventListener('abort', () => resolve({ exitCode: -1, reason: 'cancelled', stdout: '', stderr: '' }), { once: true }); }); + const engine = await createLocalEngine({ dataRoot: root, settings: mockSettings(), runner, definitions: [definition], projects: [] }); + const input = { exerciseId: 'unit-lab', source: 'class Solution {}', action: 'run', requestId: 'active' }; + const operation = engine.execute(input); + await ready; + await assert.rejects(engine.execute({ ...input, requestId: 'other' }), { code: 'execution_busy' }); + await assert.rejects(engine.execute(input), { code: 'duplicate_request' }); + assert.deepEqual(engine.cancel('active'), { cancelled: true }); + const result = await operation; + assert.equal(result.error.code, 'request_cancelled'); + await engine.close(); + await assert.rejects(engine.execute({ ...input, requestId: 'closed' }), { code: 'app_closing' }); +}); + +test('Maven command fixes launcher/settings/repository and never invokes a shell or learner command', async (t) => { + const root = await scratch(t); + const workspace = await createJobWorkspace(root); + try { + const maven = { home: path.join(root, 'Maven & 한글 %'), launcher: path.join(root, 'Maven & 한글 %/boot/plexus.jar'), configuration: path.join(root, 'Maven & 한글 %/bin/m2.conf') }; + const jdk = { java: path.join(root, 'Java & 한글 %/bin/java.exe') }; + const command = await mavenCommand({ workspace, moduleDirectory: path.join(workspace.directory, 'module'), dataRoot: root, jdk, maven }); + assert.equal(command.executable, jdk.java); + assert.ok(command.args.includes('--offline')); + assert.ok(command.args.includes('-DforkCount=0')); + assert.equal(command.args.at(-1), 'test'); + assert.ok(command.args.includes(maven.launcher)); + assert.equal(command.args.filter((arg) => arg === '--settings' || arg === '--global-settings').length, 2); + const settings = await readFile(command.args[command.args.indexOf('--settings') + 1], 'utf8'); + assert.match(settings, /\*<\/mirrorOf>/); + assert.match(settings, /https:\/\/repo.maven.apache.org\/maven2/); + } finally { await workspace.dispose(); } +}); + +test('Surefire reports distinguish passing, failing and skipped behavior without evaluating XML entities', async (t) => { + const root = await scratch(t); + const reports = path.join(root, 'target/surefire-reports'); + await mkdir(reports, { recursive: true }); + const file = path.join(reports, 'TEST-edu.lab.ContractTest.xml'); + await writeFile(file, ''); + const checks = await readTestReports(root); + assert.deepEqual(checks.map((entry) => entry.passed), [true, false, false]); + assert.equal(checks[2].skipped, true); + await writeFile(file, ']>'); + await assert.rejects(readTestReports(root), { code: 'invalid_test_report' }); +}); + +test('desktop storage survives new origins/restarts and cannot address arbitrary files', async (t) => { + const root = await scratch(t); + const first = createDesktopStorage(root); + first.setItem('java-edu:progress:v1', '{"lesson":"oop"}'); + assert.equal(createDesktopStorage(root).getItem('java-edu:progress:v1'), '{"lesson":"oop"}'); + assert.throws(() => first.setItem('../settings.json', 'bad')); + assert.throws(() => first.setItem('java-edu:test', 'x'.repeat(8 * 1024 * 1024))); + first.removeItem('java-edu:progress:v1'); + assert.equal(createDesktopStorage(root).getItem('java-edu:progress:v1'), null); +}); + +test('toolchain parsing separates old/new Java version forms and strips inherited process options', () => { + assert.equal(parseJavaVersion('openjdk version "1.8.0_265"'), 8); + assert.equal(parseJavaVersion('javac 21.0.12.1'), 21); + assert.equal(parseJavaVersion('java version "17.0.1"'), 17); + const env = processEnvironment(path.resolve('jdk'), path.resolve('tmp')); + for (const key of ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', '_JAVA_OPTIONS', 'CLASSPATH', 'MAVEN_OPTS', 'MAVEN_ARGS', 'HTTP_PROXY', 'GITHUB_TOKEN']) assert.equal(Object.hasOwn(env, key), false); +}); + +test('optional services use fixed training loopback addresses and reject missing infrastructure before execution', async () => { + const project = { profile: { resources: [{ type: 'redis', host: 'attacker.invalid', port: 6379 }, { type: 'kafka' }] } }; + assert.deepEqual(requiredServices(project).map(({ host, port }) => ({ host, port })), [{ host: '127.0.0.1', port: 16379 }, { host: '127.0.0.1', port: 19092 }]); + await assert.rejects(prepareServiceEnvironment(project, undefined, async () => false), { code: 'unsupported_resources', httpStatus: 409 }); + assert.deepEqual(await prepareServiceEnvironment(project, undefined, async () => true), { JAVA_EDU_REDIS_HOST: '127.0.0.1', JAVA_EDU_REDIS_PORT: '16379', JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS: '127.0.0.1:19092' }); + assert.deepEqual(await prepareServiceEnvironment({ profile: { resources: [{ type: 'h2' }] } }), {}); +}); diff --git a/tests/local/http.test.mjs b/tests/local/http.test.mjs new file mode 100644 index 0000000..1b46b29 --- /dev/null +++ b/tests/local/http.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import http from 'node:http'; +import { startLocalServer, SESSION_COOKIE, CSRF_HEADER } from '../../server/local/http.mjs'; + +test('loopback authenticates static/API access and validates Host, Origin and CSRF before execution', async (t) => { + let calls = 0; + const engine = { runtime: () => ({ mode: 'local', available: false }), execute: async () => { calls++; return { status: 'passed' }; }, close: async () => {}, cancel: () => ({ cancelled: true }) }; + const local = await startLocalServer({ distRoot: path.resolve(fileURLToPath(new URL('../../dist', import.meta.url))), engine }); + t.after(() => local.close()); + assert.match(local.origin, /^http:\/\/127\.0\.0\.1:\d+$/); + const cookie = `${SESSION_COOKIE}=${local.sessionToken}`; + const auth = { Cookie: cookie }; + assert.equal((await fetch(`${local.origin}/api/runtime`)).status, 401); + assert.equal((await fetch(`${local.origin}/api/runtime`, { headers: auth })).status, 200); + assert.equal((await fetch(`${local.origin}/`, { headers: auth })).status, 200); + const forgedHostStatus = await new Promise((resolve, reject) => { + const request = http.get(`${local.origin}/api/runtime`, { headers: { ...auth, Host: 'evil.test' } }, (response) => { response.resume(); resolve(response.statusCode); }); + request.on('error', reject); + }); + assert.equal(forgedHostStatus, 403); + assert.equal((await fetch(`${local.origin}/api/runtime`, { headers: { ...auth, Origin: 'null' } })).status, 403); + assert.equal((await fetch(`${local.origin}/server/executor.mjs`, { headers: auth })).status, 404); + const post = (headers) => fetch(`${local.origin}/api/execute`, { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json', ...headers }, body: '{}' }); + assert.equal((await post({})).status, 403); + assert.equal((await post({ Origin: local.origin })).status, 403); + assert.equal((await post({ Origin: 'https://other.example', [CSRF_HEADER]: local.csrfToken })).status, 403); + assert.equal(calls, 0); + assert.equal((await post({ Origin: local.origin, [CSRF_HEADER]: local.csrfToken })).status, 200); + assert.equal(calls, 1); +}); diff --git a/tests/local/java-smoke.mjs b/tests/local/java-smoke.mjs new file mode 100644 index 0000000..c5bce74 --- /dev/null +++ b/tests/local/java-smoke.mjs @@ -0,0 +1,64 @@ +// Opt-in integration check. Runs repository-authored reference answers only. +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, realpath, rm, mkdir, cp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSettingsStore } from '../../server/local/settings.mjs'; +import { createLocalEngine } from '../../server/local/engine.mjs'; +import { exerciseDefinitions } from '../../server/exercises.mjs'; + +const repo = fileURLToPath(new URL('../../', import.meta.url)); +const ephemeralParent = await realpath(tmpdir()); +const root = process.env.JAVA_EDU_SMOKE_ROOT ? path.resolve(process.env.JAVA_EDU_SMOKE_ROOT) : await mkdtemp(path.join(ephemeralParent, 'java-edu-java-smoke-한글 & %JAVA_HOME%-')); +await mkdir(root, { recursive: true }); +if (process.env.JAVA_EDU_SMOKE_CACHE && !process.env.JAVA_EDU_SMOKE_ROOT) await cp(process.env.JAVA_EDU_SMOKE_CACHE, path.join(root, 'maven-repository'), { recursive: true, errorOnExist: true, force: false }); +const settings = createSettingsStore(root); +let engine; +try { + await settings.initialize(); + for (const version of [8, 11, 17, 21]) { + const home = process.env[`JAVA_EDU_JDK_${version}`]; + if (home) await settings.chooseJdk(home, version); + } + await settings.setConsent(true); + if (process.env.JAVA_EDU_MAVEN_HOME) await settings.chooseMaven(process.env.JAVA_EDU_MAVEN_HOME); + engine = await createLocalEngine({ dataRoot: root, projectRoot: path.join(repo, 'curriculum/professional'), settings }); + const solutions = JSON.parse(await readFile(path.join(repo, 'examples/solutions.json'), 'utf8')); + if (process.env.JAVA_EDU_SMOKE_PROJECT) { + for (const id of process.env.JAVA_EDU_SMOKE_PROJECT.split(',')) { + const result = await engine.prepare({ exerciseId: `${id}-project`, requestId: `prepare-${Date.now()}`, runtimeVersion: 21 }); + console.log(JSON.stringify(result.status === 'passed' ? { exerciseId: `${id}-project`, operation: 'prepare', status: result.status, runtimeVersion: result.runtimeVersion, passed: result.passed, total: result.total, durationMs: result.durationMs } : result, null, 2)); + assert.equal(result.status, 'passed'); + const project = await engine.project(`${id}-project`); + const lesson = JSON.parse(await readFile(path.join(repo, `curriculum/professional/lessons/${id}/lesson.json`), 'utf8')); + const files = Object.fromEntries(await Promise.all(lesson.professionalExercise.files.filter((file) => file.editable).map(async (file) => [file.path, await readFile(path.join(repo, 'curriculum/professional', file.referencePath), 'utf8')]))); + const submitted = await engine.execute({ exerciseId: project.exerciseId, files, action: 'submit', requestId: `submit-${Date.now()}`, runtimeVersion: 21 }); + console.log(JSON.stringify({ exerciseId: project.exerciseId, status: submitted.status, runtimeVersion: submitted.runtimeVersion, passed: submitted.passed, total: submitted.total, stderr: submitted.stderr }, null, 2)); + assert.equal(submitted.status, 'passed'); + } + } else { + for (const version of [8, 11, 17, 21]) { + if (!process.env[`JAVA_EDU_JDK_${version}`]) continue; + for (const exercise of exerciseDefinitions.filter((definition) => definition.minVersion <= version && (!process.env.JAVA_EDU_SMOKE_EXERCISE || definition.id === process.env.JAVA_EDU_SMOKE_EXERCISE))) { + const result = await engine.execute({ exerciseId: exercise.id, source: solutions[exercise.id], action: 'submit', requestId: `smoke-${version}-${exercise.id}`, runtimeVersion: version }); + console.log(JSON.stringify({ exerciseId: exercise.id, runtime: result.runtimeVersion, target: result.targetVersion, status: result.status, passed: result.passed, total: result.total, durationMs: result.durationMs })); + if (result.status !== 'passed') console.error(result.compilerOutput, result.stderr, result.stdout); + assert.equal(result.status, 'passed'); + assert.equal(result.runtimeVersion, version); + } + const compilationError = await engine.execute({ exerciseId: 'java-basics-lab', source: 'class Solution { public boolean sameCode(String left, String right) { return 없는변수; } }', action: 'submit', requestId: `smoke-korean-error-${version}`, runtimeVersion: version }); + assert.equal(compilationError.status, 'compile_error'); + assert.match(compilationError.compilerOutput, /없는변수/); + console.log(JSON.stringify({ case: 'Korean compiler diagnostics', runtime: version, status: 'passed' })); + } + } +} finally { + await engine?.close(); + if (!process.env.JAVA_EDU_SMOKE_ROOT) { + const verified = await realpath(root); + assert.equal(path.dirname(verified), ephemeralParent); + assert.ok(path.basename(verified).startsWith('java-edu-java-smoke-')); + await rm(verified, { recursive: true, force: true }); + } +} diff --git a/tests/local/process-fixture.mjs b/tests/local/process-fixture.mjs new file mode 100644 index 0000000..e022bac --- /dev/null +++ b/tests/local/process-fixture.mjs @@ -0,0 +1,17 @@ +// Fixed maintenance fixtures, never learner-supplied commands. +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +if (process.argv[2] === 'echo') process.stdout.write(JSON.stringify(process.argv.slice(3))); +else if (process.argv[2] === 'overflow') { for (let index = 0; index < 5_000; index++) process.stdout.write('x'.repeat(100)); } +else if (process.argv[2] === 'sleep') { writeFileSync(process.argv[3], String(process.pid)); setInterval(() => {}, 1000); } +else if (process.argv[2] === 'tree') { + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'sleep', process.argv[3]], { stdio: 'inherit', shell: false, windowsHide: true }); + process.stdout.write(`CHILD:${child.pid}\n`); + setInterval(() => {}, 1000); +} +else if (process.argv[2] === 'supervisor-parent') { + const { runProcess, processEnvironment } = await import('../../server/local/process.mjs'); + await runProcess(process.execPath, [fileURLToPath(import.meta.url), 'tree', process.argv[4]], { cwd: process.argv[3], env: processEnvironment(undefined, process.argv[3]), timeoutMs: 20_000 }); +} diff --git a/tests/local/process.test.mjs b/tests/local/process.test.mjs new file mode 100644 index 0000000..2d45dd7 --- /dev/null +++ b/tests/local/process.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; +import { runProcess, processEnvironment } from '../../server/local/process.mjs'; + +const fixedFixture = fileURLToPath(new URL('./process-fixture.mjs', import.meta.url)); + +test('Windows Job supervisor preserves Unicode/metacharacter argv and stops timeout process trees', { skip: process.platform !== 'win32' }, async (t) => { + const parent = await realpath(tmpdir()); + const root = await mkdtemp(path.join(parent, 'java-edu-local-test-공백 & %-')); + t.after(async () => { const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-local-test-')); await rm(resolved, { recursive: true, force: true }); }); + const env = processEnvironment(undefined, root); + const argv = ['공백 포함', 'a&b%PATH%', 'a"quoted"', 'trailing\\', '']; + const echo = await runProcess(process.execPath, [fixedFixture, 'echo', ...argv], { cwd: root, env }); + assert.equal(echo.exitCode, 0, echo.stderr); + assert.deepEqual(JSON.parse(echo.stdout), argv); + const pidfile = path.join(root, 'child.pid'); + const tree = await runProcess(process.execPath, [fixedFixture, 'tree', pidfile], { cwd: root, env, timeoutMs: 1200 }); + assert.equal(tree.reason, 'timeout', tree.stderr); + const childPid = Number(await readFile(pidfile, 'utf8')); + assert.throws(() => process.kill(childPid, 0), { code: 'ESRCH' }); + const flood = await runProcess(process.execPath, [fixedFixture, 'overflow'], { cwd: root, env, outputLimit: 1024 }); + assert.equal(flood.reason, 'output_limit'); + assert.ok(Buffer.byteLength(flood.stdout) <= 1024); + const orphanPidfile = path.join(root, 'orphan-child.pid'); + const parentProcess = spawn(process.execPath, [fixedFixture, 'supervisor-parent', root, orphanPidfile], { cwd: root, env, shell: false, windowsHide: true, stdio: 'ignore' }); + t.after(() => { if (parentProcess.exitCode === null) parentProcess.kill(); }); + let orphanPid; + for (let retry = 0; retry < 60 && !orphanPid; retry++) { await delay(100); orphanPid = Number(await readFile(orphanPidfile, 'utf8').catch(() => '')); } + assert.ok(orphanPid, 'Supervised descendant must have started.'); + parentProcess.kill(); + let alive = true; + for (let retry = 0; retry < 30 && alive; retry++) { await delay(100); try { process.kill(orphanPid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; else throw error; } } + assert.equal(alive, false, 'Opened parent-process handle must trigger descendant cleanup after app termination.'); + // Instrument only a private test copy to pause exactly after creation and before ResumeThread. + const supervisorSource = await readFile(new URL('../../desktop/job-runner.ps1', import.meta.url), 'utf8'); + const createdPidfile = path.join(root, 'suspended.pid'); + const escapedPidfile = createdPidfile.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + const instrumented = supervisorSource.replace(/(Check\(CreateProcess\(executable,[^\r\n]+\);\r?\n)/, `$1 System.IO.File.WriteAllText("${escapedPidfile}", pi.pid.ToString()); System.Threading.Thread.Sleep(30000);\n`); + assert.notEqual(instrumented, supervisorSource); + const helperFile = path.join(root, 'instrumented-helper.ps1'); + const specFile = path.join(root, 'boundary-command.json'); + await writeFile(helperFile, `\ufeff${instrumented}`); // Windows PowerShell 5 must read the injected Unicode fixture path as UTF-8. + await writeFile(specFile, JSON.stringify({ executable: process.execPath, args: [fixedFixture, 'echo', 'boundary'], cwd: root, timeoutMs: 5000, memoryMiB: 384, maxProcesses: 8, parentPid: process.pid, resultPath: path.join(root, 'boundary-result.json') })); + const powershell = path.join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe'); + const helper = spawn(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', helperFile, '-SpecPath', specFile], { cwd: root, env, shell: false, windowsHide: true, stdio: 'ignore' }); + t.after(() => { if (helper.exitCode === null) helper.kill(); }); + let suspendedPid; + for (let retry = 0; retry < 60 && !suspendedPid; retry++) { await delay(100); suspendedPid = Number(await readFile(createdPidfile, 'utf8').catch(() => '')); } + assert.ok(suspendedPid, 'Suspended child must have been created.'); + t.after(() => { try { process.kill(suspendedPid); } catch { /* supervisor already cleaned up */ } }); + helper.kill(); + let suspendedAlive = true; + for (let retry = 0; retry < 30 && suspendedAlive; retry++) { await delay(100); try { process.kill(suspendedPid, 0); } catch (error) { if (error.code === 'ESRCH') suspendedAlive = false; else throw error; } } + assert.equal(suspendedAlive, false, 'Atomic Job-list creation must clean up even before ResumeThread.'); +}); diff --git a/tests/local/reference-policy.test.mjs b/tests/local/reference-policy.test.mjs new file mode 100644 index 0000000..50ef37e --- /dev/null +++ b/tests/local/reference-policy.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { lessons as coreLessons } from '../../dist/data/curriculum.js'; +import { jobEvidence } from '../../dist/data/job-evidence.js'; +import { externalReferenceUrl } from '../../desktop/reference-policy.cjs'; + +const projectRoot = new URL('../../', import.meta.url); +const text = (relative) => readFile(new URL(relative, projectRoot), 'utf8'); + +test('every core and professional lesson source is permitted by the desktop external link policy', async () => { + const catalog = JSON.parse(await text('curriculum/professional/catalog.json')); + // Read canonical inputs, so this guard also runs before generated assets exist. + const professionalLessons = await Promise.all(catalog.lessons.map(async (entry) => JSON.parse(await text(`curriculum/professional/${entry.lessonPath}`)))); + const lessons = [...coreLessons, ...professionalLessons]; + assert.equal(lessons.length, 34); + let checked = 0; + for (const lesson of lessons) { + assert.ok(lesson.sources.length > 0, `Missing sources for ${lesson.id}`); + for (const source of lesson.sources) { + assert.equal(externalReferenceUrl(source.url), new URL(source.url).href, `${lesson.id}: ${source.url}`); + checked++; + } + } + assert.equal(checked, 90); +}); + +test('hiring evidence, actual UI links and installation guide downloads remain accessible', async () => { + const uiFiles = await Promise.all(['dist/index.html', 'dist/app.js'].map(text)); + const uiUrls = uiFiles.flatMap((content) => [...content.matchAll(/https:\/\/[^\s'"<>`]+/g)].map((match) => match[0])); + const installUrls = [...(await text('docs/installation.md')).matchAll(/\]\((https:\/\/[^\s)]+)\)/g)].map((match) => match[1]); + assert.ok(uiUrls.some((url) => url.startsWith('https://github.com/jbsim999/java-edu/releases'))); + assert.ok(installUrls.some((url) => new URL(url).hostname === 'adoptium.net')); + assert.ok(installUrls.some((url) => new URL(url).hostname === 'maven.apache.org')); + assert.equal(jobEvidence.length, 5); + for (const url of [...jobEvidence.map((entry) => entry.url), ...uiUrls, ...installUrls]) { + assert.equal(externalReferenceUrl(url), new URL(url).href, url); + } +}); + +test('external links reject protocols, credentials, ports and host confusion', () => { + for (const value of [ + undefined, null, {}, '', 'not a URL', + 'http://docs.junit.org/5.11.4/user-guide/', + 'javascript:alert(1)', 'file:///C:/Windows/System32/cmd.exe', 'data:text/html,hello', + 'mailto:help@docs.junit.org', '//docs.junit.org/', + 'https://attacker.invalid/', 'https://docs.junit.org.attacker.invalid/', + 'https://subdomain.docs.junit.org/', 'https://docs.junit.org./', + 'https://docs.junit.org@attacker.invalid/', 'https://user:secret@docs.junit.org/', + 'https://@docs.junit.org/', 'https://docs.junit.org:443/', 'https://docs.junit.org:8443/', + 'https://docs.junit.org:/', 'https://%64ocs.junit.org/', + 'https://docs.junit.org\\@attacker.invalid/', 'https:\\docs.junit.org/', + ' https://docs.junit.org/', 'https://docs.ju\tnit.org/', 'https://docs.junit.org/\n', + 'https://127.0.0.1/', 'https://[::1]/', + ]) assert.equal(externalReferenceUrl(value), null, String(value)); +}); + +test('approved reference links preserve document paths, query strings and fragments', () => { + for (const url of [ + 'https://docs.hibernate.org/orm/6.6/userguide/html_single/#locking-optimistic', + 'https://adoptium.net/temurin/releases/?version=21', + 'https://github.com/jbsim999/java-edu/releases', + ]) assert.equal(externalReferenceUrl(url), url); +}); diff --git a/tests/professional.test.mjs b/tests/professional.test.mjs new file mode 100644 index 0000000..48b9edd --- /dev/null +++ b/tests/professional.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { unzipSync, strFromU8 } from 'fflate'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const course = path.join(root, 'curriculum/professional'); +const catalog = JSON.parse(await readFile(path.join(course, 'catalog.json'), 'utf8')); +const profiles = JSON.parse(await readFile(path.join(course, 'profiles.json'), 'utf8')).profiles; + +function safeRelative(value) { + assert.equal(typeof value, 'string'); + assert.ok(value && !path.isAbsolute(value) && !value.includes('\\')); + assert.ok(value.split('/').every(segment => segment && segment !== '.' && segment !== '..'), `safe relative path: ${value}`); + return value; +} + +test('Published course contains 12 core and 22 distinct professional lessons with resolvable learning contracts', () => { + assert.equal(coreLessons.length, 12); + assert.equal(professionalLessons.length, 22); + assert.equal(professionalExercises.length, 22); + assert.equal(catalog.lessons.length, 22); + const allLessons = [...coreLessons, ...professionalLessons]; + assert.equal(new Set(allLessons.map(lesson => lesson.id)).size, 34); + assert.equal(new Set(allLessons.map(lesson => lesson.exerciseId)).size, 34); + const questionIds = []; + for (const lesson of professionalLessons) { + const authored = catalog.lessons.find(item => item.id === lesson.id); + const exercise = professionalExercises.find(item => item.id === lesson.exerciseId); + const profile = profiles.find(item => item.id === exercise?.profileId); + assert.ok(authored && exercise && profile, lesson.id); + assert.equal(exercise.lessonId, lesson.id); + assert.equal(lesson.minVersion, profile.compilerTarget); + assert.equal(exercise.minVersion, lesson.minVersion); + assert.ok(lesson.minVersion >= 17 && [17, 21].includes(lesson.minVersion)); + assert.equal(lesson.preview, false); + assert.equal(exercise.mode, 'project'); + assert.equal(exercise.requiresDesktop, true); + assert.ok(lesson.theory.length >= 2 && lesson.goals.length >= 2 && lesson.quiz.length >= 2); + assert.ok(lesson.example.files.length >= 2 && lesson.practice.tasks.length >= 2); + assert.ok(lesson.sources.length > 0 && lesson.sources.every(source => new URL(source.url).protocol === 'https:' && source.title && source.checkedAt)); + for (const question of lesson.quiz) { + questionIds.push(question.id); + assert.equal(question.options.length, 4); + assert.equal(new Set(question.options).size, 4); + assert.ok(Number.isInteger(question.correctIndex) && question.correctIndex >= 0 && question.correctIndex < 4); + assert.ok(question.question && question.explanation); + } + } + assert.equal(new Set(questionIds).size, questionIds.length, 'professional quiz answers cannot collide by id'); +}); + +test('Professional prerequisite graph resolves against the combined course and has no cycles', () => { + const coreIds = new Set(coreLessons.map(lesson => lesson.id)); + const lessons = new Map(professionalLessons.map(lesson => [lesson.id, lesson])); + const visiting = new Set(); + const visited = new Set(); + function visit(id) { + if (visited.has(id)) return; + assert.equal(visiting.has(id), false, `cyclic prerequisite at ${id}`); + visiting.add(id); + for (const prerequisite of lessons.get(id).prerequisites) { + if (prerequisite.startsWith('core:')) assert.ok(coreIds.has(prerequisite.slice(5)), `${id}: ${prerequisite}`); + else { assert.ok(lessons.has(prerequisite), `${id}: ${prerequisite}`); visit(prerequisite); } + } + visiting.delete(id); + visited.add(id); + } + for (const id of lessons.keys()) visit(id); +}); + +test('Published editable files and readonly POM match authored templates and example reference sources', async () => { + for (const lesson of professionalLessons) { + const exercise = professionalExercises.find(item => item.id === lesson.exerciseId); + const contract = lesson.professionalExercise; + assert.equal(new Set(exercise.files.map(file => file.path)).size, exercise.files.length); + assert.ok(exercise.files.some(file => file.editable), lesson.id); + assert.equal(exercise.files.find(file => file.path === 'pom.xml')?.editable, false, `${lesson.id} build file is immutable`); + assert.deepEqual(exercise.files.map(file => file.path).sort(), contract.files.map(file => file.path).sort()); + for (const file of exercise.files) { + safeRelative(file.path); + const authored = contract.files.find(item => item.path === file.path); + assert.equal(file.editable, authored.editable === true); + assert.equal(file.source, await readFile(path.join(course, safeRelative(authored.templatePath)), 'utf8'), `${lesson.id}/${file.path} template`); + const example = lesson.example.files.find(item => item.path === file.path); + assert.ok(example, `${lesson.id}/${file.path} has a visible reference`); + assert.equal(example.code, await readFile(path.join(course, safeRelative(authored.referencePath)), 'utf8'), `${lesson.id}/${file.path} reference`); + } + } +}); + +test('Every downloadable ZIP is readable and contains the exact parent POM, starter, reference, and trusted tests', async () => { + const parentPom = await readFile(path.join(course, 'pom.xml'), 'utf8'); + for (const lesson of professionalLessons) { + const exercise = professionalExercises.find(item => item.id === lesson.exerciseId); + assert.equal(exercise.downloadUrl, `/downloads/${lesson.id}.zip`); + const archive = unzipSync(await readFile(path.join(root, 'dist', exercise.downloadUrl.slice(1)))); + for (const name of Object.keys(archive)) { + safeRelative(name); + assert.doesNotMatch(name, /(?:^|\/)(?:target|node_modules)\//, 'downloads exclude generated directories'); + assert.doesNotMatch(name, /\.(?:exe|dll|cmd|bat|ps1)$/i, 'downloads contain learning source, not executables'); + } + assert.equal(strFromU8(archive['pom.xml']), parentPom); + assert.match(strFromU8(archive['README.txt']), /Maven/); + for (const file of lesson.professionalExercise.files) { + for (const [kind, relative] of [['starter', file.templatePath], ['reference', file.referencePath]]) { + const name = `lessons/${lesson.id}/${kind}/${file.path}`; + assert.ok(archive[name], name); + assert.equal(strFromU8(archive[name]), await readFile(path.join(course, safeRelative(relative)), 'utf8'), name); + } + } + for (const file of lesson.professionalExercise.testFiles) { + const name = `lessons/${lesson.id}/grader/${file.path}`; + assert.ok(archive[name], name); + assert.equal(strFromU8(archive[name]), await readFile(path.join(course, safeRelative(file.sourcePath)), 'utf8'), name); + } + } +}); diff --git a/worker/index.js b/worker/index.js index 18ee8e5..e12e935 100644 --- a/worker/index.js +++ b/worker/index.js @@ -11,9 +11,11 @@ export default { const pathname = url.pathname === '/' ? '/index.html' : url.pathname; const asset = assets[pathname]; if (!asset) return new Response('Not found', { status: 404 }); - return new Response(request.method === 'HEAD' ? null : asset.body, { + const body = asset.encoding === 'base64' ? Uint8Array.from(atob(asset.body), (c) => c.charCodeAt(0)) : asset.body; + return new Response(request.method === 'HEAD' ? null : body, { headers: { 'Content-Type': asset.type, + ...(asset.download ? { 'Content-Disposition': `attachment; filename="${asset.download}"` } : {}), 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', From bb061d15fad4fd2a73d5803bf951a1278ec799ca Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:56:18 +0900 Subject: [PATCH 08/29] =?UTF-8?q?docs:=20GitHub=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=86=8C=EA=B0=9C=EC=99=80=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 164 ++++++++++++++++++++++++++------ docs/assets/java-edu-banner.svg | 31 ++++++ docs/assets/learning-lab.png | Bin 0 -> 126078 bytes 3 files changed, 168 insertions(+), 27 deletions(-) create mode 100644 docs/assets/java-edu-banner.svg create mode 100644 docs/assets/learning-lab.png diff --git a/README.md b/README.md index 74782ca..1eab6ea 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,149 @@ +
+ +Java Edu — 공식 문서로 이해하고, 직접 코딩하며 배우는 Java + # Java Edu -공식 Java 문서 기반으로 다른 언어 경험자의 Java 전환 학습과 현직자의 심화를 연결하는 한국어 교육 서비스입니다. OOP·타입·동시성을 깊게 다루고, 수업 끝에서 Java 코드를 직접 작성·실행·검증합니다. +**공식 문서로 이해하고, 직접 코딩하며 배우는 Java.** + +다른 언어 경험자의 Java 전환 학습부터 현직 개발자의 OOP·백엔드 심화까지. + +[![Java](https://img.shields.io/badge/Java-8%20%7C%2011%20%7C%2017%20%7C%2021-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/java-version-matrix.md) [![Windows](https://img.shields.io/badge/Desktop-Windows%20x64-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) [![Lessons](https://img.shields.io/badge/Lessons-34-806223?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) [![Status](https://img.shields.io/badge/Status-v0.2%20in%20development-806223?style=flat-square)](https://github.com/jbsim999/java-edu/issues) [![CI](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml/badge.svg?branch=feat%2Fjava-learning-mvp)](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml?query=branch%3Afeat%2Fjava-learning-mvp) + +[교육 로드맵](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) · [개발 소스](https://github.com/jbsim999/java-edu/tree/feat/java-learning-mvp) · [기여 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/CONTRIBUTING.md) · [버그·제안](https://github.com/jbsim999/java-edu/issues) + +
+ +## 소개 + +Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교재**를 함께 만드는 한국어 교육 프로젝트입니다. 문법을 넘어 객체의 책임·불변식, 타입과 동등성, 동시성, 트랜잭션을 코드와 행동 검사로 연결합니다. + +수업은 **이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩** 순서로 진행합니다. Java 언어·표준 API는 Oracle/OpenJDK 공식 문서를, 프레임워크·라이브러리는 각 프로젝트의 공식 문서를 기준으로 합니다. 실무 주제에는 출처를 기록한 채용 공고의 기술 요건도 연결합니다. + +> **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. Windows v0.2 설치 파일은 최종 검증 전으로 아직 릴리스하지 않았습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 현재 코어 12수업과 편집기 테마 버전이 배포돼 있습니다. + +## 학습실 미리보기 + +![Java Edu 학습실: 수업 목록, 과제 설명, Java 문법 색상과 직접 코딩 편집기](docs/assets/learning-lab.png) + +*개발 중인 공용 학습실의 실제 로컬 웹 화면입니다. 화면 캡처를 실행·제출 성공 근거로 사용하지 않습니다.* + +## 주요 기능 + +| 기능 | 학습 경험 | +| --- | --- | +| 공식 문서 기반 과정 | Java 8·11·17·21의 언어·API 차이와 공식 출처 확인 | +| 깊이 있는 OOP | 객체의 책임·불변식, 다형성·합성, 동등성·변경 계약 탐구 | +| 직접 코딩 | 클래스 골격, 메서드 본문, 다중 파일 프로젝트에 코드 작성 | +| 읽기 쉬운 편집기 | 클래스·타입·변수·메서드 문법 색상, 다크·라이트·고대비 테마 | +| 로컬 Java 실행 | Windows 앱에서 등록한 JDK로 컴파일·실행·제출·취소 | +| 실무 프로젝트 | Spring/H2·REST·MyBatis·JPA·보안 과제를 고정 테스트로 확인 | +| 이어서 학습 | 진도·메모·코드 초안·테마 저장 및 앱 재시작 후 복원 | + +## 교육 과정 + +**Java 코어 12수업 + 실무 22수업 · 개념 문제 68문항 · 코딩 과제 34개** + +| 과정 | 주요 주제 | +| --- | --- | +| Java 사고 모델 | 값 전달·객체 동등성, OOP·불변식·다형성·합성 | +| 언어와 표준 API | 컬렉션·제네릭·예외·Stream·날짜/시간, 동시성, 최신 Java | +| 빌드와 테스트 | Maven, JUnit, AssertJ, Mockito, Lombok | +| Spring과 HTTP | DI·AOP, MVC·REST, JSON·입력 검증 | +| 데이터와 일관성 | JDBC·MyBatis·JPA, Flyway, 트랜잭션·동시 수정 | +| 보안과 운영 | CSRF, OAuth2/OIDC의 인증 주체, JWT 검증, 관측·빌드/배포 비교 | +| 선택 과정과 캡스톤 | Redis 캐시, Kafka 멱등성, 주문 API 통합 프로젝트 | + +기본 실무 20프로젝트는 H2·로컬 테스트를 사용합니다. Redis/Kafka 두 단원은 별도 선택 환경이며 실제 서비스 연동은 아직 검증하지 않았습니다. OIDC 수업은 인증 주체 처리에 집중하고 전체 로그인 프로토콜 구현은 다루지 않습니다. [전체 로드맵](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) · [실무 과정 상세](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/professional-curriculum.md) + +## 빠르게 시작하기 + +현재는 개발 소스에서 실행할 수 있습니다. **Node.js 22 이상**이 필요하며 Windows 앱의 Java 실습에는 **JDK**, Spring 실습에는 **Maven 3.9 이상**도 준비합니다. + +```sh +git clone --branch feat/java-learning-mvp https://github.com/jbsim999/java-edu.git +cd java-edu +npm ci +``` + +**웹 교재 실행** + +```sh +npm run dev +``` + +브라우저에서 `http://127.0.0.1:4173`을 엽니다. 실무 코드는 웹에서 편집하고 프로젝트 ZIP을 내려받을 수 있습니다. Spring 프로젝트 실행은 Windows 앱 또는 로컬 IDE에서 진행합니다. + +**Windows 학습 앱 실행** + +```sh +npm run desktop +``` + +앱의 실행 설정에서 JDK 폴더와 Maven 폴더를 선택하고 코드 실행에 동의합니다. 실무 과제는 **환경 준비 → 파일 편집 → 실행·제출** 순서로 진행합니다. [Windows 사용 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) + +
+Windows 설치 파일 직접 빌드 + +```sh +npm run build:desktop +``` + +`desktop-release/`에 Windows x64 NSIS 설치 파일을 생성합니다. 현재 빌드는 코드 서명되지 않았으며 설치·제거 및 설치된 앱의 최종 검증이 남아 있습니다. 공개 설치 파일은 검증 후 [Releases](https://github.com/jbsim999/java-edu/releases)에 게시할 예정입니다. + +
+ +### 실행 방식과 버전 + +| 구분 | Windows 앱 | 웹 교재 | +| --- | --- | --- | +| Java 코어 | 등록한 실제 JDK 8·11·17·21 사용 | Wandbox의 JVM 21, 과제별 최소 지원 버전으로 컴파일 | +| Spring 프로젝트 | 컴파일 대상 17, 실제 JDK 17/21·Maven 사용 | 교재·파일 편집·ZIP 다운로드 | +| 코드 전송 | 작성 코드를 Wandbox로 전송하지 않음 | 코어 실행·제출 시 Wandbox로 전송 | +| 학습 기록 | 앱 데이터에 저장 | 해당 브라우저에 저장 | + +웹과 앱 간 자동 동기화는 제공하지 않습니다. 실무 환경 준비에는 의존성 다운로드가 필요합니다. [로컬 실행 계약](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/local-execution.md) + +## 개발과 검증 + +| 명령 | 목적 | +| --- | --- | +| `npm run check` | 콘텐츠·API·구문 정합성 검사 | +| `npm run test:java` | 설치된 JDK의 Java 예제·답안 검사 | +| `npm run test:local` | 로컬 실행·Windows 프로세스 종료 계약 검사 | +| `npm run test:professional` | JDK/Maven에서 실무 기준 구현과 미완성 코드 비교 | +| `npm run test:browser` | 실행 중인 로컬 서버의 모의 API 브라우저 검사 | +| `npm run build` / `npm run test:worker` | 웹 Worker 빌드·산출물 검사 | -현재 구성: **Windows 설치형 + 웹 교재 v0.2**. Java 코어12수업과 실무22수업, 총68문항과34코딩 과제를 연결합니다. Spring·Lombok·MyBatis·JPA·트랜잭션·Security·OAuth2/OIDC·JWT·Redis·Kafka·관측·빌드/운영 과정을 포함합니다. 기본20개 실무 프로젝트는 별도 DB 서버 없이 실행하며 Redis/Kafka 두 단원은 선택 환경입니다. 설치형과34단원 웹의 최종 출시 검증을 진행 중입니다. +실무 검사는 `JAVA_HOME`·`MAVEN_HOME` 설정이 필요합니다. 기본 검사는 Redis/Kafka를 제외합니다. 브라우저 검사는 실행 제공자의 실제 동작 검증과 구분합니다. -- [기획안](docs/product-plan.md) -- [교육 로드맵](docs/education-roadmap.md) -- [마일스톤](docs/milestones.md) -- [실행 아키텍처](docs/architecture.md) -- [실행 API 계약과 제한](docs/execution-contract.md) -- [공식 Java 호환 기준](docs/java-version-matrix.md) -- [채용 근거](docs/job-evidence.md) -- [구현된 코어 과정](docs/curriculum.md) -- [실무22단원](docs/professional-curriculum.md) -- [Windows 설치와 사용](docs/installation.md) -- [로컬 실행 계약](docs/local-execution.md) -- [협업 규칙](CONTRIBUTING.md) -- [실제 검증과 미확인 범위](docs/verification.md) -- [배포 소스·버전 기록](docs/deployment.md) -- [독립 브라우저 QA](docs/qa-report.md) -- [문법 강조·테마 QA](docs/editor-qa-report.md) -- [Windows 앱·실무 통합 QA](docs/desktop-qa-report.md) +2026-10-12 기준, 로컬에서 기본 20실무프로젝트의 기준 검사 **54개**와 개발 앱 **9개 흐름**을 확인했습니다. 최신 CI는 Windows 로컬 실행 작업의 실패가 남아 있으며 설치 패키지와 최종 시각 검수도 미완료입니다. CI 배지는 개발 브랜치의 실제 결과를 표시합니다. [검증 기록](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/verification.md) · [앱 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/desktop-qa-report.md) -수업은 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩의 5단계입니다. 객관식 성취·코드 행동 검증·설계 평가를 구분합니다. +## 문서와 로드맵 -교재는 Java8·11·17·21 기준입니다. 설치형은 등록한 실제 JDK로 코드를 실행하며 작성 코드를 Wandbox로 전송하지 않습니다. 현대 Spring 프로젝트의 컴파일 대상은17이고 실행 JDK는17/21입니다. 웹 Java 코어 실행은 Wandbox의 실제 JVM21을 사용하며8·11·17 컴파일 대상과 해당 JVM 실행을 혼동하지 않습니다. 웹 실행·제출 버튼을 누를 때 코드가 Wandbox로 전송되며 운영 로그에 남을 수 있습니다. +| 찾는 내용 | 문서 | +| --- | --- | +| 서비스의 목표와 학습 대상 | [기획안](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/product-plan.md) | +| 학습 순서와 Java 버전 | [교육 로드맵](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) · [호환 기준](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/java-version-matrix.md) | +| 코어·실무 교재 | [Java 코어](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/curriculum.md) · [실무 22단원](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/professional-curriculum.md) | +| 채용 공고 조사 근거 | [출처·표본·조사 범위](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/job-evidence.md) | +| 구조와 실행 경계 | [아키텍처](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/architecture.md) | +| 현재 작업·출시 상태 | [마일스톤](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/milestones.md) · [개발 인계](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/development-handoff.md) | -학습자는 Windows 설치 파일과 JDK를 준비하며 실무 프로젝트에는 Maven도 등록합니다. 앱의 진도·코드·메모·테마는 앱 재시작 뒤에도 보존됩니다. 웹과 앱 사이 자동 동기화는 제공하지 않습니다. 편집기는 Java 문법에 따라 클래스·타입·변수·메서드 색상을 구분하고 다크·라이트·고대비 테마, 줄 번호·들여쓰기·괄호 표시를 제공합니다. 의미 분석 기반 IDE나 자동 완성 엔진은 포함하지 않습니다. +- [x] Java 코어 12수업과 직접 코딩 과제 +- [x] Java 문법 강조·3테마·다중 파일 편집 +- [x] Windows 로컬 JDK/Maven 실행과 진도 저장 구현 +- [x] 실무 22수업과 기본 20프로젝트의 기준 검증 +- [ ] Windows CI 실패 해결·설치/제거 검증·최종 시각 검수 +- [ ] Windows v0.2 릴리스와 34수업 웹 배포 +- [ ] 선택 과정의 Redis/Kafka 실제 연동 검증 -개발자는 Node22+로 `npm ci`, `npm run dev`를 실행하면 `http://127.0.0.1:4173`에서 학습실을 사용할 수 있습니다. `npm run desktop`으로 개발 앱, `npm run build:desktop`으로 Windows x64 NSIS 설치 파일을 생성합니다. JDK/Maven은 앱 설정의 폴더 선택으로 등록하므로 시스템 PATH를 변경하지 않습니다. +## 기여와 문의 -`npm run check`는 콘텐츠와 실행 API 검사, `npm run test:java`는 설치된 JDK의 예제·기준 답안 검증입니다. `npm run test:browser`는 서버가 실행 중일 때 모의 API로 기능을 검사합니다. 로컬 Edge는 `PLAYWRIGHT_CHANNEL=msedge`를 지정하고 CI는 Chromium을 사용합니다. 검사 정의와 실제 결과를 구분해 기록합니다. +교육 내용의 오류, 공식 문서와 다른 설명, 실행 문제와 개선 아이디어는 [Issues](https://github.com/jbsim999/java-edu/issues)에 남겨 주세요. 실행 문제는 OS·JDK·Maven 버전, 단원, 재현 순서와 오류 메시지를 포함하면 확인에 도움이 됩니다. -`npm run build`는 정적 UI와 실행 API를 하나의 Worker로 묶습니다. `npm run test:worker`는 빌드 산출물의 경로·CSP·개인 파일 제외·인증 필수 동작을 검사합니다. 네트워크 실행 테스트는 별도 명시적 모드에서만 호출하며 기본 테스트는 외부로 코드를 보내지 않습니다. +기여 전 [CONTRIBUTING](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/CONTRIBUTING.md)을 읽어 주세요. 공식 출처와 버전별 근거를 함께 기록하고 실제 실행한 검사와 미검증 범위를 구분합니다. 유지관리자: [jbsim999](https://github.com/jbsim999). -`npm run test:local`은 로컬 실행·Windows 프로세스 종료 계약, `npm run test:professional`은 JAVA_HOME/MAVEN_HOME에서 기준 프로젝트 통과와 미완성 과제 실패를 검사합니다. Redis/Kafka 두 단원은 기본 검증에서 명시적으로 제외하며 `--compile-only`로 의존성과 컴파일만 따로 확인할 수 있습니다. `tests/desktop.mjs`는 실제 개발 Electron·로컬 JDK/Maven 흐름과 모의 API 웹 검사를 구분합니다. +## 라이선스 -[웹 교재](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)는 현재 소유자 전용 접근을 유지하며 배포된 버전은 코어12수업과 편집기 테마입니다. Windows 설치 파일은 로컬 빌드가 완료됐고, 설치·제거와 최종 시각 검수 후 공개 저장소의 [Releases](https://github.com/jbsim999/java-edu/releases)에 게시할 예정입니다. 새 프로젝트에서 이어갈 상태는 [개발 인계](docs/development-handoff.md)를 먼저 확인합니다. +프로젝트 코드·교재의 라이선스는 아직 지정하지 않았습니다. 편집기에 포함된 외부 패키지의 라이선스 고지는 편집기 빌드에 포함하며 각 패키지의 조건을 따릅니다. diff --git a/docs/assets/java-edu-banner.svg b/docs/assets/java-edu-banner.svg new file mode 100644 index 0000000..17fbc01 --- /dev/null +++ b/docs/assets/java-edu-banner.svg @@ -0,0 +1,31 @@ + + Java Edu + 공식 Java 문서 기반 학습. Java 8, 11, 17, 21. Windows 로컬 실습과 웹 교재. + + + + OFFICIAL DOCS. HANDS-ON JAVA. + Java Edu. + Understand the contract. Write the code. + + + JAVA 8 · 11 · 17 · 21 + + WINDOWS + WEB + + THEORY → EXAMPLES → QUIZ → PRACTICE → CODE + + + + + + + JavaEdu.java + + final class JavaEdu { + String topic = "OOP"; + int[] versions = {8,11,17,21}; + // Read. Reason. Run. + } + + diff --git a/docs/assets/learning-lab.png b/docs/assets/learning-lab.png new file mode 100644 index 0000000000000000000000000000000000000000..244eea96466ca8b810a4768008003625680a9d87 GIT binary patch literal 126078 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

Rv)y9H=~R803F`4uhc${279!!< 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 Date: Mon, 12 Oct 2026 14:56:18 +0900 Subject: [PATCH 09/29] =?UTF-8?q?test:=20Windows=20=EA=B0=90=EB=8F=85=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20CI=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=EC=A7=80=EC=A0=90=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/local/process.test.mjs | 41 +++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/local/process.test.mjs b/tests/local/process.test.mjs index 2d45dd7..8e4a46e 100644 --- a/tests/local/process.test.mjs +++ b/tests/local/process.test.mjs @@ -15,9 +15,45 @@ test('Windows Job supervisor preserves Unicode/metacharacter argv and stops time const root = await mkdtemp(path.join(parent, 'java-edu-local-test-공백 & %-')); t.after(async () => { const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-local-test-')); await rm(resolved, { recursive: true, force: true }); }); const env = processEnvironment(undefined, root); + // Diagnose cold-host failures without adding logging or test hooks to the product helper. + const supervisorSource = await readFile(new URL('../../desktop/job-runner.ps1', import.meta.url), 'utf8'); + const diagnosticHelper = path.join(root, 'diagnostic-helper.ps1'); + let diagnosticSource = supervisorSource; + for (const [needle, stage] of [ + ["$ErrorActionPreference = 'Stop'", 'helper-entered'], + [' $spec = Get-Content', 'reading-spec'], + [' Add-Type -TypeDefinition', 'compiling-supervisor'], + [' $result = [JavaEduJob]::Run', 'supervisor-compiled'], + [' $report = @{', 'supervisor-returned'], + ]) { + assert.ok(diagnosticSource.includes(needle), `Missing PowerShell diagnostic point: ${stage}`); + diagnosticSource = diagnosticSource.replace(needle, `[Console]::Error.WriteLine('Java Edu CI stage: ${stage} ' + [DateTime]::UtcNow.ToString('o'))\n${needle}`); + } + for (const [needle, stage] of [ + [' parent = OpenProcess', 'opening-parent'], + [' job = CreateJobObject', 'creating-job'], + [' Check(CreateProcess', 'creating-child'], + [' if (ResumeThread', 'resuming-child'], + [' var reason = "exited";', 'waiting-child'], + [' uint code; Check(GetExitCodeProcess', 'child-wait-finished'], + ]) { + assert.ok(diagnosticSource.includes(needle), `Missing native diagnostic point: ${stage}`); + diagnosticSource = diagnosticSource.replace(needle, ` Console.Error.WriteLine("Java Edu CI stage: ${stage} " + DateTime.UtcNow.ToString("o"));\n${needle}`); + } + await writeFile(diagnosticHelper, `\ufeff${diagnosticSource}`); const argv = ['공백 포함', 'a&b%PATH%', 'a"quoted"', 'trailing\\', '']; - const echo = await runProcess(process.execPath, [fixedFixture, 'echo', ...argv], { cwd: root, env }); - assert.equal(echo.exitCode, 0, echo.stderr); + const echo = await runProcess(process.execPath, [fixedFixture, 'echo', ...argv], { + cwd: root, env, + spawnImpl: (command, args, options) => { + const fileIndex = args.indexOf('-File'); + assert.ok(fileIndex >= 0, 'Windows supervisor must execute its fixed script.'); + const diagnosticArgs = [...args]; + diagnosticArgs[fileIndex + 1] = diagnosticHelper; + return spawn(command, diagnosticArgs, options); + }, + }); + assert.equal(echo.exitCode, 0, `Node ${process.version}; supervisor echo result: ${JSON.stringify(echo)}`); + t.diagnostic(`Node ${process.version}; supervisor echo completed in ${echo.durationMs}ms`); assert.deepEqual(JSON.parse(echo.stdout), argv); const pidfile = path.join(root, 'child.pid'); const tree = await runProcess(process.execPath, [fixedFixture, 'tree', pidfile], { cwd: root, env, timeoutMs: 1200 }); @@ -38,7 +74,6 @@ test('Windows Job supervisor preserves Unicode/metacharacter argv and stops time for (let retry = 0; retry < 30 && alive; retry++) { await delay(100); try { process.kill(orphanPid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; else throw error; } } assert.equal(alive, false, 'Opened parent-process handle must trigger descendant cleanup after app termination.'); // Instrument only a private test copy to pause exactly after creation and before ResumeThread. - const supervisorSource = await readFile(new URL('../../desktop/job-runner.ps1', import.meta.url), 'utf8'); const createdPidfile = path.join(root, 'suspended.pid'); const escapedPidfile = createdPidfile.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); const instrumented = supervisorSource.replace(/(Check\(CreateProcess\(executable,[^\r\n]+\);\r?\n)/, `$1 System.IO.File.WriteAllText("${escapedPidfile}", pi.pid.ToString()); System.Threading.Thread.Sleep(30000);\n`); From 6fa9abee10190ff0ca8584d2d7f75405208c5002 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:14:53 +0900 Subject: [PATCH 10/29] =?UTF-8?q?fix(desktop):=20Windows=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EB=AA=A8=EB=93=88=EB=A1=9C=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EA=B8=B0=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/job-runner.ps1 | 5 ++++- tests/local/process.test.mjs | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/desktop/job-runner.ps1 b/desktop/job-runner.ps1 index 933cabb..85b6e6d 100644 --- a/desktop/job-runner.ps1 +++ b/desktop/job-runner.ps1 @@ -1,5 +1,6 @@ param([Parameter(Mandatory = $true)][string]$SpecPath) $ErrorActionPreference = 'Stop' +$PSModuleAutoLoadingPreference = 'None' $source = @' using System; using System.Collections.Generic; @@ -97,7 +98,9 @@ public static class JavaEduJob { } '@ try { - $spec = Get-Content -LiteralPath $SpecPath -Raw -Encoding UTF8 | ConvertFrom-Json + # Avoid scanning user/third-party modules on clean Windows hosts. Only the OS utility module is needed. + Import-Module -Name ([System.IO.Path]::Combine($PSHOME, 'Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1')) -ErrorAction Stop + $spec = [System.IO.File]::ReadAllText($SpecPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json Add-Type -TypeDefinition $source -Language CSharp $result = [JavaEduJob]::Run([string]$spec.executable, [string[]]$spec.args, [string]$spec.cwd, [int]$spec.timeoutMs, [int]$spec.memoryMiB, [int]$spec.maxProcesses, [uint32]$spec.parentPid) $report = @{ exitCode = [long]$result[0]; reason = $result[1] } | ConvertTo-Json -Compress diff --git a/tests/local/process.test.mjs b/tests/local/process.test.mjs index 8e4a46e..8850455 100644 --- a/tests/local/process.test.mjs +++ b/tests/local/process.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, realpath, readFile, writeFile, rm } from 'node:fs/promises'; +import { mkdtemp, mkdir, realpath, readFile, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -21,7 +21,8 @@ test('Windows Job supervisor preserves Unicode/metacharacter argv and stops time let diagnosticSource = supervisorSource; for (const [needle, stage] of [ ["$ErrorActionPreference = 'Stop'", 'helper-entered'], - [' $spec = Get-Content', 'reading-spec'], + [' Import-Module -Name', 'loading-fixed-utility-module'], + [' $spec = [System.IO.File]::ReadAllText', 'reading-spec'], [' Add-Type -TypeDefinition', 'compiling-supervisor'], [' $result = [JavaEduJob]::Run', 'supervisor-compiled'], [' $report = @{', 'supervisor-returned'], @@ -29,6 +30,7 @@ test('Windows Job supervisor preserves Unicode/metacharacter argv and stops time assert.ok(diagnosticSource.includes(needle), `Missing PowerShell diagnostic point: ${stage}`); diagnosticSource = diagnosticSource.replace(needle, `[Console]::Error.WriteLine('Java Edu CI stage: ${stage} ' + [DateTime]::UtcNow.ToString('o'))\n${needle}`); } + diagnosticSource = diagnosticSource.replace(" + $_.Exception.Message)", " + $_.Exception.ToString())"); for (const [needle, stage] of [ [' parent = OpenProcess', 'opening-parent'], [' job = CreateJobObject', 'creating-job'], @@ -55,6 +57,14 @@ test('Windows Job supervisor preserves Unicode/metacharacter argv and stops time assert.equal(echo.exitCode, 0, `Node ${process.version}; supervisor echo result: ${JSON.stringify(echo)}`); t.diagnostic(`Node ${process.version}; supervisor echo completed in ${echo.durationMs}ms`); assert.deepEqual(JSON.parse(echo.stdout), argv); + const foreignModules = path.join(root, 'foreign-modules'); + const foreignUtility = path.join(foreignModules, 'Microsoft.PowerShell.Utility'); + await mkdir(foreignUtility, { recursive: true }); + await writeFile(path.join(foreignUtility, 'Microsoft.PowerShell.Utility.psd1'), "@{ RootModule = 'Microsoft.PowerShell.Utility.psm1'; ModuleVersion = '99.0'; FunctionsToExport = @('ConvertFrom-Json', 'Add-Type') }"); + await writeFile(path.join(foreignUtility, 'Microsoft.PowerShell.Utility.psm1'), "function ConvertFrom-Json { throw 'Unexpected third-party module discovery' }; function Add-Type { throw 'Unexpected third-party module discovery' }"); + const fixedModule = await runProcess(process.execPath, [fixedFixture, 'echo', 'fixed-os-module'], { cwd: root, env: { ...env, PSModulePath: foreignModules } }); + assert.equal(fixedModule.exitCode, 0, JSON.stringify(fixedModule)); + assert.deepEqual(JSON.parse(fixedModule.stdout), ['fixed-os-module']); const pidfile = path.join(root, 'child.pid'); const tree = await runProcess(process.execPath, [fixedFixture, 'tree', pidfile], { cwd: root, env, timeoutMs: 1200 }); assert.equal(tree.reason, 'timeout', tree.stderr); From a0d12571dcca5574d19b6ffdc35cd1a1093790d4 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:21:36 +0900 Subject: [PATCH 11/29] =?UTF-8?q?feat(curriculum):=20=EA=B2=80=EC=88=98?= =?UTF-8?q?=ED=95=9C=20=EC=9D=B4=EB=A1=A0=20=EB=8F=84=EC=8B=9D=2012?= =?UTF-8?q?=EA=B0=9C=EC=99=80=20=EA=B3=B5=EC=8B=9D=20=EC=B0=B8=EA=B3=A0?= =?UTF-8?q?=EC=9E=90=EB=A3=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/verify.yml | 3 + README.md | 4 + dist/app.js | 130 ++- dist/data/lesson-visuals.js | 1328 +++++++++++++++++++++++++++++++ dist/styles.css | 58 ++ docs/milestones.md | 1 + docs/theory-visual-qa-report.md | 86 ++ docs/theory-visuals.md | 122 +++ docs/verification.md | 4 + package.json | 3 +- scripts/build-worker.mjs | 2 +- scripts/check-worker.mjs | 4 + tests/theory-visuals.mjs | 339 ++++++++ 13 files changed, 2080 insertions(+), 4 deletions(-) create mode 100644 dist/data/lesson-visuals.js create mode 100644 docs/theory-visual-qa-report.md create mode 100644 docs/theory-visuals.md create mode 100644 tests/theory-visuals.mjs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 6b1647d..c58638e 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -55,6 +55,9 @@ jobs: - run: npm run test:browser env: QA_ARTIFACT_DIR: work/java-edu-qa + - run: npm run test:theory + env: + QA_ARTIFACT_DIR: work/theory-independent-qa professional: name: Professional projects (${{ matrix.java }}) runs-on: ubuntu-latest diff --git a/README.md b/README.md index 1eab6ea..db4de59 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교 | --- | --- | | 공식 문서 기반 과정 | Java 8·11·17·21의 언어·API 차이와 공식 출처 확인 | | 깊이 있는 OOP | 객체의 책임·불변식, 다형성·합성, 동등성·변경 계약 탐구 | +| 검수한 이론 도식 | 참조·상태·호출·보안 조건을 설명하는 12개 도식과 공식 참고자료 | | 직접 코딩 | 클래스 골격, 메서드 본문, 다중 파일 프로젝트에 코드 작성 | | 읽기 쉬운 편집기 | 클래스·타입·변수·메서드 문법 색상, 다크·라이트·고대비 테마 | | 로컬 Java 실행 | Windows 앱에서 등록한 JDK로 컴파일·실행·제출·취소 | @@ -113,6 +114,7 @@ npm run build:desktop | `npm run test:local` | 로컬 실행·Windows 프로세스 종료 계약 검사 | | `npm run test:professional` | JDK/Maven에서 실무 기준 구현과 미완성 코드 비교 | | `npm run test:browser` | 실행 중인 로컬 서버의 모의 API 브라우저 검사 | +| `npm run test:theory` | 이론 도식·원문 보존·키보드·반응형 검사 | | `npm run build` / `npm run test:worker` | 웹 Worker 빌드·산출물 검사 | 실무 검사는 `JAVA_HOME`·`MAVEN_HOME` 설정이 필요합니다. 기본 검사는 Redis/Kafka를 제외합니다. 브라우저 검사는 실행 제공자의 실제 동작 검증과 구분합니다. @@ -126,6 +128,7 @@ npm run build:desktop | 서비스의 목표와 학습 대상 | [기획안](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/product-plan.md) | | 학습 순서와 Java 버전 | [교육 로드맵](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) · [호환 기준](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/java-version-matrix.md) | | 코어·실무 교재 | [Java 코어](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/curriculum.md) · [실무 22단원](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/professional-curriculum.md) | +| 이론 도식의 선정과 검수 | [34수업 평가·12개 채택 근거](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/theory-visuals.md) · [독립 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/theory-visual-qa-report.md) | | 채용 공고 조사 근거 | [출처·표본·조사 범위](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/job-evidence.md) | | 구조와 실행 경계 | [아키텍처](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/architecture.md) | | 현재 작업·출시 상태 | [마일스톤](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/milestones.md) · [개발 인계](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/development-handoff.md) | @@ -134,6 +137,7 @@ npm run build:desktop - [x] Java 문법 강조·3테마·다중 파일 편집 - [x] Windows 로컬 JDK/Maven 실행과 진도 저장 구현 - [x] 실무 22수업과 기본 20프로젝트의 기준 검증 +- [x] 이론 도식 12개와 공식 출처·텍스트 설명 - [ ] Windows CI 실패 해결·설치/제거 검증·최종 시각 검수 - [ ] Windows v0.2 릴리스와 34수업 웹 배포 - [ ] 선택 과정의 Redis/Kafka 실제 연동 검증 diff --git a/dist/app.js b/dist/app.js index 9d68ce2..fcf166d 100644 --- a/dist/app.js +++ b/dist/app.js @@ -17,6 +17,7 @@ const mobileMedia = window.matchMedia('(max-width: 899px)'); let lessons = []; let exercises = []; let jobEvidence = []; +let lessonVisuals = {}; let saveTimer; let toastTimer; let runtimeSequence = 0; @@ -552,6 +553,124 @@ function sectionTitle(text, number) { return heading; } +function validLessonVisual(value) { + if (!value || !['flow', 'comparison', 'state', 'relationship'].includes(value.kind)) return null; + if (!['id', 'title', 'caption', 'altText', 'readingOrder'].every((key) => typeof value[key] === 'string' && value[key].trim())) return null; + if (!Array.isArray(value.panels) || !value.panels.length || value.panels.length > 6) return null; + for (const panel of value.panels) { + if (!panel || typeof panel.title !== 'string' || !Array.isArray(panel.nodes) || !panel.nodes.length || panel.nodes.length > 12 || !Array.isArray(panel.edges) || panel.edges.length > 30) return null; + if (!panel.nodes.every((node) => node && typeof node.id === 'string' && node.id && typeof node.label === 'string' && node.label.trim() && typeof node.detail === 'string')) return null; + const ids = new Set(panel.nodes.map((node) => node.id)); + if (ids.size !== panel.nodes.length || !panel.edges.every((edge) => edge && ids.has(edge.from) && ids.has(edge.to) && typeof edge.label === 'string' && edge.label.trim())) return null; + } + if (!Array.isArray(value.sources) || !value.sources.length || !value.sources.every((source) => { + if (!source || typeof source.title !== 'string' || !source.title.trim()) return false; + try { return ['http:', 'https:'].includes(new URL(source.url).protocol); } catch { return false; } + })) return null; + return value; +} + +function visualNode(node) { + const tone = ['accent', 'warning'].includes(node.tone) ? node.tone : 'neutral'; + const card = el('div', `visual-node is-${tone}`); + card.dataset.nodeId = node.id; + card.append(el('strong', 'visual-node-label', node.label)); + if (node.detail) card.append(el('p', 'visual-node-detail', node.detail)); + return card; +} + +function visualConnection(edge, nodes, standalone = false) { + const from = nodes.find((node) => node.id === edge.from); + const to = nodes.find((node) => node.id === edge.to); + const connection = el('div', standalone ? 'visual-relationship' : 'visual-transition'); + connection.dataset.from = edge.from; + connection.dataset.to = edge.to; + if (standalone) connection.append(el('span', 'visual-endpoint', from.label)); + const arrow = el('span', 'visual-arrow'); + arrow.append(icon('arrow')); + const label = el('span', 'visual-edge-label'); + label.append(el('span', 'sr-only', `${from.label}에서 ${to.label}로: `), document.createTextNode(edge.label)); + arrow.append(label); + connection.append(arrow); + if (standalone) connection.append(el('span', 'visual-endpoint', to.label)); + return connection; +} + +function renderTheoryVisual(lesson, visual) { + const figure = el('figure', 'theory-visual'); + const prefix = `theory-visual-${lessons.indexOf(lesson)}`; + figure.dataset.visualId = visual.id; + figure.dataset.visualKind = visual.kind; + figure.setAttribute('aria-labelledby', `${prefix}-title`); + figure.setAttribute('aria-describedby', `${prefix}-caption`); + const caption = el('figcaption', 'visual-caption'); + const kinds = { flow: '실행 순서', comparison: '나란히 비교하기', state: '상태와 전이', relationship: '요소 사이의 관계' }; + const label = el('p', 'visual-kicker', kinds[visual.kind]); + const title = el('h3', 'visual-title', visual.title); title.id = `${prefix}-title`; + const description = el('p', 'visual-description', visual.caption); description.id = `${prefix}-caption`; + caption.append(label, title, description); + figure.append(caption); + const order = el('p', 'visual-reading-order'); + order.append(el('strong', '', '읽는 순서'), document.createTextNode(` ${visual.readingOrder}`)); + figure.append(order); + const panels = el('div', 'visual-panels'); + visual.panels.forEach((panel, panelIndex) => { + const section = el('section', 'visual-panel'); + const heading = el('h4', 'visual-panel-title', panel.title); heading.id = `${prefix}-panel-${panelIndex}`; + section.setAttribute('aria-labelledby', heading.id); + section.append(heading); + const nodes = el(visual.kind === 'flow' || visual.kind === 'state' ? 'ol' : 'ul', visual.kind === 'relationship' ? 'visual-nodes is-catalog' : 'visual-nodes'); + const used = new Set(); + panel.nodes.forEach((node, index) => { + const row = el('li', 'visual-node-row'); + row.append(visualNode(node)); + if (visual.kind !== 'relationship' && index < panel.nodes.length - 1) { + const edge = panel.edges.find((item) => item.from === node.id && item.to === panel.nodes[index + 1].id); + if (edge) { row.append(visualConnection(edge, panel.nodes)); used.add(edge); } + } + nodes.append(row); + }); + section.append(nodes); + const additional = panel.edges.filter((edge) => !used.has(edge)); + if (additional.length) { + const connections = el('div', 'visual-connections'); + connections.append(el('p', 'visual-connections-label', visual.kind === 'relationship' ? '연결 관계 · 화살표의 양 끝과 라벨을 함께 읽으세요' : '함께 볼 연결 관계')); + for (const edge of additional) connections.append(visualConnection(edge, panel.nodes, true)); + section.append(connections); + } + panels.append(section); + }); + figure.append(panels); + if (typeof visual.takeaway === 'string' && visual.takeaway) { + const takeaway = el('p', 'visual-takeaway'); + takeaway.append(el('strong', '', '핵심 판단'), document.createTextNode(` ${visual.takeaway}`)); + figure.append(takeaway); + } + const details = el('details', 'visual-text-description'); + details.append(el('summary', '', '도식의 텍스트 설명과 범위')); + const alternative = el('p', 'visual-alt-text', visual.altText); alternative.id = `${prefix}-description`; + details.append(alternative); + if (Array.isArray(visual.limits) && visual.limits.length) { + const limits = el('ul', 'visual-limits'); + visual.limits.filter((item) => typeof item === 'string').forEach((item) => limits.append(el('li', '', item))); + details.append(limits); + } + figure.append(details); + const sources = el('div', 'visual-sources'); + sources.append(el('p', 'visual-sources-label', '공식 문서에서 확인')); + const sourceList = el('ul'); + for (const source of visual.sources) { + const item = el('li'); + const link = safeLink(source.title, source.url, 'visual-source-link'); link.append(icon('external')); + const facts = [source.edition, source.checkedAt ? `확인 ${source.checkedAt}` : ''].filter(Boolean); + item.append(link); + if (facts.length) item.append(el('span', 'visual-source-meta', facts.join(' · '))); + sourceList.append(item); + } + sources.append(sourceList); figure.append(sources); + return figure; +} + function renderTheory(lesson) { const goals = el('div', 'learning-goals'); goals.append(el('p', 'small-label', 'AFTER THIS LESSON')); @@ -559,11 +678,16 @@ function renderTheory(lesson) { (lesson.goals || []).forEach((goal) => { const item = el('li'); item.append(icon('check'), el('span', '', goal)); list.append(item); }); goals.append(list); dom['lesson-panel'].append(goals); - (lesson.theory || []).forEach((part, index) => { + const parts = lesson.theory || []; + const visual = validLessonVisual(lessonVisuals[lesson.id]); + const afterSection = visual && Number.isInteger(visual.afterSection) ? Math.min(Math.max(visual.afterSection, 0), Math.max(parts.length - 1, 0)) : 0; + parts.forEach((part, index) => { const section = el('section', 'theory-section'); section.append(sectionTitle(part.heading, String(index + 1).padStart(2, '0')), el('p', '', part.text)); dom['lesson-panel'].append(section); + if (visual && index === afterSection) dom['lesson-panel'].append(renderTheoryVisual(lesson, visual)); }); + if (visual && !parts.length) dom['lesson-panel'].append(renderTheoryVisual(lesson, visual)); dom['lesson-panel'].append(nextStepButton('example', '예제로 개념 확인하기')); } @@ -1649,16 +1773,18 @@ function bindEvents() { async function boot() { try { - const [curriculumModule, exerciseModule, evidenceModule, editorModule, professionalModule] = await Promise.all([ + const [curriculumModule, exerciseModule, evidenceModule, editorModule, professionalModule, visualModule] = await Promise.all([ import('./data/curriculum.js'), import('./data/exercises.js'), import('./data/job-evidence.js'), import('./assets/editor.bundle.js').catch(() => null), import('./data/professional.js'), + import('./data/lesson-visuals.js').catch(() => null), ]); if (!Array.isArray(professionalModule.professionalLessons) || !Array.isArray(professionalModule.professionalExercises)) throw new Error('Professional content data unavailable'); lessons = [...curriculumModule.lessons, ...professionalModule.professionalLessons]; exercises = [...exerciseModule.exercises, ...professionalModule.professionalExercises]; jobEvidence = evidenceModule.jobEvidence; editorLibrary = editorModule; + lessonVisuals = visualModule?.lessonVisuals && typeof visualModule.lessonVisuals === 'object' && !Array.isArray(visualModule.lessonVisuals) ? visualModule.lessonVisuals : {}; if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(exercises) || !Array.isArray(jobEvidence)) throw new Error('Content data unavailable'); restoreStorage(); restoreEditorPreferences(); diff --git a/dist/data/lesson-visuals.js b/dist/data/lesson-visuals.js new file mode 100644 index 0000000..cf144b3 --- /dev/null +++ b/dist/data/lesson-visuals.js @@ -0,0 +1,1328 @@ +// Teaching diagrams are semantic data. Labels and descriptions are plain text. +// Their edges describe the relation stated by each caption, not an implicit universal execution order. +export const lessonVisuals = { + "java-basics": { + "id": "viz-java-basics-reference-copy", + "title": "복사되는 참조값과 바뀌는 객체", + "kind": "comparison", + "afterSection": 0, + "caption": "update(box) 안의 두 시점을 비교합니다. 변수 카드와 객체 카드는 서로 다른 대상을 나타냅니다.", + "altText": "호출 직후에는 호출자의 box와 매개변수 box가 같은 Box A를 가리킵니다. 필드 변경 뒤 A의 value는 15가 됩니다. 매개변수를 새 Box B로 재할당해도 호출자의 box는 A를 가리킵니다.", + "readingOrder": "첫 패널에서 두 변수의 참조 대상을 확인한 다음, 두 번째 패널에서 필드 변경과 매개변수 재할당의 결과를 따로 읽으세요.", + "panels": [ + { + "id": "copy", + "title": "1. 메서드 진입 직후", + "nodes": [ + { + "id": "caller", + "label": "호출자 변수 box", + "detail": "기존 참조값을 유지합니다.", + "tone": "neutral" + }, + { + "id": "parameter", + "label": "매개변수 box", + "detail": "호출자의 참조값을 복사받습니다.", + "tone": "neutral" + }, + { + "id": "original", + "label": "기존 객체 A · value = 5", + "detail": "두 변수로 같은 객체에 접근합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "caller", + "to": "original", + "label": "참조값이 A를 가리킴" + }, + { + "from": "parameter", + "to": "original", + "label": "복사된 참조값도 A를 가리킴" + } + ] + }, + { + "id": "reassigned", + "title": "2. update의 마지막 대입 직후", + "nodes": [ + { + "id": "caller", + "label": "호출자 변수 box", + "detail": "재할당되지 않았습니다.", + "tone": "neutral" + }, + { + "id": "original", + "label": "기존 객체 A · value = 15", + "detail": "box.value += 10이 이 객체를 바꿨습니다.", + "tone": "accent" + }, + { + "id": "parameter", + "label": "매개변수 box", + "detail": "box = new Box(99)로 이 변수만 바뀝니다.", + "tone": "neutral" + }, + { + "id": "replacement", + "label": "새 객체 B · value = 99", + "detail": "호출자의 box가 가리키는 대상은 아닙니다.", + "tone": "neutral" + } + ], + "edges": [ + { + "from": "caller", + "to": "original", + "label": "여전히 A를 가리킴" + }, + { + "from": "parameter", + "to": "replacement", + "label": "새 참조값은 B를 가리킴" + } + ] + } + ], + "takeaway": "객체를 변경하는 일과 참조 변수에 다른 값을 대입하는 일을 분리해서 추적하세요.", + "limits": [ + "객체 A·B는 설명용 이름이며 실제 메모리 주소나 JVM의 물리적 배치를 나타내지 않습니다.", + "두 번째 패널은 메서드가 반환되기 직전입니다. 자원 수명·GC·동시성은 이 그림의 범위가 아닙니다." + ], + "sources": [ + { + "title": "Oracle Tutorial — Passing Reference Data Type Arguments", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/arguments.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "oop": { + "id": "viz-oop-invariant", + "title": "실패 뒤에도 지켜야 하는 잔액", + "kind": "state", + "afterSection": 1, + "caption": "Credit 예제의 spend(40), spend(70)을 순서대로 적용합니다. 잔액 0 이상이라는 업무 규칙을 상태 변화로 확인합니다.", + "altText": "잔액 100에서 40 차감은 검증을 통과해 60이 됩니다. 이어 70 차감은 잔액을 초과하므로 false를 반환하고 60을 유지합니다.", + "readingOrder": "첫 패널의 성공 후 잔액 60을 두 번째 패널의 시작 상태로 이어 읽으세요.", + "panels": [ + { + "id": "accepted", + "title": "1. spend(40) 성공", + "nodes": [ + { + "id": "before", + "label": "잔액 100", + "detail": "생성자가 유효한 초기 상태를 만들었습니다.", + "tone": "neutral" + }, + { + "id": "check", + "label": "0 < 40 ≤ 100", + "detail": "변경하기 전에 요청을 검사합니다.", + "tone": "neutral" + }, + { + "id": "after", + "label": "잔액 60 · true", + "detail": "검증을 통과한 뒤에만 차감합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "before", + "to": "check", + "label": "40 차감 요청" + }, + { + "from": "check", + "to": "after", + "label": "조건 충족 → 차감" + } + ] + }, + { + "id": "rejected", + "title": "2. spend(70) 거절", + "nodes": [ + { + "id": "before", + "label": "잔액 60", + "detail": "앞선 성공 결과가 다음 호출의 시작입니다.", + "tone": "neutral" + }, + { + "id": "check", + "label": "70 > 60", + "detail": "요청이 잔액보다 큽니다.", + "tone": "warning" + }, + { + "id": "after", + "label": "잔액 60 · false", + "detail": "실패를 알리고 기존 상태를 보존합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "before", + "to": "check", + "label": "70 차감 요청" + }, + { + "from": "check", + "to": "after", + "label": "조건 불충족 → 변경 없음" + } + ] + } + ], + "takeaway": "반환값뿐 아니라 실패 후 객체가 어떤 상태인지도 메서드 계약에 포함하세요.", + "limits": [ + "검증 순서와 잔액 불변식은 이 예제의 설계입니다. Java나 private 키워드가 업무 규칙을 자동으로 보장하지 않습니다.", + "단일 스레드의 객체 상태를 설명하며 동시 호출·여러 객체·DB 트랜잭션의 원자성을 뜻하지 않습니다." + ], + "sources": [ + { + "title": "Oracle Tutorial — Classes", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/classes.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JLS — Access Control", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-6.html#jls-6.6", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "polymorphism": { + "id": "viz-polymorphism-composition", + "title": "Report는 그대로, 협력 구현은 교체", + "kind": "relationship", + "afterSection": 2, + "caption": "main에서 서로 다른 Formatter를 주입합니다. Report는 선택된 구현에 같은 format 요청을 보냅니다.", + "altText": "대괄호 Formatter를 가진 Report는 stock을 [stock]으로, 접두사 Formatter를 가진 Report는 report:stock으로 만듭니다. 두 Report의 render 코드는 같고 협력 구현이 다릅니다.", + "readingOrder": "각 패널에서 Report의 요청, 주입한 Formatter, 반환 결과를 읽은 뒤 두 패널의 바뀐 부분만 비교하세요.", + "panels": [ + { + "id": "bracketed", + "title": "같은 Formatter 계약 · 대괄호 구현", + "nodes": [ + { + "id": "report", + "label": "Report.render(\"stock\")", + "detail": "Report는 Formatter 타입의 필드에 요청합니다.", + "tone": "neutral" + }, + { + "id": "formatter", + "label": "주입한 Formatter", + "detail": "value를 대괄호로 감싸는 람다입니다.", + "tone": "accent" + }, + { + "id": "result", + "label": "반환값: [stock]", + "detail": "선택한 협력 객체의 결과를 반환합니다.", + "tone": "neutral" + } + ], + "edges": [ + { + "from": "report", + "to": "formatter", + "label": "formatter.format(value)로 위임" + }, + { + "from": "formatter", + "to": "result", + "label": "대괄호 구현의 결과" + } + ] + }, + { + "id": "prefixed", + "title": "같은 Formatter 계약 · 접두사 구현", + "nodes": [ + { + "id": "report", + "label": "Report.render(\"stock\")", + "detail": "Report의 render 구현은 같습니다.", + "tone": "neutral" + }, + { + "id": "formatter", + "label": "주입한 Formatter", + "detail": "value 앞에 report:를 붙이는 람다입니다.", + "tone": "accent" + }, + { + "id": "result", + "label": "반환값: report:stock", + "detail": "다른 협력 구현이 다른 결과를 만듭니다.", + "tone": "neutral" + } + ], + "edges": [ + { + "from": "report", + "to": "formatter", + "label": "같은 메서드 계약으로 위임" + }, + { + "from": "formatter", + "to": "result", + "label": "접두사 구현의 결과" + } + ] + } + ], + "takeaway": "구현을 선택하는 곳과 사용하는 곳을 분리하되, 교체할 구현의 입력·결과·실패 계약도 확인하세요.", + "limits": [ + "객체 간 위임 관계이며 클래스 상속도나 Spring 컨테이너 구조가 아닙니다.", + "인터페이스 문법만으로 LSP나 업무상 교체 가능성이 검증되는 것은 아닙니다." + ], + "sources": [ + { + "title": "Oracle Tutorial — Interfaces", + "url": "https://docs.oracle.com/javase/tutorial/java/IandI/createinterface.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Oracle Tutorial — Lambda Expressions", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/lambdaexpressions.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "collections": { + "id": "viz-collections-equality-hash", + "title": "같으면 같은 해시, 같은 해시여도 다를 수 있음", + "kind": "comparison", + "afterSection": 1, + "caption": "equals와 hashCode의 관계는 한 방향의 요구입니다. 반대 방향을 가정하면 해시 충돌을 동등성으로 오해합니다.", + "altText": "equals가 true인 두 객체는 같은 hashCode를 반환해야 합니다. 반대로 문자열 Aa와 BB는 hashCode가 모두 2112여도 equals는 false입니다.", + "readingOrder": "첫 패널의 반드시 따라야 하는 계약을 읽고, 두 번째 패널의 반례로 역방향 추론을 확인하세요.", + "panels": [ + { + "id": "required", + "title": "equals로 같은 객체 → 같은 해시값 필요", + "nodes": [ + { + "id": "equal", + "label": "a.equals(b) == true", + "detail": "예: 별도로 만든 두 String(\"java\") 객체", + "tone": "neutral" + }, + { + "id": "hash", + "label": "a.hashCode() == b.hashCode()", + "detail": "equals로 같다면 만족해야 하는 계약입니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "equal", + "to": "hash", + "label": "equals가 같으면 반드시 같은 해시" + } + ] + }, + { + "id": "collision", + "title": "같은 해시값 → equals 결과는 미정", + "nodes": [ + { + "id": "hash", + "label": "\"Aa\"와 \"BB\"의 해시 = 2112", + "detail": "String의 명세상 해시 계산에서 같은 값이 나옵니다.", + "tone": "neutral" + }, + { + "id": "different", + "label": "\"Aa\".equals(\"BB\") == false", + "detail": "내용이 다른데 해시는 같은 충돌 사례입니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "hash", + "to": "different", + "label": "같은 해시가 같은 내용을 보장하지 않음" + } + ] + } + ], + "takeaway": "해시값만으로 동등성을 결정하지 말고, equals와 hashCode를 함께 같은 업무 의미로 설계하세요.", + "limits": [ + "화살표는 논리 관계를 나타내며 HashMap 내부의 조회 단계나 버킷 구조를 보여 주지 않습니다.", + "키 필드의 변경·순회 순서·성능은 별도 계약입니다. 예시의 숫자는 String API의 해시 계산에만 해당합니다." + ], + "sources": [ + { + "title": "Java API — Object.hashCode and equals", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#hashCode--", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java API — String.hashCode", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/String.html#hashCode--", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "generics": { + "id": "viz-generics-pecs", + "title": "타입을 모를 때 무엇을 보장할 수 있는가", + "kind": "comparison", + "afterSection": 1, + "caption": "extends와 super는 실제 요소 타입을 모두 안다는 뜻이 아닙니다. 읽기와 쓰기에서 안전하게 약속할 수 있는 범위를 비교합니다.", + "altText": "List는 요소를 Number로 읽을 수 있지만 특정 non-null Integer를 추가할 수는 없습니다. List는 Integer를 추가할 수 있으나 읽은 값의 일반적인 정적 타입은 Object입니다.", + "readingOrder": "첫 패널에서 읽기의 보장을, 두 번째 패널에서 쓰기의 보장을 확인하세요. 각 패널의 마지막 카드에는 반대 방향의 제한이 있습니다.", + "panels": [ + { + "id": "producer", + "title": "extends Number · 읽기의 보장", + "nodes": [ + { + "id": "view", + "label": "List", + "detail": "실제 요소 타입은 Number 또는 그 하위 타입입니다.", + "tone": "neutral" + }, + { + "id": "read", + "label": "get 결과를 Number로 읽음", + "detail": "Integer 목록과 Double 목록을 같은 읽기 API에 전달할 수 있습니다.", + "tone": "accent" + }, + { + "id": "write", + "label": "non-null Integer 추가는 불가", + "detail": "실제 목록이 Double 전용일 수 있으므로 컴파일러가 허용하지 않습니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "view", + "to": "read", + "label": "읽기 타입의 상한 보장" + }, + { + "from": "view", + "to": "write", + "label": "구체 요소 타입을 모르므로 쓰기 제한" + } + ] + }, + { + "id": "consumer", + "title": "super Integer · 쓰기의 보장", + "nodes": [ + { + "id": "view", + "label": "List", + "detail": "Integer·Number·Object 등 Integer의 상위 타입을 요소 타입으로 가질 수 있습니다.", + "tone": "neutral" + }, + { + "id": "write", + "label": "Integer 값을 add할 수 있음", + "detail": "해당 정적 타입 관계에서 안전한 값입니다.", + "tone": "accent" + }, + { + "id": "read", + "label": "get의 일반적 정적 타입은 Object", + "detail": "Integer가 아닌 다른 상위 타입의 값도 들어 있을 수 있습니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "view", + "to": "write", + "label": "Integer를 받는 타입 관계" + }, + { + "from": "view", + "to": "read", + "label": "구체 읽기 타입은 더 좁힐 수 없음" + } + ] + } + ], + "takeaway": "읽을 값의 타입과 넣을 값의 타입을 따로 판단한 뒤 wildcard를 선택하세요.", + "limits": [ + "컴파일 시점의 타입 안전성을 비교합니다. 실제 컬렉션이 변경 불가이면 add는 런타임에 실패할 수 있습니다.", + "extends 자체는 null 추가나 제거 연산을 타입 수준에서 금지하지 않습니다. 실제 컬렉션이 해당 연산을 허용하는지는 별도 계약입니다.", + "컬렉션의 불변성·capture helper·타입 소거의 세부는 생략합니다." + ], + "sources": [ + { + "title": "Oracle Tutorial — Upper Bounded Wildcards", + "url": "https://docs.oracle.com/javase/tutorial/java/generics/upperBounded.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Oracle Tutorial — Lower Bounded Wildcards", + "url": "https://docs.oracle.com/javase/tutorial/java/generics/lowerBounded.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Oracle Tutorial — Guidelines for Wildcard Use", + "url": "https://docs.oracle.com/javase/tutorial/java/generics/wildcardGuidelines.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "streams": { + "id": "viz-streams-lazy", + "title": "파이프라인 구성과 실제 소비를 나누기", + "kind": "flow", + "afterSection": 0, + "caption": "기준 예제의 filter·distinct·map을 연결하는 단계와 collect가 결과를 구하는 단계를 나눠 봅니다.", + "altText": "중간 연산을 연결할 때는 처리 계획을 만듭니다. collect 최종 연산이 호출되면 소스를 소비해 JAVA와 JVM을 수집합니다. 이 예제의 원본 names는 네 요소를 유지합니다.", + "readingOrder": "먼저 구성만 하는 패널을 읽고, 다음 패널에서 최종 연산 이후의 평가와 결과를 확인하세요.", + "panels": [ + { + "id": "build", + "title": "1. 중간 연산을 연결", + "nodes": [ + { + "id": "source", + "label": "names: java, sql, jvm, java", + "detail": "Stream의 입력 소스입니다.", + "tone": "neutral" + }, + { + "id": "pipeline", + "label": "filter → distinct → map", + "detail": "j로 시작하는 값 선택·중복 제거·대문자 변환을 연결합니다.", + "tone": "neutral" + } + ], + "edges": [ + { + "from": "source", + "to": "pipeline", + "label": "처리 흐름 구성 · 아직 최종 연산 없음" + } + ] + }, + { + "id": "evaluate", + "title": "2. collect로 최종 결과 요청", + "nodes": [ + { + "id": "terminal", + "label": "collect(Collectors.toList())", + "detail": "최종 연산이 파이프라인 평가를 시작합니다.", + "tone": "accent" + }, + { + "id": "evaluate", + "label": "소스를 소비하며 연산 평가", + "detail": "이 예제는 소스나 외부 상태를 변경하지 않는 콜백을 사용합니다.", + "tone": "neutral" + }, + { + "id": "result", + "label": "결과: [JAVA, JVM]", + "detail": "원본 names.size()는 4입니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "terminal", + "to": "evaluate", + "label": "최종 연산이 소비 시작" + }, + { + "from": "evaluate", + "to": "result", + "label": "결과를 리스트로 수집" + } + ] + } + ], + "takeaway": "중간 연산을 적은 시점과 결과가 필요한 시점을 구분하고, 외부 상태 변경을 집계에 섞지 마세요.", + "limits": [ + "각 중간 연산이 별도의 임시 List를 만든다는 뜻이 아닙니다.", + "단락 평가·최적화·병렬 실행의 콜백 횟수와 순서는 그리지 않았습니다. 모든 Stream이 소스를 불변으로 만드는 것도 아닙니다." + ], + "sources": [ + { + "title": "Java API — Stream package, pipelines and laziness", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java API — Stream", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "concurrency": { + "id": "viz-concurrency-atomicity", + "title": "보이는 값과 원자적인 증가는 다른 보장", + "kind": "comparison", + "afterSection": 0, + "caption": "두 스레드가 한 번씩 증가시키는 비교용 사례입니다. 왼쪽은 volatile int에서도 가능한 끼어들기 한 가지이며 기준 예제의 실행 기록은 아닙니다.", + "altText": "count++를 나누어 실행하면 두 스레드가 모두 0을 읽고 각각 1을 써 최종값이 1일 수 있습니다. AtomicInteger의 두 증가가 완료되면 최종값은 2입니다.", + "readingOrder": "첫 패널의 가능한 읽기·쓰기 순서를 따라가고, 두 번째 패널의 원자 연산 결과와 비교하세요.", + "panels": [ + { + "id": "compound", + "title": "volatile count++ · 가능한 실행 사례", + "nodes": [ + { + "id": "initial", + "label": "count = 0", + "detail": "volatile이어도 ++ 전체가 하나의 연산은 아닙니다.", + "tone": "neutral" + }, + { + "id": "read-a", + "label": "스레드 A: 0 읽기", + "detail": "아직 계산 결과를 쓰지 않았습니다.", + "tone": "neutral" + }, + { + "id": "read-b", + "label": "스레드 B: 0 읽기", + "detail": "A의 쓰기 전이므로 같은 값을 읽을 수 있습니다.", + "tone": "neutral" + }, + { + "id": "write-a", + "label": "스레드 A: 1 쓰기", + "detail": "자신이 읽은 0에 1을 더한 값입니다.", + "tone": "neutral" + }, + { + "id": "write-b", + "label": "스레드 B: 1 쓰기", + "detail": "두 증가가 끝났어도 최종값은 1입니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "initial", + "to": "read-a", + "label": "설명용 실행 순서" + }, + { + "from": "read-a", + "to": "read-b", + "label": "A 쓰기 전에 B 읽기가 끼어듦" + }, + { + "from": "read-b", + "to": "write-a", + "label": "A가 계산한 결과 기록" + }, + { + "from": "write-a", + "to": "write-b", + "label": "B도 이전에 읽은 값으로 기록" + } + ] + }, + { + "id": "atomic", + "title": "AtomicInteger.incrementAndGet()", + "nodes": [ + { + "id": "initial", + "label": "초기값 0", + "detail": "각 증가를 원자 연산으로 요청합니다.", + "tone": "neutral" + }, + { + "id": "first", + "label": "한 증가 완료 → 1", + "detail": "어느 스레드가 먼저인지는 고정하지 않습니다.", + "tone": "accent" + }, + { + "id": "second", + "label": "다른 증가 완료 → 2", + "detail": "두 작업의 완료를 확인한 뒤 결과를 관찰합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "initial", + "to": "first", + "label": "원자적으로 1 증가" + }, + { + "from": "first", + "to": "second", + "label": "다음 원자적 증가" + } + ] + } + ], + "takeaway": "volatile의 가시성·순서 보장과 복합 연산의 원자성을 혼동하지 마세요.", + "limits": [ + "화살표는 가능한 실행 사례를 설명하며 JMM의 happens-before 관계도나 실제 시간 측정 결과가 아닙니다.", + "AtomicInteger 하나의 연산이 여러 필드나 DB의 업무 불변식까지 원자적으로 만들지는 않습니다. 기준 예제는 Future.get으로 작업 완료를 기다립니다." + ], + "sources": [ + { + "title": "JLS — Memory Model and happens-before", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4.5", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java API — AtomicInteger.incrementAndGet", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/atomic/AtomicInteger.html#incrementAndGet--", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java API — ExecutorService memory consistency", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "jpa-state": { + "id": "viz-jpa-managed-detached", + "title": "같은 필드 변경, 다른 영속 상태", + "kind": "comparison", + "afterSection": 0, + "caption": "각 패널은 DB 수량 2에서 별도로 시작하는 테스트 사례입니다. 관리 여부와 DB 동기화를 구분합니다.", + "altText": "관리 상태의 주문 수량을 4로 바꾼 뒤 트랜잭션을 커밋하면 새 EntityManager 조회에서 4가 보입니다. EntityManager를 닫고 분리된 주문을 7로 바꾸면 DB에는 자동 반영되지 않아 2가 남습니다.", + "readingOrder": "두 패널을 독립된 실행으로 비교하세요. 첫 패널의 최종 DB 값이 두 번째 패널의 시작 값은 아닙니다.", + "panels": [ + { + "id": "managed", + "title": "A. 관리 상태에서 변경 후 커밋", + "nodes": [ + { + "id": "managed", + "label": "관리 객체 · quantity = 2", + "detail": "활성 트랜잭션의 EntityManager가 관리합니다.", + "tone": "neutral" + }, + { + "id": "changed", + "label": "changeQuantity(4)", + "detail": "객체의 상태가 바뀝니다.", + "tone": "neutral" + }, + { + "id": "commit", + "label": "트랜잭션 commit 호출", + "detail": "필요한 flush와 커밋이 성공합니다.", + "tone": "neutral" + }, + { + "id": "stored", + "label": "새 조회에서 DB 수량 4", + "detail": "다른 EntityManager에서도 확정된 변경을 읽습니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "managed", + "to": "changed", + "label": "업무 메서드 호출" + }, + { + "from": "changed", + "to": "commit", + "label": "이 예제에서 커밋 시 동기화" + }, + { + "from": "commit", + "to": "stored", + "label": "커밋 성공 후 새로 조회" + } + ] + }, + { + "id": "detached", + "title": "B. 컨텍스트 종료 후 객체만 변경", + "nodes": [ + { + "id": "managed", + "label": "조회한 객체 · quantity = 2", + "detail": "별도 실행의 초기 DB 값은 2입니다.", + "tone": "neutral" + }, + { + "id": "close", + "label": "EntityManager.close()", + "detail": "객체가 이 컨텍스트의 관리 범위를 벗어납니다.", + "tone": "neutral" + }, + { + "id": "changed", + "label": "분리 객체를 quantity = 7로", + "detail": "일반 Java 객체의 값은 바뀝니다.", + "tone": "neutral" + }, + { + "id": "stored", + "label": "새 조회에서 DB 수량 2", + "detail": "분리 객체 변경은 자동 저장되지 않습니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "managed", + "to": "close", + "label": "컨텍스트 종료" + }, + { + "from": "close", + "to": "changed", + "label": "detached 객체 메서드 호출" + }, + { + "from": "changed", + "to": "stored", + "label": "자동 UPDATE 없음 · DB 값 유지" + } + ] + } + ], + "takeaway": "setter나 업무 메서드의 호출만 보고 DB 저장 여부를 판단하지 말고 영속 상태와 트랜잭션 경계를 확인하세요.", + "limits": [ + "flush는 SQL 동기화이고 commit은 트랜잭션 확정입니다. SQL은 commit보다 먼저 실행될 수도 있습니다.", + "persist·remove·merge 전체 상태도를 생략했습니다. 특히 merge를 원래 객체가 자동 재부착되는 과정으로 표현하지 않습니다." + ], + "sources": [ + { + "title": "Hibernate User Guide — Persistence Context", + "url": "https://docs.hibernate.org/orm/6.6/userguide/html_single/#pc", + "edition": "Hibernate ORM 6.6", + "checkedAt": "2026-10-12" + }, + { + "title": "Jakarta Persistence specification", + "url": "https://jakarta.ee/specifications/persistence/3.1/", + "edition": "Jakarta Persistence 3.1", + "checkedAt": "2026-10-12" + } + ] + }, + "tx-proxy": { + "id": "viz-tx-proxy-boundary", + "title": "같은 메서드 이름, 다른 트랜잭션 진입", + "kind": "comparison", + "afterSection": 1, + "caption": "호출 전에 열린 트랜잭션이 없는 이 수업의 두 테스트를 비교합니다. INSERT 뒤에는 모두 IllegalStateException이 발생합니다.", + "altText": "외부에서 reserveThenFail을 프록시로 호출하면 트랜잭션이 열리고 예외 전파 후 롤백되어 행이 0개입니다. 트랜잭션이 없는 bypassProxy가 this.reserveThenFail을 부르면 새 조언이 적용되지 않아 자동 커밋된 행 1개가 남습니다.", + "readingOrder": "두 패널에서 프록시의 트랜잭션 진입 여부를 먼저 확인하고, 같은 예외가 난 뒤 DB 상태를 비교하세요.", + "panels": [ + { + "id": "external", + "title": "A. 외부에서 @Transactional 메서드 호출", + "nodes": [ + { + "id": "caller", + "label": "외부 호출자", + "detail": "Spring이 제공한 프록시 빈을 호출합니다.", + "tone": "neutral" + }, + { + "id": "proxy", + "label": "프록시가 트랜잭션을 엶", + "detail": "reserveThenFail의 트랜잭션 조언에 진입합니다.", + "tone": "accent" + }, + { + "id": "target", + "label": "INSERT 후 RuntimeException", + "detail": "예외를 잡아 숨기지 않고 호출 밖으로 보냅니다.", + "tone": "neutral" + }, + { + "id": "database", + "label": "DB 행 수: 0", + "detail": "프록시가 실패를 받아 트랜잭션을 롤백합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "caller", + "to": "proxy", + "label": "외부에서 reserveThenFail 호출" + }, + { + "from": "proxy", + "to": "target", + "label": "트랜잭션 안에서 대상 실행" + }, + { + "from": "target", + "to": "database", + "label": "예외가 프록시까지 전파 → 롤백" + } + ] + }, + { + "id": "self", + "title": "B. 트랜잭션 없는 메서드의 자기 호출", + "nodes": [ + { + "id": "caller", + "label": "bypassProxy로 외부 진입", + "detail": "이 메서드에는 @Transactional이 없습니다.", + "tone": "neutral" + }, + { + "id": "self", + "label": "this.reserveThenFail()", + "detail": "같은 대상 객체 안에서 호출합니다.", + "tone": "neutral" + }, + { + "id": "target", + "label": "INSERT 후 RuntimeException", + "detail": "내부 메서드의 트랜잭션 조언을 새로 통과하지 않습니다.", + "tone": "warning" + }, + { + "id": "database", + "label": "DB 행 수: 1", + "detail": "이 예제에서는 INSERT가 자동 커밋되어 남습니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "caller", + "to": "self", + "label": "현재 트랜잭션 없이 내부 호출" + }, + { + "from": "self", + "to": "target", + "label": "프록시 재진입 없음" + }, + { + "from": "target", + "to": "database", + "label": "이미 커밋된 INSERT는 예외만으로 되돌아가지 않음" + } + ] + } + ], + "takeaway": "어노테이션의 존재와 호출이 실제로 조언을 통과하는지를 따로 확인하세요.", + "limits": [ + "자기 호출이 기존 트랜잭션을 없애는 것은 아닙니다. 바깥에서 이미 열린 트랜잭션이 있으면 그 안에서 실행될 수 있습니다.", + "기본 프록시 모드·이 수업의 DataSource와 기본 RuntimeException 롤백 정책에 한정합니다. 전파 옵션·AspectJ weaving·checked 예외 정책은 생략합니다." + ], + "sources": [ + { + "title": "Spring — Using @Transactional, proxy mode", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/transaction/declarative/annotations.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring — Rolling Back a Declarative Transaction", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/transaction/declarative/rolling-back.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ] + }, + "security-csrf": { + "id": "viz-security-independent-conditions", + "title": "한 조건씩 바꾸어 실패 이유 구별", + "kind": "comparison", + "afterSection": 2, + "caption": "POST /orders에 보내는 네 개의 독립 요청입니다. 필터의 고정 실행 순서를 나타내는 그림이 아닙니다.", + "altText": "유효한 CSRF 토큰을 가진 미인증 요청은 401입니다. 인증된 VIEWER는 토큰이 있어도 403입니다. BUYER도 CSRF 토큰이 없으면 403이고, BUYER와 유효한 토큰을 함께 가진 요청은 200입니다.", + "readingOrder": "각 패널의 요청 전제와 응답을 한 쌍으로 읽으세요. 패널 사이에 실행 순서나 상태 전달은 없습니다.", + "panels": [ + { + "id": "anonymous", + "title": "미인증 · CSRF 조건은 충족", + "nodes": [ + { + "id": "request", + "label": "인증 없음 + 유효 CSRF", + "detail": "인증 실패만 관찰할 수 있도록 토큰은 제공합니다.", + "tone": "neutral" + }, + { + "id": "result", + "label": "응답 401", + "detail": "이 수업의 HTTP Basic 설정에서 인증이 필요합니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "request", + "to": "result", + "label": "인증 조건 미충족" + } + ] + }, + { + "id": "role", + "title": "VIEWER · CSRF 조건은 충족", + "nodes": [ + { + "id": "request", + "label": "VIEWER 인증 + 유효 CSRF", + "detail": "인증은 되었지만 BUYER 역할이 없습니다.", + "tone": "neutral" + }, + { + "id": "result", + "label": "응답 403", + "detail": "역할 권한이 부족합니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "request", + "to": "result", + "label": "BUYER 권한 미충족" + } + ] + }, + { + "id": "csrf", + "title": "BUYER · CSRF 토큰 누락", + "nodes": [ + { + "id": "request", + "label": "BUYER 인증 + CSRF 없음", + "detail": "인증과 역할을 갖춰 CSRF 실패를 분리합니다.", + "tone": "neutral" + }, + { + "id": "result", + "label": "응답 403", + "detail": "상태 변경 요청의 CSRF 보호에 의해 거절됩니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "request", + "to": "result", + "label": "CSRF 조건 미충족" + } + ] + }, + { + "id": "accepted", + "title": "BUYER · CSRF 조건도 충족", + "nodes": [ + { + "id": "request", + "label": "BUYER 인증 + 유효 CSRF", + "detail": "이 엔드포인트의 세 조건을 충족합니다.", + "tone": "neutral" + }, + { + "id": "result", + "label": "응답 200", + "detail": "컨트롤러가 이 예제의 정상 요청을 처리합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "request", + "to": "result", + "label": "이 예제의 요청 허용" + } + ] + } + ], + "takeaway": "401이나 403 하나만 보고 원인을 단정하지 말고 다른 전제를 고정해 실패 조건을 검사하세요.", + "limits": [ + "응답 코드는 이 수업의 Spring Security 설정과 테스트 계약입니다. 모든 인증 방식이나 설정에서 같은 응답을 보장하지 않습니다.", + "자원 소유권·브라우저 쿠키 정책·TLS·전체 필터 순서는 생략했습니다. BUYER 역할이 다른 사용자의 주문 소유권을 대신하지 않습니다." + ], + "sources": [ + { + "title": "Spring Security — CSRF", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/exploits/csrf.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring Security — Authorize HttpServletRequests", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/authorization/authorize-http-requests.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + } + ] + }, + "jwt-validation": { + "id": "viz-jwt-acceptance", + "title": "서명 성공만으로 토큰을 수용하지 않기", + "kind": "relationship", + "afterSection": 1, + "caption": "디코더의 형식·헤더 검사를 통과한 토큰을 전제로, 이 실습의 주요 수용 조건을 비교합니다. 모두 충족해야 하며 나열은 실제 검사 실행 순서가 아닙니다.", + "altText": "디코더의 형식·헤더 검사를 통과한 토큰이 전제입니다. 허용된 RS256 서명과 주어진 RSA 공개키, 신뢰한 issuer, orders-api audience, 비어 있지 않은 subject, 유효한 시간 조건이 모두 필요합니다. 한 조건만 통과한 것은 충분하지 않습니다.", + "readingOrder": "각 조건 카드에서 수용 카드로 이어지는 AND 관계를 읽으세요. 어느 한 조건이라도 실패하면 수용할 수 없습니다.", + "panels": [ + { + "id": "conditions", + "title": "이 과제의 수용 정책 · 모든 조건 AND", + "nodes": [ + { + "id": "signature", + "label": "허용 알고리즘·키로 서명 검증", + "detail": "RS256과 실행 시 전달한 RSA 공개키를 사용합니다.", + "tone": "neutral" + }, + { + "id": "issuer", + "label": "신뢰한 issuer", + "detail": "https://training-issuer.invalid와 일치해야 합니다.", + "tone": "neutral" + }, + { + "id": "audience", + "label": "우리 API가 audience에 포함", + "detail": "orders-api가 aud에 있어야 합니다.", + "tone": "neutral" + }, + { + "id": "subject", + "label": "필수 subject", + "detail": "sub가 존재하고 공백이 아니어야 합니다.", + "tone": "neutral" + }, + { + "id": "time", + "label": "필수 exp + 시간 조건", + "detail": "exp는 현재보다 뒤여야 합니다. nbf가 있으면 현재 이하여야 합니다.", + "tone": "neutral" + }, + { + "id": "accepted", + "label": "전제 아래 모든 조건 충족 → 수용", + "detail": "그림의 조건 중 하나라도 실패하면 거절합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "signature", + "to": "accepted", + "label": "AND: 서명 조건 필수" + }, + { + "from": "issuer", + "to": "accepted", + "label": "AND: 발급자 조건 필수" + }, + { + "from": "audience", + "to": "accepted", + "label": "AND: 대상 조건 필수" + }, + { + "from": "subject", + "to": "accepted", + "label": "AND: 주체 조건 필수" + }, + { + "from": "time", + "to": "accepted", + "label": "AND: 시간 조건 필수" + } + ] + } + ], + "takeaway": "토큰을 읽을 수 있다는 사실, 서명이 유효하다는 사실, 우리 API가 받아들인다는 판단을 구분하세요.", + "limits": [ + "typ 헤더 등 NimbusJwtDecoder의 기본 형식·헤더 검사는 도식에서 생략했습니다. Spring Security 6.5의 공개키 디코더는 기본 typ 검증을 적용합니다.", + "필수 claim과 clock skew 0은 이 실습의 정책입니다. 모든 JWT에 동일한 필수 claim을 요구하는 일반 규칙으로 확대하지 않습니다.", + "암호화·권한 scope 매핑·키 회전·폐기·원격 JWKS·실제 OIDC 로그인 전체 흐름은 포함하지 않습니다." + ], + "sources": [ + { + "title": "Spring Security — JWT Resource Server validation", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/oauth2/resource-server/jwt.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "RFC 7519 — JSON Web Token claims", + "url": "https://www.rfc-editor.org/rfc/rfc7519", + "edition": "RFC 7519", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring Security API — NimbusJwtDecoder default typ validation", + "url": "https://docs.spring.io/spring-security/reference/6.5/api/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.PublicKeyJwtDecoderBuilder.html#validateType(boolean)", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + } + ] + }, + "kafka-idempotency": { + "id": "viz-kafka-db-offset", + "title": "DB 커밋과 오프셋 커밋 사이의 빈틈", + "kind": "flow", + "afterSection": 2, + "caption": "이 과제의 DB 우선 처리 순서와 중간 장애 가능성을 설명합니다. 재전달 장애 시나리오를 실제 실행한 결과 그림은 아닙니다.", + "altText": "메시지를 받은 뒤 eventId 기록과 주문 변경을 같은 DB 트랜잭션에 커밋하고 그다음 오프셋을 커밋합니다. 두 커밋 사이에 중단되면 메시지를 다시 받을 수 있어 같은 eventId와 payload의 DB 효과를 반복하지 않아야 합니다.", + "readingOrder": "첫 패널에서 두 커밋의 순서를 확인한 뒤, 두 번째 패널에서 그 사이에 중단되었을 때의 가능성을 읽으세요.", + "panels": [ + { + "id": "normal", + "title": "1. 정상 처리의 두 커밋", + "nodes": [ + { + "id": "received", + "label": "Kafka 메시지 수신", + "detail": "자동 오프셋 커밋을 끈 소비자가 읽습니다.", + "tone": "neutral" + }, + { + "id": "db-work", + "label": "같은 DB 트랜잭션", + "detail": "processed_events의 eventId 기록과 orders 변경을 함께 수행합니다.", + "tone": "neutral" + }, + { + "id": "db-commit", + "label": "DB commit 성공", + "detail": "업무 효과는 DB에 반영됐습니다.", + "tone": "accent" + }, + { + "id": "offset", + "label": "그다음 offset commit", + "detail": "소비자의 처리 위치를 Kafka에 기록합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "received", + "to": "db-work", + "label": "이벤트를 처리" + }, + { + "from": "db-work", + "to": "db-commit", + "label": "두 DB 변경을 함께 확정" + }, + { + "from": "db-commit", + "to": "offset", + "label": "DB 성공 후 소비 위치 커밋" + } + ] + }, + { + "id": "gap", + "title": "2. DB 성공 뒤 offset 커밋 전에 중단", + "nodes": [ + { + "id": "gap", + "label": "DB에는 효과가 남음", + "detail": "오프셋은 아직 커밋되지 않았을 수 있습니다.", + "tone": "warning" + }, + { + "id": "redelivery", + "label": "같은 이벤트를 다시 받을 수 있음", + "detail": "복구 후 소비 위치에 따라 재처리 가능성이 생깁니다.", + "tone": "neutral" + }, + { + "id": "dedupe", + "label": "eventId와 payload 확인", + "detail": "같은 eventId·같은 orderId이면 이미 반영된 요청으로 판단합니다.", + "tone": "neutral" + }, + { + "id": "no-repeat", + "label": "DB 효과를 반복하지 않음", + "detail": "동일 payload 중복은 false를 반환합니다. 다른 payload이면 거절합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "gap", + "to": "redelivery", + "label": "두 커밋은 별도의 성공 지점" + }, + { + "from": "redelivery", + "to": "dedupe", + "label": "처리 이력과 입력을 대조" + }, + { + "from": "dedupe", + "to": "no-repeat", + "label": "동일한 업무 요청의 멱등 처리" + } + ] + } + ], + "takeaway": "전달 횟수와 DB 반영 횟수를 분리하고, 서로 다른 커밋 사이의 실패를 설계에 포함하세요.", + "limits": [ + "제공 테스트는 같은 payload를 두 메시지로 보내는 모델입니다. 실제 브로커 장애·프로세스 재시작 복구를 재현한 검사는 아닙니다.", + "이 과제의 단일 DB·eventId 유일성 계약에 한정합니다. Kafka와 임의의 외부 DB가 자동으로 exactly-once가 된다는 뜻이 아닙니다." + ], + "sources": [ + { + "title": "KafkaConsumer — Manual Offset Control and delivery semantics", + "url": "https://kafka.apache.org/39/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html", + "edition": "Kafka 3.9", + "checkedAt": "2026-10-12" + }, + { + "title": "Java API — JDBC Connection transactions", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.sql/java/sql/Connection.html", + "edition": "Java SE 17", + "checkedAt": "2026-10-12" + } + ] + } +}; diff --git a/dist/styles.css b/dist/styles.css index 817b8b5..9f1b7a1 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -162,6 +162,63 @@ ul, ol { padding-left: 1.35rem; } .learning-goals .icon { width: 14px; height: 14px; flex-basis: 14px; color: #628055; margin-top: .2rem; } .theory-section + .theory-section { margin-top: 1.7rem; padding-top: 1.45rem; border-top: 1px solid #e8eadf; } .theory-section p { color: #536152; font-size: .9375rem; line-height: 1.95; margin-top: .7rem; white-space: pre-line; } +.theory-visual { container-type: inline-size; container-name: theory-visual; min-width: 0; margin: 1.65rem 0 1.8rem; padding: 1.05rem; border: 1px solid #cfdcc7; border-radius: 12px; background: #f3f6ee; } +.theory-visual + .theory-section { margin-top: 1.7rem; } +.visual-caption { margin-bottom: .85rem; } +.visual-kicker { color: #546b48; font-size: .6875rem; font-weight: 700; letter-spacing: .08em; word-break: keep-all; overflow-wrap: anywhere; } +.visual-title { margin: .35rem 0 .55rem; color: var(--green); word-break: keep-all; overflow-wrap: anywhere; } +.visual-description { color: #485f45; font-size: .8125rem; line-height: 1.85; } +.visual-reading-order { color: #526849; font-size: .75rem; line-height: 1.85; padding: .6rem 0 .95rem; border-top: 1px solid #dce4d4; } +.visual-reading-order strong { color: #304f37; margin-right: .35rem; } +.visual-panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); gap: 1rem; align-items: start; } +.visual-panel { container-type: inline-size; container-name: visual-panel; min-width: 0; padding: .8rem; border: 1px solid #d8e1cf; border-radius: 9px; background: #fafbf6; } +.visual-panel-title { margin: 0 0 .75rem; font-size: .8125rem; font-weight: 750; line-height: 1.75; color: #34533c; word-break: keep-all; overflow-wrap: anywhere; } +.visual-nodes { display: grid; gap: .55rem; list-style: none; margin: 0; padding: 0; } +.visual-nodes.is-catalog { grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); } +.visual-node-row { min-width: 0; } +.visual-node { min-width: 0; padding: .7rem .75rem; border: 1px solid #cbd8c0; border-radius: 7px; background: #fffefa; } +.visual-node.is-accent { border-color: #648576; border-left: 3px solid #32624d; background: #edf4ed; } +.visual-node.is-warning { border-color: #b07856; border-style: dashed; background: #fcf2e9; } +.visual-node-label { display: block; color: #284535; font-size: .8125rem; font-weight: 700; line-height: 1.75; word-break: keep-all; overflow-wrap: anywhere; } +.visual-node.is-warning .visual-node-label { color: #804225; } +.visual-node-detail { margin-top: .35rem; color: #4e6149; font-size: .75rem; line-height: 1.85; white-space: pre-line; } +.visual-node.is-warning .visual-node-detail { color: #78523b; } +.visual-transition { padding: .4rem .35rem 0; } +.visual-arrow { display: flex; align-items: center; gap: .45rem; color: #3d614c; min-width: 0; } +.visual-arrow > .icon { width: 20px; height: 20px; flex: 0 0 20px; transform: rotate(90deg); stroke-width: 1.8; } +.visual-edge-label { min-width: 0; font-size: .75rem; line-height: 1.8; word-break: keep-all; overflow-wrap: anywhere; } +.visual-connections { border-top: 1px solid #d8e1cf; padding-top: .75rem; margin-top: .85rem; } +.visual-connections-label { color: #52644c; font-size: .6875rem; line-height: 1.85; margin-bottom: .45rem; } +.visual-relationship { display: grid; grid-template-columns: minmax(0, 1fr); gap: .4rem; align-items: center; padding: .5rem 0; } +.visual-relationship + .visual-relationship { border-top: 1px dotted #cfdac5; } +.visual-endpoint { padding: .4rem .6rem; background: #e7eee0; border: 1px solid #ccd9bf; border-radius: 6px; color: #345337; font-size: .75rem; font-weight: 650; line-height: 1.75; text-align: center; word-break: keep-all; overflow-wrap: anywhere; } +.visual-relationship .visual-arrow { justify-content: center; flex-direction: column-reverse; gap: .15rem; text-align: center; } +.visual-takeaway { padding: .85rem 0 .7rem; margin-top: .6rem; color: #34533c; font-size: .8125rem; line-height: 1.85; } +.visual-takeaway strong { margin-right: .4rem; color: var(--green); } +.visual-text-description { border-top: 1px solid #d2dfc6; border-bottom: 1px solid #d2dfc6; padding: .75rem 0; margin-top: .5rem; } +.visual-text-description summary { cursor: pointer; color: #34533c; font-size: .8125rem; font-weight: 650; line-height: 1.8; } +.visual-alt-text, .visual-limits { font-size: .8125rem; line-height: 1.9; color: #4b6046; white-space: pre-line; } +.visual-alt-text { margin-top: .7rem; } +.visual-limits { padding-left: 1.25rem; margin-top: .65rem; } +.visual-limits li + li { margin-top: .4rem; } +.visual-sources { padding-top: .8rem; } +.visual-sources-label { color: #4b6342; font-size: .6875rem; font-weight: 700; margin-bottom: .45rem; } +.visual-sources ul { list-style: none; padding: 0; display: grid; gap: .55rem; } +.visual-source-link { display: inline-flex; align-items: baseline; gap: .35rem; color: #355f45; font-size: .75rem; line-height: 1.85; max-width: 100%; overflow-wrap: anywhere; } +.visual-source-link > .icon { width: 12px; height: 12px; flex: 0 0 12px; } +.visual-source-meta { display: block; color: #53694a; font-size: .6875rem; line-height: 1.8; } +.visual-description, .visual-reading-order, .visual-node-detail, .visual-alt-text, .visual-limits, .visual-takeaway, .visual-text-description summary { word-break: keep-all; overflow-wrap: anywhere; } +@container theory-visual (max-width: 26rem) { + .visual-panel { padding: 0; border: 0; background: transparent; } + .visual-panels { gap: 1.4rem; } + .visual-panel + .visual-panel { border-top: 1px solid #cbd8c0; padding-top: 1.1rem; border-radius: 0; } + .visual-node { padding: min(.65rem, 12px); } + .visual-endpoint { padding: min(.4rem, 10px) min(.6rem, 12px); } +} +@container visual-panel (min-width: 28rem) { + .visual-relationship { grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr) minmax(0, 1fr); gap: .6rem; } + .visual-relationship .visual-arrow > .icon { transform: none; } +} .next-step-row { display: flex; justify-content: flex-end; padding-top: 1.8rem; margin-top: 1.3rem; border-top: 1px solid #e6e9df; } .button { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; min-height: 44px; padding: .65rem 1rem; border: 1px solid transparent; border-radius: 8px; font-size: .8125rem; font-weight: 650; line-height: 1.5; text-decoration: none; text-align: center; } .button .icon { width: 16px; height: 16px; flex-basis: 16px; } @@ -539,6 +596,7 @@ ul, ol { padding-left: 1.35rem; } .learning-goals { padding: .9rem; margin-bottom: 1.4rem; } .learning-goals li { font-size: .875rem; } .theory-section p { font-size: .9375rem; } + .theory-visual { margin-right: -.5rem; margin-left: -.5rem; padding: min(.5rem, 10px); border-radius: 9px; } .code-block { padding: 1rem; font-size: .875rem; } .lesson-footer { padding: .75rem 1.25rem; } .reference-column { display: flex; } diff --git a/docs/milestones.md b/docs/milestones.md index 51e1da2..b608c5b 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -12,6 +12,7 @@ | M5 | Windows 설치형·로컬 JDK8/11/17/21·Maven 실행 | 세션·도구 등록·프로세스 정리·앱 재시작 진도·실제 로컬 실행·설치 파일 | 구현·개발 앱9/9·로컬16/16 PASS, 설치·제거 및 최종 시각 gate 남음 | | M6 | Spring/REST/Lombok/MyBatis/테스트 대표 수업 | 실제 의존성·다중 파일·DB·HTTP 코딩 실습 | 22실무수업 통합, 기본20프로젝트 기준54JUnit PASS·starter 실패 확인 | | M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | 구현·독립 코드/출처 PASS, Redis/Kafka 실제 서비스 미검증·새 배포 남음 | +| M7.1 | 이론 도식과 GitHub 프로젝트 소개 | 34수업 채택 평가·공식 출처·독립 코드/기능/시각 검수·README 실제 화면 | main 소개 반영, 12도식 채택·22보류; 도식19/19·회귀49/49·내용/시각 PASS, 웹 배포 진행 | M4는 전체 교육 과정 완료를 뜻하지 않는다. 2026-10-12 사용자는 **Windows 설치형 + 웹 교재**를 선택했다. 학습자 로컬 PC에서 Java/Spring/H2를 실행하며, 호스팅 컨테이너 서버는 기본 과정에 필요하지 않다. Redis/Kafka 실제 연동은 별도 선택 환경이다. 웹의 기존 코어 실행은 Wandbox 실제 JVM21을 사용한다. 교재 버전·컴파일 대상·실제 JVM을 구분한다. 마일스톤마다 관련 파일만 stage하고 강제 푸시하지 않는다. 배포는 검증한 커밋의 동일 소스를 사용한다. diff --git a/docs/theory-visual-qa-report.md b/docs/theory-visual-qa-report.md new file mode 100644 index 0000000..fd15a41 --- /dev/null +++ b/docs/theory-visual-qa-report.md @@ -0,0 +1,86 @@ +# 이론 시각 자료 독립 QA + +2026-10-12 기능 판정: **PASS**. 새 도식 검사 19/19와 기존 브라우저 회귀 49/49가 실제 실행에서 통과했다. 미적 완성도와 설명의 교육 효과는 자동 검사 통과로 승인하지 않으며, 독립 시각·내용 검토의 별도 판정을 따른다. + +## 환경과 실제 실행 + +Windows, Node.js 22.17.0, Playwright 1.62.1, Edge 155.0.4283.45를 사용했다. 부모가 관리하는 `http://127.0.0.1:4173`의 동결본을 별도 브라우저 컨텍스트에서 검사했다. API 응답은 모의 처리했고, 공식 출처 링크 활성화도 새 탭의 요청을 가로채 로컬 응답으로 확인했다. 실제 외부 출처 방문 또는 코드 전송은 하지 않았다. + +공통 `boot()`의 선택 모듈 import와 이론 렌더링·스타일이 바뀌므로 기존 49개 회귀도 전체 재실행했다. 기존 테스트 파일은 이번 작업에서 수정하지 않았다. + +```powershell +$env:NODE_TLS_REJECT_UNAUTHORIZED = '1' +$env:NODE_OPTIONS = '--use-system-ca' +$env:PLAYWRIGHT_CHANNEL = 'msedge' +$env:QA_ARTIFACT_DIR = '/work/theory-independent-qa' +node tests/theory-visuals.mjs + +$env:QA_ARTIFACT_DIR = '/work/theory-independent-qa/baseline' +node tests/browser.mjs +``` + +- `node --check tests/theory-visuals.mjs`: PASS. +- `node tests/theory-visuals.mjs`: **19 PASS, 0 FAIL**, 종료 코드 0. +- `node tests/browser.mjs`: **49 PASS, 0 FAIL**, 종료 코드 0. 이번 실행은 49개 전체가 한 번의 실행에서 통과한 결과다. +- 이후 QA 파일의 기본 산출물 경로만 저장소 내부로 변경했다. `QA_ARTIFACT_DIR` 없이 `QA_FILTER=keyboard-description-and-reference-activation`으로 1개 시나리오를 재실행해 **1/1 PASS** 및 기본 위치의 JSON·화면 저장을 확인했다. 제품 코드는 변경되지 않았다. + +## 확인한 수용 조건 + +| 범위 | 실제 확인 결과 | +| --- | --- | +| 전체 수업 본문 | 34개 수업의 기존 이론 절 제목과 원문을 데이터와 정확히 대조. 도식이 없는 22개 수업에도 원문이 유지되고 불필요한 figure가 없음 | +| 선정된 12개 도식 | 수업 ID, 도식 ID·종류, 이론 절 다음 삽입 위치, 제목·caption·읽는 순서·텍스트 설명·한계가 일치 | +| 내부 관계 | 25개 패널, 80개 노드의 DOM 순서·label·detail, 54개 연결의 수·from/to·보이는 라벨·스크린리더용 양 끝 설명을 데이터와 대조. 데이터에 없는 연결이 추가되지 않음 | +| 출처 | 26개 출처 참조의 제목·HTTPS URL·판본·확인일이 일치. 새 탭과 `noopener noreferrer` 확인. 키보드와 새 탭 활성화는 모의 네트워크에서 확인 | +| 키보드 | `details`에 Enter/Space로 열기·닫기, Tab/Shift+Tab으로 설명과 첫 출처 링크 사이 이동, 실제 초점과 펼친 설명 확인 | +| 기존 상태 보존 | UI에서 퀴즈 통과·설계 노트·코어 초안·프로젝트 파일 초안·테마 설정 후 도식 수업 왕복, 버전·트랙·검색 변경, 새로고침 수행. `quizPassed`, `labPassed`, `drafts`, `fileDrafts`, `notes`가 동일하게 유지됨 | +| 실행·성취 분리 | 이론의 탐색과 자료 오류 상황에서 실행 API 요청 0건. 도식 열람으로 실습 완료가 생기지 않음 | +| 선택 자료 로딩 실패 | 도식 모듈 404, 모듈 구문 오류, 배열 형태의 잘못된 export 각각에서 선정된 12개 수업의 기존 본문과 다음 예제 단계 유지 | +| 개별 잘못된 자료 | 중복 노드 ID, 없는 도착 노드, 알 수 없는 kind, `javascript:` 출처를 주입. 해당 도식만 빠지고 정상 도식·본문 유지 | +| 안전한 텍스트 처리 | 제목과 노드에 ``·`'; + await run('diagram-text-is-not-executed-as-markup', async ({ page }) => { + await openLesson(page, 'java-basics'); + assert.equal(await page.locator('.visual-title').textContent(), textOnly['java-basics'].title); + assert.equal(await page.locator('.theory-visual img,.theory-visual script').count(), 0); + assert.equal(await page.evaluate(() => window.qaDiagramInjected), undefined); + await originalTheory(page, coreLessons[0]); + }, { visualModule: `export const lessonVisuals = ${JSON.stringify(textOnly)};` }); + + for (const width of [320, 360, 390, 768, 1440]) for (const font of [100, 200]) { + await run(`all12-diagram-layout-${width}px-${font}percent`, async ({ page }) => { + for (const id of selected) { + await openLesson(page, id); + await geometry(page, id); + } + for (const id of ['java-basics', 'security-csrf', 'jwt-validation']) { + await openLesson(page, id); + await captureViewport(page, id, `${width}-${font}-title`); + if (width === 320 && font === 200 || width === 390 && font === 200) { + await captureViewport(page, id, `${width}-${font}-nodes`, '.visual-node'); + await page.locator('.visual-text-description summary').click(); + await captureViewport(page, id, `${width}-${font}-description`, '.visual-alt-text'); + await captureViewport(page, id, `${width}-${font}-sources`, '.visual-sources'); + await geometry(page, id); + } + } + }, { viewport: { width, height: width < 500 ? 844 : 1000 }, font }); + } + + await run('short-height-orientation-and-expanded-details', async ({ page }) => { + for (const viewport of [{ width: 844, height: 390 }, { width: 1280, height: 600 }, { width: 390, height: 844 }, { width: 844, height: 390 }]) { + await page.setViewportSize(viewport); + for (const id of ['oop', 'streams', 'jwt-validation', 'kafka-idempotency']) { + await openLesson(page, id); + await page.locator('.visual-text-description summary').click(); + await geometry(page, id); + } + await captureViewport(page, 'kafka-idempotency', `${viewport.width}x${viewport.height}-orientation`, '.visual-node'); + } + }); + } finally { await browser.close(); } + const hashes = {}; + for (const relative of ['dist/app.js', 'dist/styles.css', 'dist/data/lesson-visuals.js', 'tests/theory-visuals.mjs']) hashes[relative] = createHash('sha256').update(await readFile(path.join(root, relative))).digest('hex'); + const report = { recordedAt: new Date().toISOString(), browser: process.env.PLAYWRIGHT_CHANNEL || 'chromium', browserVersion: version, mode: 'Isolated browser emulation; mocked API/reference activation; no real devices', selectedLessons: selected, hashes, results }; + await writeFile(path.join(artifacts, `results-${Date.now()}.json`), JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report, null, 2)); + if (!results.length || results.some(result => result.status !== 'PASS')) process.exitCode = 1; +} + +main().catch(error => { console.error(error.stack); process.exitCode = 1; }); From 9c2d2b8a06dacfd81c05e3c88549d9aad2d19d80 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:37:37 +0900 Subject: [PATCH 12/29] =?UTF-8?q?docs:=20=EC=9D=B4=EB=A1=A0=20=EB=8F=84?= =?UTF-8?q?=EC=8B=9D=20=EC=9B=B9=20=EB=B0=B0=ED=8F=AC=EC=99=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=83=81=ED=83=9C=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +++-- docs/deployment.md | 20 +++++++++++++++++++- docs/milestones.md | 4 ++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index db4de59..c910ef1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교 수업은 **이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩** 순서로 진행합니다. Java 언어·표준 API는 Oracle/OpenJDK 공식 문서를, 프레임워크·라이브러리는 각 프로젝트의 공식 문서를 기준으로 합니다. 실무 주제에는 출처를 기록한 채용 공고의 기술 요건도 연결합니다. -> **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. Windows v0.2 설치 파일은 최종 검증 전으로 아직 릴리스하지 않았습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 현재 코어 12수업과 편집기 테마 버전이 배포돼 있습니다. +> **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. Windows v0.2 설치 파일은 최종 검증 전으로 아직 릴리스하지 않았습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 34수업·이론 도식 12개·편집기 테마가 배포돼 있습니다. ## 학습실 미리보기 @@ -139,7 +139,8 @@ npm run build:desktop - [x] 실무 22수업과 기본 20프로젝트의 기준 검증 - [x] 이론 도식 12개와 공식 출처·텍스트 설명 - [ ] Windows CI 실패 해결·설치/제거 검증·최종 시각 검수 -- [ ] Windows v0.2 릴리스와 34수업 웹 배포 +- [x] 34수업·이론 도식 웹 교재 배포 +- [ ] Windows v0.2 릴리스 - [ ] 선택 과정의 Redis/Kafka 실제 연동 검증 ## 기여와 문의 diff --git a/docs/deployment.md b/docs/deployment.md index 5aad61d..d5e8d79 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -25,4 +25,22 @@ M4.1 GitHub 소스 `a4d277e10f6c9e03b6a14f4fb6a11a2672987b54`, CI38204310833 전체6작업 성공. Sites source SHA `de02f178b7e236805e4e1594b7f7f4634ff0dd1e`, 버전 `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_91e1ae45c3f88191b32bc7c994e34ad2`, 배포 `appgdep_6acc654d2f788191b0fcec1f82f8738a`가 2026-10-12 04:43:08 UTC에 succeeded를 반환했다. 같은 개인 전용 URL에 코어12수업과 문법 강조·3테마를 제공한다. -Windows v0.2.0 설치 파일은 로컬 빌드가 완료됐지만 GitHub Release에는 아직 게시하지 않았다. 34수업 웹도 아직 배포하지 않았다. 미완료 설치·시각 gate 및 새 CI 결과를 확인한 뒤 동일 검증 소스를 게시한다. 실제 최신 상태는 [개발 인계](development-handoff.md)를 따른다. +M4.1 당시에는 Windows v0.2.0 설치 파일의 로컬 빌드만 완료됐고 GitHub Release와 34수업 웹 배포는 남아 있었다. 이후 웹 배포 결과는 아래 M7.1 기록을 따른다. Windows 설치형의 최신 검증 상태는 [개발 인계](development-handoff.md)를 따른다. + +## M7.1 34수업·이론 도식 배포 + +2026-10-12 06:29:47 UTC에 같은 소유자 전용 URL로 웹 교재 배포가 성공했다. Java 코어12+실무22수업과 검수한 이론 도식12개를 제공한다. Windows 설치 파일 출시는 별도이며 이 웹 배포를 설치 앱 검증으로 표현하지 않는다. + +| 항목 | 확인 값 | +| --- | --- | +| GitHub 검증 소스 | `a0d12571dcca5574d19b6ffdc35cd1a1093790d4` | +| CI | [38212145793](https://github.com/jbsim999/java-edu/actions/runs/38212145793): 콘텐츠·브라우저(도식 포함)·Java4·실무2 성공, Windows4 실패 | +| Sites source SHA | `88e92c4a6889c621f710027f924450d540040460` | +| 저장된 버전 | `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_776dd5662690819188b347b7a7be2b14` | +| 배포 ID | `appgdep_6acc7e4ad0c88191a5c9c2e721d984a1` | +| 결과 | `succeeded`, `failure_message: null` | +| 배포 파일 SHA256 | `e11b37024706fc81ffe2fbe5f2ee2e3f0ffacd129f524e57b1a6e3cabae54bc4` | + +공식 Sites helper로 연 기존 체크아웃에 GitHub 검증 커밋의 파일260개를 복사하고 Git blob SHA를 전부 대조했다. 정확히 같은 소스를 `npm ci`(audit0)→Worker 빌드→Worker 정적·인증 검사→source commit/push→archive 순서로 준비했다. 이전 opening result의 SHA는 체크아웃 기준 정보이며, 배포에는 helper가 새로 반환한 pushed source SHA만 전달했다. 자동 승인 검토가 초기 패키징 입력을 오래된 SHA·만료 가능 인증정보로 판단해 거절했으나, 260파일 일치 확인·공식 helper 계약 확인·새 인증정보 후 재검토에서 진행됐다. 토큰은 저장·출력하지 않았다. + +웹 관련 8개 CI 작업과 내용·기능·시각 검수는 PASS다. 전체 CI의 Windows4 실패는 감독 PowerShell의 .NET CodeDom 임시 경로 초기화에서 발생하며 Windows 설치형 수정·검증에서 추적한다. 실무 웹은 교재·파일 편집·ZIP 다운로드를 제공하고 Spring을 호스팅에서 실행하지 않는다. 실제 인증 세션 Java 제출·실기기·학습 효과는 이번 배포 검증에 포함하지 않는다. diff --git a/docs/milestones.md b/docs/milestones.md index b608c5b..0a3c0bd 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -11,8 +11,8 @@ | M4.1 | Java 문법 색상·3테마·편집 기능 | 구문·브라우저 회귀·선택 대비·새 체크아웃 빌드·독립 코드/시각 검수 | 완료: `a4d277e` 원격·CI PASS·private 배포 성공 | | M5 | Windows 설치형·로컬 JDK8/11/17/21·Maven 실행 | 세션·도구 등록·프로세스 정리·앱 재시작 진도·실제 로컬 실행·설치 파일 | 구현·개발 앱9/9·로컬16/16 PASS, 설치·제거 및 최종 시각 gate 남음 | | M6 | Spring/REST/Lombok/MyBatis/테스트 대표 수업 | 실제 의존성·다중 파일·DB·HTTP 코딩 실습 | 22실무수업 통합, 기본20프로젝트 기준54JUnit PASS·starter 실패 확인 | -| M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | 구현·독립 코드/출처 PASS, Redis/Kafka 실제 서비스 미검증·새 배포 남음 | -| M7.1 | 이론 도식과 GitHub 프로젝트 소개 | 34수업 채택 평가·공식 출처·독립 코드/기능/시각 검수·README 실제 화면 | main 소개 반영, 12도식 채택·22보류; 도식19/19·회귀49/49·내용/시각 PASS, 웹 배포 진행 | +| M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | 구현·독립 코드/출처 PASS, 웹 교재 배포 완료; Redis/Kafka 실제 서비스 미검증·Windows 미출시 | +| M7.1 | 이론 도식과 GitHub 프로젝트 소개 | 34수업 채택 평가·공식 출처·독립 코드/기능/시각 검수·README 실제 화면 | main 소개 반영, 12도식 채택·22보류; 도식19/19·회귀49/49·내용/시각 PASS, `a0d1257` 원격·웹 배포 성공 | M4는 전체 교육 과정 완료를 뜻하지 않는다. 2026-10-12 사용자는 **Windows 설치형 + 웹 교재**를 선택했다. 학습자 로컬 PC에서 Java/Spring/H2를 실행하며, 호스팅 컨테이너 서버는 기본 과정에 필요하지 않다. Redis/Kafka 실제 연동은 별도 선택 환경이다. 웹의 기존 코어 실행은 Wandbox 실제 JVM21을 사용한다. 교재 버전·컴파일 대상·실제 JVM을 구분한다. 마일스톤마다 관련 파일만 stage하고 강제 푸시하지 않는다. 배포는 검증한 커밋의 동일 소스를 사용한다. From 6ba4b8aa124f5ce78e79240253946c9fdac3e770 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:41:16 +0900 Subject: [PATCH 13/29] =?UTF-8?q?fix(desktop):=20=EA=B0=90=EB=8F=85=20?= =?UTF-8?q?=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=9E=84=EC=8B=9C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EC=99=80=20=ED=95=99=EC=8A=B5=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/job-runner.ps1 | 3 +++ docs/local-execution.md | 8 ++++++++ server/local/process.mjs | 32 ++++++++++++++++++++++++-------- tests/local/process.test.mjs | 27 +++++++++++++++++++++++---- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/desktop/job-runner.ps1 b/desktop/job-runner.ps1 index 85b6e6d..fe0936f 100644 --- a/desktop/job-runner.ps1 +++ b/desktop/job-runner.ps1 @@ -102,6 +102,9 @@ try { Import-Module -Name ([System.IO.Path]::Combine($PSHOME, 'Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1')) -ErrorAction Stop $spec = [System.IO.File]::ReadAllText($SpecPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json Add-Type -TypeDefinition $source -Language CSharp + # Compilation uses a private OS-temp child; learner processes keep their original job-local temp. + $env:TEMP = if ($spec.childTemp) { [string]$spec.childTemp } else { [string]$spec.cwd } + $env:TMP = if ($spec.childTmp) { [string]$spec.childTmp } else { [string]$spec.cwd } $result = [JavaEduJob]::Run([string]$spec.executable, [string[]]$spec.args, [string]$spec.cwd, [int]$spec.timeoutMs, [int]$spec.memoryMiB, [int]$spec.maxProcesses, [uint32]$spec.parentPid) $report = @{ exitCode = [long]$result[0]; reason = $result[1] } | ConvertTo-Json -Compress [System.IO.File]::WriteAllText([string]$spec.resultPath, $report, (New-Object System.Text.UTF8Encoding($false))) diff --git a/docs/local-execution.md b/docs/local-execution.md index 73cbce0..0e3ecaf 100644 --- a/docs/local-execution.md +++ b/docs/local-execution.md @@ -88,3 +88,11 @@ Electron은 contextIsolation=true, nodeIntegration=false, sandbox=true, webSecur 실제 Java 11·17 PC 런타임은 이 환경에 설치되지 않아 로컬 엔진으로 실행하지 않았다. 다른 JDK의 CI 결과와 별개다. Redis·Kafka 실제 서비스 연결은 미검증이며 미준비 시 409 반환과 고정 loopback 주소 정책만 검증했다. Electron 전체 화면 흐름·설치 파일·패키지 실행 검증은 독립 QA 및 최종 릴리스 기록을 따른다. 실행 중 발견한 실패는 숨기지 않았다. Java 8 도구 검사에서 자동 heap 크기가 Job 메모리 한도를 넘는 문제는 명시적 heap 한도로 수정했다. Maven의 첫 TLS 실패는 Windows-ROOT의 정상 인증서 검증으로 해결했다. Surefire 자식 JVM은 PATHEXT 누락과 `%JAVA_HOME%` 경로의 내부 셸 확장에서 실패했으며, 최종 구현은 forkCount=0으로 매 작업의 독립 Maven JVM에서 테스트하여 내부 셸 호출을 없앴다. JDK 21의 표준 오류 인코딩은 file.encoding만으로 고정되지 않아 stdout/stderr 인코딩도 명시했다. JWT 기준 테스트의 모호한 RSAKey import는 콘텐츠 담당자가 고친 후 5/5로 재검증했다. + +## Windows 감독 초기화와 임시 경로 + +Windows Server CI에서 PowerShell 모듈 자동 탐색 지연과 관리자 .NET CodeDom의 임시 디렉터리 생성 오류를 발견했다. 감독 helper는 Windows 기본 Utility manifest만 명시적으로 로드한다. CodeDom 컴파일에는 OS 기본 TEMP 아래 호출별 고유 디렉터리를 사용하고, 실제 Java/Maven 시작 전 TEMP/TMP를 원래 작업별 폴더로 복원한다. 학습자 작업 폴더의 한글·공백·`&`·`%`를 제거하는 방식이 아니다. 기존 CodeDom ACL 보호와 Job·시간·출력 제한은 유지한다. + +[.NET TempFileCollection 원문](https://github.com/microsoft/referencesource/blob/main/System/compmod/system/codedom/compiler/TempFiles.cs)과 [NativeMethods 원문](https://github.com/microsoft/referencesource/blob/main/System/compmod/microsoft/win32/NativeMethods.cs)의 관리자 분기·ANSI 경로 호출이 오류 스택과 일치했다. 로컬 코드페이지 밖 문자의 직접 호출에서도 Win32 오류123을 재현했다. 비관리자 로컬 수정 검사는16/16, JDK8 56/56·JDK21 73/73 및 양쪽 한글 진단 PASS다. 관리자 Windows CI의 수정 결과는 이후 실제 결과로 확인한다. + +감독 초기화에는 ANSI로 표현 가능한 OS 기본 TEMP 경로가 필요하다(ASCII 경로 권장). 기본 TEMP 자체가 현재 코드페이지로 표현되지 않는 경로일 때는 이 .NET 경로 제약이 남는다. 정상 완료·취소·감독 중단 후에는 이번 호출의 디렉터리 실제 부모·고유 접두사를 확인해 정리한다. 앱 부모가 강제로 종료되어 finally가 실행되지 않은 경우에는 임시 디렉터리가 남을 수 있다. 자식 프로세스 종료 검증을 모든 임시 파일의 자동 정리 보장으로 확대하지 않는다. diff --git a/server/local/process.mjs b/server/local/process.mjs index a20fa78..2e0a9f8 100644 --- a/server/local/process.mjs +++ b/server/local/process.mjs @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { readFile, writeFile, rm } from 'node:fs/promises'; +import { mkdtemp, realpath, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { LocalError } from './errors.mjs'; @@ -41,12 +42,20 @@ export async function runProcess(executable, args, { const resultPath = path.join(cwd, `.command-${invocationId}.result.json`); let command = executable; let commandArgs = args; - if (process.platform === 'win32') { - await writeFile(specPath, JSON.stringify({ executable, args, cwd, timeoutMs, memoryMiB, maxProcesses, parentPid: process.pid, resultPath }), { flag: 'wx' }); - command = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); - commandArgs = ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', windowsRunner, '-SpecPath', specPath]; - } + let commandEnvironment = env; + let supervisorDirectory; + let supervisorParent; try { + if (process.platform === 'win32') { + // CodeDom's elevated temp-directory path uses ANSI Win32 interop. Keep its + // private compilation temp separate from Unicode learner workspace paths. + supervisorParent = await realpath(tmpdir()); + supervisorDirectory = await mkdtemp(path.join(supervisorParent, `java-edu-supervisor-${invocationId}-`)); + commandEnvironment = { ...env, TEMP: supervisorDirectory, TMP: supervisorDirectory }; + await writeFile(specPath, JSON.stringify({ executable, args, cwd, timeoutMs, memoryMiB, maxProcesses, parentPid: process.pid, resultPath, childTemp: env.TEMP ?? cwd, childTmp: env.TMP ?? cwd }), { flag: 'wx' }); + command = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + commandArgs = ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', windowsRunner, '-SpecPath', specPath]; + } return await new Promise((resolve, reject) => { let child; let stdout = ''; @@ -83,7 +92,7 @@ export async function runProcess(executable, args, { const timer = setTimeout(() => stop('timeout'), timeoutMs + (process.platform === 'win32' ? 15_000 : 0)); signal?.addEventListener('abort', onAbort, { once: true }); try { - child = spawnImpl(command, commandArgs, { cwd, env, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] }); + child = spawnImpl(command, commandArgs, { cwd, env: commandEnvironment, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (error) { void finish(-1, error); return; } for (const [stream, key] of [[child.stdout, 'stdout'], [child.stderr, 'stderr']]) { stream.setEncoding('utf8'); @@ -101,6 +110,13 @@ export async function runProcess(executable, args, { if (signal?.aborted) stop('cancelled'); }); } finally { - if (process.platform === 'win32') await Promise.all([rm(specPath, { force: true }), rm(resultPath, { force: true })]); + if (process.platform === 'win32') { + await Promise.all([rm(specPath, { force: true }), rm(resultPath, { force: true })]); + if (supervisorDirectory) { + const verified = await realpath(supervisorDirectory); + if (path.dirname(verified) !== supervisorParent || !path.basename(verified).startsWith(`java-edu-supervisor-${invocationId}-`)) throw new LocalError('invalid_cleanup_path', '감독 프로세스의 임시 경로를 확인할 수 없습니다.', 500); + await rm(verified, { recursive: true, force: true }); + } + } } } diff --git a/tests/local/process.test.mjs b/tests/local/process.test.mjs index 8850455..c7ef69f 100644 --- a/tests/local/process.test.mjs +++ b/tests/local/process.test.mjs @@ -12,7 +12,7 @@ const fixedFixture = fileURLToPath(new URL('./process-fixture.mjs', import.meta. test('Windows Job supervisor preserves Unicode/metacharacter argv and stops timeout process trees', { skip: process.platform !== 'win32' }, async (t) => { const parent = await realpath(tmpdir()); - const root = await mkdtemp(path.join(parent, 'java-edu-local-test-공백 & %-')); + const root = await mkdtemp(path.join(parent, 'java-edu-local-test-공백 & % 🚀-')); t.after(async () => { const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-local-test-')); await rm(resolved, { recursive: true, force: true }); }); const env = processEnvironment(undefined, root); // Diagnose cold-host failures without adding logging or test hooks to the product helper. @@ -30,6 +30,7 @@ test('Windows Job supervisor preserves Unicode/metacharacter argv and stops time assert.ok(diagnosticSource.includes(needle), `Missing PowerShell diagnostic point: ${stage}`); diagnosticSource = diagnosticSource.replace(needle, `[Console]::Error.WriteLine('Java Edu CI stage: ${stage} ' + [DateTime]::UtcNow.ToString('o'))\n${needle}`); } + diagnosticSource = diagnosticSource.replace(' Add-Type -TypeDefinition', `[Console]::Error.WriteLine('Java Edu CI platform: codepage=' + [System.Text.Encoding]::Default.CodePage + '; elevated=' + (New-Object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator))\n Add-Type -TypeDefinition`); diagnosticSource = diagnosticSource.replace(" + $_.Exception.Message)", " + $_.Exception.ToString())"); for (const [needle, stage] of [ [' parent = OpenProcess', 'opening-parent'], @@ -44,6 +45,7 @@ test('Windows Job supervisor preserves Unicode/metacharacter argv and stops time } await writeFile(diagnosticHelper, `\ufeff${diagnosticSource}`); const argv = ['공백 포함', 'a&b%PATH%', 'a"quoted"', 'trailing\\', '']; + let compilerTemp; const echo = await runProcess(process.execPath, [fixedFixture, 'echo', ...argv], { cwd: root, env, spawnImpl: (command, args, options) => { @@ -51,12 +53,25 @@ test('Windows Job supervisor preserves Unicode/metacharacter argv and stops time assert.ok(fileIndex >= 0, 'Windows supervisor must execute its fixed script.'); const diagnosticArgs = [...args]; diagnosticArgs[fileIndex + 1] = diagnosticHelper; + compilerTemp = options.env.TEMP; + assert.equal(options.env.TMP, compilerTemp); + assert.equal(path.dirname(compilerTemp), parent); + assert.notEqual(compilerTemp, root); return spawn(command, diagnosticArgs, options); }, }); assert.equal(echo.exitCode, 0, `Node ${process.version}; supervisor echo result: ${JSON.stringify(echo)}`); t.diagnostic(`Node ${process.version}; supervisor echo completed in ${echo.durationMs}ms`); + t.diagnostic(echo.stderr.split(/\r?\n/).find((line) => line.startsWith('Java Edu CI platform:'))); assert.deepEqual(JSON.parse(echo.stdout), argv); + await assert.rejects(realpath(compilerTemp), { code: 'ENOENT' }); + assert.equal(env.TEMP, root, 'Starting the helper must not mutate the learner environment object.'); + let nextCompilerTemp; + const childEnvironment = await runProcess(process.execPath, ['-p', 'JSON.stringify([process.env.TEMP,process.env.TMP,process.cwd()])'], { cwd: root, env, spawnImpl: (command, args, options) => { nextCompilerTemp = options.env.TEMP; return spawn(command, args, options); } }); + assert.equal(childEnvironment.exitCode, 0, JSON.stringify(childEnvironment)); + assert.deepEqual(JSON.parse(childEnvironment.stdout), [root, root, root]); + assert.notEqual(nextCompilerTemp, compilerTemp, 'Supervisor compilation directories must not be shared across invocations.'); + await assert.rejects(realpath(nextCompilerTemp), { code: 'ENOENT' }); const foreignModules = path.join(root, 'foreign-modules'); const foreignUtility = path.join(foreignModules, 'Microsoft.PowerShell.Utility'); await mkdir(foreignUtility, { recursive: true }); @@ -70,11 +85,13 @@ test('Windows Job supervisor preserves Unicode/metacharacter argv and stops time assert.equal(tree.reason, 'timeout', tree.stderr); const childPid = Number(await readFile(pidfile, 'utf8')); assert.throws(() => process.kill(childPid, 0), { code: 'ESRCH' }); - const flood = await runProcess(process.execPath, [fixedFixture, 'overflow'], { cwd: root, env, outputLimit: 1024 }); + let overflowCompilerTemp; + const flood = await runProcess(process.execPath, [fixedFixture, 'overflow'], { cwd: root, env, outputLimit: 1024, spawnImpl: (command, args, options) => { overflowCompilerTemp = options.env.TEMP; return spawn(command, args, options); } }); assert.equal(flood.reason, 'output_limit'); assert.ok(Buffer.byteLength(flood.stdout) <= 1024); + await assert.rejects(realpath(overflowCompilerTemp), { code: 'ENOENT' }); const orphanPidfile = path.join(root, 'orphan-child.pid'); - const parentProcess = spawn(process.execPath, [fixedFixture, 'supervisor-parent', root, orphanPidfile], { cwd: root, env, shell: false, windowsHide: true, stdio: 'ignore' }); + const parentProcess = spawn(process.execPath, [fixedFixture, 'supervisor-parent', root, orphanPidfile], { cwd: root, env: { ...env, TEMP: parent, TMP: parent }, shell: false, windowsHide: true, stdio: 'ignore' }); t.after(() => { if (parentProcess.exitCode === null) parentProcess.kill(); }); let orphanPid; for (let retry = 0; retry < 60 && !orphanPid; retry++) { await delay(100); orphanPid = Number(await readFile(orphanPidfile, 'utf8').catch(() => '')); } @@ -90,10 +107,12 @@ test('Windows Job supervisor preserves Unicode/metacharacter argv and stops time assert.notEqual(instrumented, supervisorSource); const helperFile = path.join(root, 'instrumented-helper.ps1'); const specFile = path.join(root, 'boundary-command.json'); + const boundaryCompilerTemp = await mkdtemp(path.join(parent, 'java-edu-local-helper-test-')); + t.after(async () => { const verified = await realpath(boundaryCompilerTemp); assert.equal(path.dirname(verified), parent); assert.ok(path.basename(verified).startsWith('java-edu-local-helper-test-')); await rm(verified, { recursive: true, force: true }); }); await writeFile(helperFile, `\ufeff${instrumented}`); // Windows PowerShell 5 must read the injected Unicode fixture path as UTF-8. await writeFile(specFile, JSON.stringify({ executable: process.execPath, args: [fixedFixture, 'echo', 'boundary'], cwd: root, timeoutMs: 5000, memoryMiB: 384, maxProcesses: 8, parentPid: process.pid, resultPath: path.join(root, 'boundary-result.json') })); const powershell = path.join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe'); - const helper = spawn(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', helperFile, '-SpecPath', specFile], { cwd: root, env, shell: false, windowsHide: true, stdio: 'ignore' }); + const helper = spawn(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', helperFile, '-SpecPath', specFile], { cwd: root, env: { ...env, TEMP: boundaryCompilerTemp, TMP: boundaryCompilerTemp }, shell: false, windowsHide: true, stdio: 'ignore' }); t.after(() => { if (helper.exitCode === null) helper.kill(); }); let suspendedPid; for (let retry = 0; retry < 60 && !suspendedPid; retry++) { await delay(100); suspendedPid = Number(await readFile(createdPidfile, 'utf8').catch(() => '')); } From c245dcbbee5a2c98836cee1b7576147b2d57d802 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:19:56 +0900 Subject: [PATCH 14/29] =?UTF-8?q?test(desktop):=20=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=EC=95=B1=20=EA=B2=80=EC=A6=9D=EA=B3=BC=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=B6=9C=EC=8B=9C=20=EA=B7=BC=EA=B1=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/installed-desktop-qa-report.md | 97 ++++++++ docs/local-execution.md | 2 + docs/verification.md | 12 +- tests/browser.mjs | 2 +- tests/desktop.mjs | 2 +- tests/installed-desktop.mjs | 364 ++++++++++++++++++++++++++++ 6 files changed, 475 insertions(+), 4 deletions(-) create mode 100644 docs/installed-desktop-qa-report.md create mode 100644 tests/installed-desktop.mjs diff --git a/docs/installed-desktop-qa-report.md b/docs/installed-desktop-qa-report.md new file mode 100644 index 0000000..58d666f --- /dev/null +++ b/docs/installed-desktop-qa-report.md @@ -0,0 +1,97 @@ +# 실제 Windows 설치 패키지 QA + +판정: **PASS**. 2026-10-12 06:57:04–06:58:08 UTC에 실제 NSIS 설치 앱으로 실행한 기능 검사는 8/8 PASS였고 앱을 정상 종료했다. 총괄이 실제 설치 제거를 확인했고 독립 시각 담당도 두 보완 화면을 직접 확인하여 Windows 앱 시각 검수를 PASS로 완료했다. + +## 실제 실행 결과 + +| 검사 | 실제 결과 | +| --- | --- | +| 설치 ASAR 실행 코드 및 unpacked helper 8개 원본 바이트 대조 | PASS; 모든 파일 길이·SHA-256 일치 | +| 실제 packaged 앱·기본 사용자 데이터·보안 설정 | PASS; 설치 exe/ASAR, contextIsolation=true, nodeIntegration=false, sandbox=true, webSecurity=true, allowRunningInsecureContent=false | +| 첫 실행 안내·실제 JDK 21/Maven 등록·명시적 동의 | PASS; 미등록 실행 차단, 등록만으로 코드 실행 없음 | +| 실제 JVM 21의 코어 제출 | Java 8 대상 6/6, Java 21 대상 6/6 PASS | +| Spring/H2 tx-proxy 환경 준비 | 실제 Maven 2/2 PASS; 기존 소스 유지, 객관식·코드 성취 모두 미완료 | +| Spring/H2 tx-proxy 학습자 제출 | 실제 Maven 2/2 PASS; 편집 파일 원문만 제출, 읽기 전용 POM 제외, 성취 기록 | +| 앱 종료·재시작 | 포트 57164→59278; 진도·테마·코어 및 파일별 초안 보존, 준비 상태 재검증 필요, 완료 성취 유지 | +| 최소 창·200% 설정 끝 | 외곽 720×560, renderer 704×521, 문서 글꼴 32px; 닫기 버튼 전체 표시와 닫기 동작 PASS | + +오류 이벤트는 없었다. 기록된 앱 API 요청은 앱의 `127.0.0.1` 엔드포인트뿐이며 외부 Java 실행 제공자는 호출하지 않았다. 최초 Maven 환경 준비에는 정상 라이브러리 다운로드가 포함된다. + +실행 명령은 승인된 도구 경로와 정상 TLS 설정을 적용한 `node tests/installed-desktop.mjs`다. + +```powershell +$env:NODE_TLS_REJECT_UNAUTHORIZED='1' +$env:NODE_OPTIONS='--use-system-ca' +$env:JAVA_EDU_JDK_21='/jdk21/jdk-21.0.12.1+1' +$env:JAVA_EDU_MAVEN_HOME='/maven/apache-maven-3.10.0' +node tests/installed-desktop.mjs +``` + +실제 도구는 이전에 승인된 작업 폴더의 Temurin JDK 21.0.12.1+1, Maven 3.10.0이다. 앱 자식 프로세스에는 `JAVA_EDU_QA_DATA`, `ELECTRON_RUN_AS_NODE`, `NODE_OPTIONS`를 전달하지 않았고 기본 사용자 데이터를 사용했다. + +## 설치·산출물 근거 + +총괄이 실제 NSIS를 사용자 범위의 전용 경로에 설치했다. `installation.json`은 06:50:28 UTC 설치 exit 0을 기록한다. 총괄의 `package-installed.json`은 검토 커밋 `6ba4b8aa124f5ce78e79240253946c9fdac3e770`의 원본 195개와 생성 파일 2개가 설치 ASAR·helper에 일치함을 기록하며, 이 QA는 추가로 핵심 실행 파일 8개를 직접 바이트 대조했다. + +- 설치 EXE SHA-256: `6d4354e0b4a5c8b471591f0ee29c106f9ec65c2561847416555c38e623fd2f74` +- 설치된 앱 exe SHA-256: `31e0610596ea65191bb964dd4f20426bad6da72eb6eca0cb06200900d2b9e636` +- 설치 ASAR SHA-256: `e14c590d6415db74250a293271c97cf1ad8a910e880cd426ef67f3b4a71cbf6b` +- 실행한 테스트 SHA-256: `66db3afc4b719203a958d4efa8705f6f597c54e62bee1078976e95c7b27124cf` +- 최종 실제 결과: `work/installed-desktop-qa/installed-results-1791788288577.json` +- 설정 끝 캡처: `installed-settings-minimum200-close-button.png` +- 준비와 성취 분리 캡처: `installed-prepare-complete-still-incomplete.png` + +모든 근거는 저장소 `work/installed-desktop-qa`에 있다. 두 번째 캡처는 준비 결과 카드와 상단 미완료 성취를 동시에 보여주기 위해 실제 창 content viewport를 1424×1794로 설정했다. 전체 페이지 합성이나 DOM 이동은 하지 않았다. 요청한 설정·준비 상태를 QA가 직접 이미지로 확인했고 독립 시각 담당에게도 전달했다. + +총괄이 수행한 실제 제거는 07:01:27 UTC의 `uninstallation.json`에 기록되어 있다. NSIS exit 0, 전용 설치 폴더 없음, 제거 등록 0개, 설치 소유 바로가기 0개, 학습 데이터 보존을 확인했다. 최초 제거 후 확인은 무관한 바탕 화면 하위 폴더의 접근 거부로 중단되었으나 제거 자체는 완료되었고, 설치 소유 바로가기 5개 정확한 경로만 확인하는 후속 검사로 보완했다. 이 설치·제거 사실은 총괄의 실행 근거이며 QA가 제거 명령을 실행한 것으로 기록하지 않는다. + +독립 시각 담당은 최소 창·글자 200%의 설정 닫기 버튼과 준비 완료/학생 미완료 상태가 함께 보이는 캡처를 직접 확인하여 기존 두 검증 공백이 해소되었다고 판정했다. 해당 Windows 앱 화면에 대한 시각 판정은 PASS다. + +## 사전 검사 + +2026-10-12 06:25:27 UTC에 실제 사용자 환경에서 다음을 읽기 전용으로 확인했다. + +| 대상 | 결과 | +| --- | --- | +| `%APPDATA%/java-edu`, `%APPDATA%/Java Edu` | 모두 없음 | +| 실제 `%USERPROFILE%/AppData/Local/java-edu`, `Java Edu` | 모두 없음 | +| 실제 Local/Programs/java-edu | 없음 | +| 사용자·공용 바탕 화면 및 시작 메뉴의 Java Edu 바로가기 | 없음 | +| HKCU·HKLM·WOW6432Node의 Java Edu 제거 등록 | 없음 | +| 저장소 `work/installed-java-edu-test` | 없음 | + +샌드박스 프로세스의 `LOCALAPPDATA`가 컨테이너 경로로 바뀌는 것을 확인해 실제 사용자 Local 경로로 다시 검사했다. 기존 사용자 파일·레지스트리를 수정하지 않았고 사전 검사 결과만 저장소 `work/installed-desktop-qa/preflight.json`에 기록했다. + +## 검사 범위와 모의 처리 + +`tests/installed-desktop.mjs`는 설치를 자동 수행하지 않는다. 총괄이 확정한 NSIS를 작업 전용 경로에 설치한 이후 실행했다. 준비된 실행 파일과 JDK·Maven이 없거나 기존 사용자 앱 데이터가 발견되면 앱을 시작하지 않는다. + +검사 계약: + +1. 앱 실행 전에 설치된 `app.asar`의 `server/local/{process,engine,http,settings}.mjs`와 `desktop/{main,preload,reference-policy}.cjs`를 `@electron/asar.extractFile`로 읽어 저장소 원본과 바이트 단위 대조. 실제 실행되는 `app.asar.unpacked/desktop/job-runner.ps1`도 원본과 대조하며 파일별 길이·SHA-256·일치 여부 기록. 불일치는 FAIL로 처리하고 앱 실행 중단. +2. 실제 `app.isPackaged`, 설치 경로의 exe·ASAR, 일반 사용자 AppData 경로와 `contextIsolation`, `nodeIntegration`, `sandbox`, `webSecurity`, `allowRunningInsecureContent` 보안 설정 확인. `JAVA_EDU_QA_DATA` 우회 없음. 설치 앱이 제공하는 `app.js`, `styles.css`, `lesson-visuals.js`도 원본 대조. +3. 실제 JDK 21·Maven 검사, 명시적 실행 동의. 모의 처리는 OS 폴더 선택 대화상자의 반환값과 편집기 전체 원문 대조용 `navigator.clipboard.writeText` 수집 두 가지다. 실제 OS 클립보드 읽기·쓰기는 검증하지 않는다. 앱 API·JDK·Maven 실행은 모의 처리하지 않는다. +4. 실제 JVM 21에서 코어 Java 8 대상 및 Java 21 대상 제출. +5. Spring/H2 `tx-proxy` 실제 준비와 제출. 준비만으로 학생 성취가 생기지 않음 확인. +6. 앱 종료·재시작 후 다른 로컬 포트에서 진도·테마·코어 및 프로젝트 초안 보존. +7. 최소 외곽 창 720×560·문서 글꼴 200%에서 설정 마지막 닫기 버튼 전체 표시와 실제 닫기. +8. 준비 완료의 결과 카드 제목·안내와 학생 미완료 상태가 함께 보이는 실제 viewport 캡처. +9. 설치·제거는 총괄이 별도 기록한다. QA 스크립트는 앱을 정상 종료하며 레지스트리 변경·데이터 삭제·앱 제거를 수행하지 않는다. + +`node --check tests/installed-desktop.mjs`는 PASS다. 이는 실제 앱 검증 결과가 아니다. + +2026-10-12 06:50:57 UTC의 읽기 전용 API 점검에서 빌드 폴더 `desktop-release/win-unpacked`의 위 8개 파일과 원본의 바이트 일치가 모두 PASS였다. 이 점검은 `extractFile`의 Windows 경로 정규화까지 확인했으며 설치본 검증을 대체하지 않는다. 근거는 `work/installed-desktop-qa/build-api-proof.json`이다. 설치 EXE의 SHA-256도 총괄이 전달한 `6d4354e0b4a5c8b471591f0ee29c106f9ec65c2561847416555c38e623fd2f74`와 일치했다. + +설치 위치: `/work/installed-java-edu-test`. 산출물: `/work/installed-desktop-qa`. 공개 교재의 기준 코드와 이전에 승인된 JDK 21·Maven 경로만 사용한다. 외부 Java 제공자에 학습 코드를 보내지 않는다. + +## 첫 실행 오류와 재검증 + +첫 실행에서 설치 ASAR/helper 8개 대조, packaged/default userData·보안 설정, 실제 도구 등록은 PASS였다. JVM 21의 Java 8 대상 제출 6/6, Java 21 대상 제출 6/6 및 Spring/H2 준비 2/2도 실제 실행했다. 준비 응답의 정상 제공자는 `Local Maven`인데 검사 코드가 `Local JDK`로만 기대하여 중단했다. 이는 검사 기대값 오류이며 제품 실패로 판정하지 않는다. 수정 전 근거 `installed-results-1791787998728.json`은 보존한다. + +앱은 정상 종료했으며 이 검사로 생성한 사용자 데이터는 `C:/Users/USER/AppData/Roaming/java-edu`다. 사전 검사에서 없던 경로임을 확인했고 QA가 삭제하거나 생산 데이터 경로를 우회하지 않았다. 총괄은 첫 검사 데이터의 원래 경로·사전 부재·프로세스 종료를 확인하고 `work/installed-desktop-qa/app-data-first-run`에 보관했다. 보호 guard를 유지한 재실행은 8/8 PASS였고 새로 생성된 같은 사용자 데이터 경로를 결과에 기록했다. 총괄이 제거 후 보존된 최종 QA 데이터도 동일한 경로·사전 부재·종료·reparse 여부를 확인하여 `work/installed-desktop-qa/app-data-final-run`에 보관했고, 원래 AppData 경로가 없던 상태로 복원했다. 두 검사 데이터 백업과 첫 실패 로그는 보존했다. + +## 미확인 및 판정 경계 + +이 결과는 실제 Windows 설치 앱의 위 8개 시나리오에 대한 **PASS**다. 앱 API·JDK·Maven 응답은 모의 처리하지 않았다. 모의 폴더 선택은 실제 Windows 파일 대화상자 조작 검증을 뜻하지 않으며, 모의 복사 원문 수집은 OS 클립보드 작동 검증을 뜻하지 않는다. + +실제 모바일 기기, 인터넷에서 내려받은 unsigned 설치 파일의 SmartScreen 흐름, 다른 Windows 사용자·OS 환경, 설치본의 실제 JDK 8/11/17, Redis/Kafka 실행은 이 검사에서 수행하지 않았다. 문서 글꼴 확대는 시스템 배율·보조기술 검사와 구분한다. 이전 개발 Electron·웹 검증은 별도 보고서의 범위이며 이번 설치 앱 PASS 수에 합산하지 않았다. diff --git a/docs/local-execution.md b/docs/local-execution.md index 0e3ecaf..b4b7232 100644 --- a/docs/local-execution.md +++ b/docs/local-execution.md @@ -96,3 +96,5 @@ Windows Server CI에서 PowerShell 모듈 자동 탐색 지연과 관리자 .NET [.NET TempFileCollection 원문](https://github.com/microsoft/referencesource/blob/main/System/compmod/system/codedom/compiler/TempFiles.cs)과 [NativeMethods 원문](https://github.com/microsoft/referencesource/blob/main/System/compmod/microsoft/win32/NativeMethods.cs)의 관리자 분기·ANSI 경로 호출이 오류 스택과 일치했다. 로컬 코드페이지 밖 문자의 직접 호출에서도 Win32 오류123을 재현했다. 비관리자 로컬 수정 검사는16/16, JDK8 56/56·JDK21 73/73 및 양쪽 한글 진단 PASS다. 관리자 Windows CI의 수정 결과는 이후 실제 결과로 확인한다. 감독 초기화에는 ANSI로 표현 가능한 OS 기본 TEMP 경로가 필요하다(ASCII 경로 권장). 기본 TEMP 자체가 현재 코드페이지로 표현되지 않는 경로일 때는 이 .NET 경로 제약이 남는다. 정상 완료·취소·감독 중단 후에는 이번 호출의 디렉터리 실제 부모·고유 접두사를 확인해 정리한다. 앱 부모가 강제로 종료되어 finally가 실행되지 않은 경우에는 임시 디렉터리가 남을 수 있다. 자식 프로세스 종료 검증을 모든 임시 파일의 자동 정리 보장으로 확대하지 않는다. + +수정 후 [CI38213755330](https://github.com/jbsim999/java-edu/actions/runs/38213755330)의 Windows8/11/17/21 네 환경은 모두 실제 관리자·코드페이지1252였다. 각 로컬16/16, 실제 JVM56/61/67/73검사 및 한글 진단이 성공했다. 이 결과는 앞서 미확인이던 관리자 경로 회귀를 보완하며 ANSI로 표현 불가한 OS 기본 TEMP 자체의 제약을 없애지는 않는다. diff --git a/docs/verification.md b/docs/verification.md index 3bfe550..0a9ce6e 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -40,14 +40,22 @@ 출시 판정: **PASS — 코어 프로토타입 범위**. private Sites 배포가 `succeeded`와 운영 URL을 반환했다. 모든 GitHub 출시 파일과 Sites source tree의 Git blob을 비교해 호스팅 manifest/ignore 설정 외 동일함을 확인했다. [배포 기록](deployment.md)에 소스·버전·배포 ID를 기록했다. 배포된 인증 세션에서의 Java 제출은 미검증이며 위 제한은 유지한다. -## M5–M7 현재 구현·검증과 미완료 gate +## M5–M7 구현·검증 기록 코어12+실무22, 총34수업의 Windows 앱·웹 공용 교재를 구현했다. 독립 기능 QA의 개발 Electron9/9, 모의API 웹4/4, 기존 브라우저49개 PASS. Node35 PASS/제공자1 SKIP, Worker 정적 모듈·ZIP 검사 PASS, 로컬 계약16/16 PASS. 실제 JDK8/21 core와 Spring/H2 UI 실행은 [설치형 QA 기록](desktop-qa-report.md)을 따른다. JDK21·컴파일 대상17·Maven3.10.0에서 기본20실무프로젝트를 실제 실행해 reference54개 JUnit PASS와20개 미완성 starter 실패를 확인했다. 독립 리뷰는129소스 및 부모POM 해시·Surefire XML·메타데이터를 대조했다. Redis/Kafka는 reference/starter 컴파일만 확인했으며 실제 서비스 실행은 미검증이다. 독립 코드·교재 리뷰에는 미해결 P1/P2가 없다. -최종 Windows x64 NSIS 빌드 성공은 설치·제거 또는 설치된 바이너리 실행 성공과 다르다. 최종 시각 검수와 설치 패키지 QA는 담당 에이전트의 서비스 사용량 한도로 끝내지 못했다. 새로운 Windows8/11/17/21·실무17/21 CI는 정의를 추가했으며 원격 결과를 별도 확인한다. 최종 출시 판정은 **미완료**다. [개발 인계](development-handoff.md)에 설치 파일 해시와 재개 절차를 남겼다. +최초 통합 당시에는 설치·제거 및 최종 시각 게이트가 서비스 한도로 미완료였다. 이후 실제 설치 QA와 CI 수정 결과는 아래 Windows v0.2 게이트 기록을 따른다. [개발 인계](development-handoff.md)는 최신 작업 상태를 제공한다. ## M7.1 이론 시각 자료 34수업을 평가해 12개 도식·25패널·80노드·54관계·26개 공식 출처를 채택하고 22수업은 중복·범위·설명 이점 기준으로 보류했다. 독립 내용·코드·시각 검수 PASS, Edge 도식19/19·기존 회귀49/49·산출물 기본 경로 영향1/1 PASS. 320/360/390/768/1440px와 100/200%의 120개 조합에서 넘침을 확인했다. Worker 경로/JavaScript MIME 및 Node35 PASS/제공자1 SKIP도 확인했다. [선정 근거](theory-visuals.md)와 [독립 QA·한계](theory-visual-qa-report.md)를 따른다. 교육 효과 측정·실기기·스크린리더 음성·설치 패키지 검증은 이 판정에 포함하지 않는다. + +## Windows v0.2 출시 게이트 + +검증 소스 `6ba4b8aa124f5ce78e79240253946c9fdac3e770`, [CI38213755330](https://github.com/jbsim999/java-edu/actions/runs/38213755330) **12작업 모두 성공**. 관리자·코드페이지1252의 Windows8/11/17/21에서 각각 로컬16/16, 실제 JVM56/61/67/73검사 및 한글 컴파일 진단 PASS. 실무17/21의 기본20프로젝트·54JUnit 및 미완성 starter 실패 확인도 성공했다. 감독 모듈 탐색·CodeDom 임시 경로 문제를 수정했고 경계 제약은 [로컬 실행](local-execution.md)에 기록했다. + +최종 NSIS 설치 파일 SHA256 `6d4354e0b4a5c8b471591f0ee29c106f9ec65c2561847416555c38e623fd2f74`. 실제 사용자용 설치·설치 앱8/8·제거·Windows 시각 PASS. ASAR에 포함된 검토 파일195개+생성2개 및 unpacked helper를 비교했다. package.json은 builder가 개발 명령·개발 의존성을 제거하므로 나머지 런타임 필드로 비교하고, 다른 소스는 원본 바이트로 비교했다. 설치된 ASAR/helper/exe는 빌드 산출물과 동일하다. 첫 검사 스크립트의 Maven 제공자 기대값 오류는 고친 후 전체8개를 재실행했으며 실패 기록을 보존했다. 기존 사용자 데이터 부재를 사전 확인한 뒤 테스트 데이터는 작업 폴더에 보관하고 환경을 복원했다. [설치 QA](installed-desktop-qa-report.md)를 따른다. 출시 게이트 판정은 **PASS — 프리뷰 범위**다. 실제 릴리스 게시 결과는 [배포 기록](deployment.md)에 기록한다. + +프로젝트 이동 후 브라우저·개발 앱 검사의 기본 산출물 경로를 저장소 내부 work/로 정리했다. 경로 영향 검사 브라우저1/1·웹 실무4/4 PASS. 제한 환경의 최초 브라우저 시작 실패 후 허용된 실행 환경에서 재검사한 결과이며, 설치 앱8/8 검증과는 별개다. diff --git a/tests/browser.mjs b/tests/browser.mjs index 7214f9b..bb4018c 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -8,7 +8,7 @@ import { chromium } from 'playwright'; // They do not submit source code to the real execution provider. const projectRoot = fileURLToPath(new URL('../', import.meta.url)); const baseUrl = process.env.QA_BASE_URL || 'http://127.0.0.1:4173'; -const artifactRoot = process.env.QA_ARTIFACT_DIR || path.resolve(projectRoot, '../../work/java-edu-qa'); +const artifactRoot = process.env.QA_ARTIFACT_DIR || path.resolve(projectRoot, 'work/java-edu-qa'); const storageKey = 'java-edu:progress:v1'; const editorPreferencesKey = 'java-edu:editor-preferences:v1'; const filter = process.env.QA_FILTER ? new RegExp(process.env.QA_FILTER) : null; diff --git a/tests/desktop.mjs b/tests/desktop.mjs index 4471a34..a7a9750 100644 --- a/tests/desktop.mjs +++ b/tests/desktop.mjs @@ -11,7 +11,7 @@ import { professionalLessons, professionalExercises } from '../dist/data/profess // Web APIs are mocked. Electron uses its actual authenticated local server and // registered JDKs. No test sends learner source to a remote execution provider. const projectRoot = fileURLToPath(new URL('../', import.meta.url)); -const artifactRoot = path.resolve(process.env.QA_ARTIFACT_DIR || path.join(projectRoot, '../../work/java-edu-desktop-qa')); +const artifactRoot = path.resolve(process.env.QA_ARTIFACT_DIR || path.join(projectRoot, 'work/java-edu-desktop-qa')); const baseUrl = process.env.QA_BASE_URL || 'http://127.0.0.1:4173'; const mode = process.env.JAVA_EDU_QA_MODE || 'all'; const results = []; diff --git a/tests/installed-desktop.mjs b/tests/installed-desktop.mjs new file mode 100644 index 0000000..aed8aa4 --- /dev/null +++ b/tests/installed-desktop.mjs @@ -0,0 +1,364 @@ +import assert from 'node:assert/strict'; +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { _electron as electron } from 'playwright'; +import { extractFile } from '@electron/asar'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { exercises as coreExercises } from '../dist/data/exercises.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; + +// Manual release gate, run only after the owner has authorized and completed the +// NSIS test installation. This script never installs/uninstalls software, changes +// registry values, redirects production userData, or deletes application data. +const root = path.resolve(fileURLToPath(new URL('../', import.meta.url))); +const installRoot = path.join(root, 'work/installed-java-edu-test'); +const executablePath = path.join(installRoot, 'Java Edu.exe'); +const artifacts = path.join(root, 'work/installed-desktop-qa'); +const preflightPath = path.join(artifacts, 'preflight.json'); +const jdk = process.env.JAVA_EDU_JDK_21; +const maven = process.env.JAVA_EDU_MAVEN_HOME; +const core = coreLessons.find(lesson => lesson.id === 'java-basics'); +const modern = coreLessons.find(lesson => lesson.id === 'java21'); +const project = professionalLessons.find(lesson => lesson.id === 'tx-proxy'); +const projectExercise = professionalExercises.find(exercise => exercise.id === project.exerciseId); +const storageKey = 'java-edu:progress:v1'; +const preferencesKey = 'java-edu:editor-preferences:v1'; +const results = []; +const evidence = { startedAt: new Date().toISOString(), kind: 'Installed production Electron; actual local JDK/Maven; directory dialog return value and navigator.clipboard.writeText readback are mocked; no real OS clipboard verification', executablePath, installRoot, artifacts }; +const errors = []; +const requests = []; +let app; +let page; + +const exists = file => access(file).then(() => true, () => false); +const normalize = value => path.resolve(value).toLowerCase(); +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const editor = () => page.locator('#code-editor [role="textbox"]'); +const badge = type => page.locator(`#lesson-achievements [data-achievement="${type}"]`).getAttribute('data-complete'); + +async function eventually(read, expected, message, timeout = 15000) { + const until = Date.now() + timeout; + let value; + while (Date.now() < until) { + value = await read(); + if (typeof expected === 'function' ? expected(value) : value === expected) return value; + await new Promise(resolve => setTimeout(resolve, 80)); + } + assert.fail(`${message}: ${JSON.stringify(value)}`); +} + +async function screenshot(name) { + await page.locator('#toast.is-visible').waitFor({ state: 'hidden', timeout: 6000 }); + await page.screenshot({ path: path.join(artifacts, `${name}.png`), fullPage: false }); +} + +async function check(name, body) { + const started = Date.now(); + console.log(`START ${name}`); + try { + await body(); + results.push({ name, status: 'PASS', durationMs: Date.now() - started }); + console.log(`PASS ${name}`); + } catch (error) { + if (page) await screenshot(`failure-${name}`).catch(() => {}); + results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - started }); + throw error; + } +} + +async function launch() { + const env = { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: '1' }; + delete env.JAVA_EDU_QA_DATA; + delete env.ELECTRON_RUN_AS_NODE; + delete env.NODE_OPTIONS; + app = await electron.launch({ executablePath, args: [], env, timeout: 60000 }); + page = await app.firstWindow(); + page.setDefaultTimeout(15000); + page.on('pageerror', error => errors.push(error.message)); + page.on('request', request => { + const url = new URL(request.url()); + if (url.pathname.startsWith('/api/')) requests.push({ origin: url.origin, path: url.pathname, method: request.method() }); + }); + await page.locator('#workspace-grid').waitFor({ state: 'visible', timeout: 45000 }); + await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => { window.qaClipboardText = text; } } })); +} + +async function openLesson(lesson, step = 'lab') { + await page.evaluate(hash => { location.hash = hash; }, `lesson=${lesson.id}&step=${step}&version=21`); + await eventually(() => page.locator('#lesson-title').textContent(), text => text?.includes(lesson.title), 'requested lesson is selected'); + await eventually(() => page.locator(`[data-step="${step}"]`).getAttribute('aria-selected'), 'true', 'requested learning stage is selected'); +} + +async function source() { + await page.evaluate(() => { window.qaClipboardText = undefined; }); + await page.locator('#copy-code').click(); + return eventually(() => page.evaluate(() => window.qaClipboardText), text => typeof text === 'string', 'current entire source is copied'); +} + +async function quiz(lesson) { + await openLesson(lesson, 'quiz'); + for (const question of lesson.quiz) await page.locator(`#quiz-form [name="quiz-${question.id}"][value="${question.correctIndex}"]`).check(); + await page.locator('#quiz-submit').click(); + await eventually(() => badge('quiz'), 'true', 'quiz achievement is recorded'); + await openLesson(lesson); +} + +async function settings() { + await page.locator('#desktop-settings-open').click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'visible' }); + await eventually(() => page.locator('#execution-consent').isEnabled(), true, 'settings loaded'); +} + +async function mockDirectorySelection(directory) { + await app.evaluate(({ dialog }, selected) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [selected] }); }, directory); +} + +async function execute(button, timeout = 60000) { + const pathname = button === '#prepare-project' ? '/api/local/prepare' : '/api/execute'; + const pending = page.waitForResponse(response => new URL(response.url()).pathname === pathname && response.request().method() === 'POST', { timeout }); + void pending.catch(() => {}); + await page.locator(button).click(); + const response = await pending; + const result = await response.json(); + const request = response.request().postDataJSON(); + evidence.executions ??= []; + evidence.executions.push({ exerciseId: request.exerciseId, action: request.action || 'prepare', httpStatus: response.status(), ...result }); + console.log('ACTUAL_INSTALLED_RESULT', JSON.stringify({ exerciseId: request.exerciseId, action: request.action || 'prepare', status: result.status, runtimeVersion: result.runtimeVersion, targetVersion: result.targetVersion, passed: result.passed, total: result.total, error: result.error })); + await eventually(() => page.locator('#execution-output').getAttribute('data-status'), status => status !== 'running', 'execution response is rendered'); + assert.equal(response.status(), 200); + const expectedProvider = professionalExercises.some(exercise => exercise.id === request.exerciseId) ? 'Local Maven' : 'Local JDK'; + assert.equal(result.provider, expectedProvider); + assert.equal(result.runtimeVersion, 21); + return { result, request }; +} + +async function preparedEvidence() { + const selectors = ['#lesson-achievements', '#project-preparation-state', '#execution-output .execution-panel-heading', '#execution-output .execution-result-label']; + const measure = () => page.evaluate(selectors => { + const rectangles = selectors.map(selector => { const r = document.querySelector(selector).getBoundingClientRect(); return { selector, top: r.top + scrollY, bottom: r.bottom + scrollY }; }); + return { min: Math.min(...rectangles.map(r => r.top)), max: Math.max(...rectangles.map(r => r.bottom)), rectangles }; + }, selectors); + const before = await measure(); + // A taller native viewport preserves the real page layout and simultaneously + // shows preparation success plus incomplete learner achievements. No DOM is moved. + const height = Math.max(920, Math.ceil(before.max - before.min) + 100); + await app.evaluate(({ BrowserWindow }, height) => BrowserWindow.getAllWindows()[0].setContentSize(1424, height), height); + await eventually(() => page.evaluate(() => innerHeight), actual => actual >= height, 'native viewport includes preparation summary and learner badges'); + const after = await measure(); + await page.evaluate(top => scrollTo({ top: Math.max(0, top - 30), behavior: 'instant' }), after.min); + const visibility = await page.evaluate(selectors => ({ width: innerWidth, height: innerHeight, nodes: selectors.map(selector => { const r = document.querySelector(selector).getBoundingClientRect(); return { selector, top: r.top, bottom: r.bottom }; }) }), selectors); + assert.ok(visibility.nodes.every(r => r.top >= 0 && r.bottom <= visibility.height), JSON.stringify(visibility)); + evidence.preparedViewport = visibility; + await screenshot('installed-prepare-complete-still-incomplete'); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 940)); +} + +async function main() { + await mkdir(artifacts, { recursive: true }); + const preflight = JSON.parse((await readFile(preflightPath, 'utf8')).replace(/^\uFEFF/, '')); + assert.ok(Array.isArray(preflight.AppData) && preflight.AppData.length === 4, 'recorded real-user preflight is required'); + assert.ok(preflight.AppData.every(item => item.Exists === false), 'pre-existing application data must be preserved'); + assert.equal(preflight.Shortcuts.length, 0, 'pre-existing shortcuts must be preserved'); + assert.equal(preflight.InstalledEntries.length, 0, 'pre-existing installation must be preserved'); + assert.equal(normalize(preflight.TestInstallPath), normalize(installRoot)); + for (const item of preflight.AppData) assert.equal(await exists(item.Path), false, `STOP: user application data exists: ${item.Path}`); + assert.ok(jdk && maven, 'approved JDK21 and Maven paths must be supplied'); + await access(path.join(jdk, 'bin/java.exe')); + await access(path.join(maven, 'boot')); + await access(executablePath); + await access(path.join(installRoot, 'resources/app.asar')); + await access(path.join(installRoot, 'resources/app.asar.unpacked/desktop/job-runner.ps1')); + evidence.preflight = preflight; + evidence.executableSha256 = createHash('sha256').update(await readFile(executablePath)).digest('hex'); + evidence.asarSha256 = createHash('sha256').update(await readFile(path.join(installRoot, 'resources/app.asar'))).digest('hex'); + await check('installed-package-execution-byte-parity', async () => { + evidence.installedExecutionFiles = []; + const asarPath = path.join(installRoot, 'resources/app.asar'); + for (const relative of ['server/local/process.mjs', 'server/local/engine.mjs', 'server/local/http.mjs', 'server/local/settings.mjs', 'desktop/main.cjs', 'desktop/preload.cjs', 'desktop/reference-policy.cjs', 'desktop/job-runner.ps1']) { + const sourceBytes = await readFile(path.join(root, relative)); + const unpacked = relative === 'desktop/job-runner.ps1'; + const installedBytes = unpacked + ? await readFile(path.join(installRoot, 'resources/app.asar.unpacked', relative)) + : extractFile(asarPath, path.normalize(relative)); + const comparison = { path: relative, location: unpacked ? 'app.asar.unpacked' : 'app.asar', sourceBytes: sourceBytes.length, installedBytes: installedBytes.length, sourceSha256: sha256(sourceBytes), installedSha256: sha256(installedBytes), equal: installedBytes.equals(sourceBytes) }; + evidence.installedExecutionFiles.push(comparison); + assert.ok(comparison.equal, `Installed ${comparison.location}/${relative} differs from the frozen source: expected ${comparison.sourceSha256}, actual ${comparison.installedSha256}`); + } + }); + const solutions = JSON.parse(await readFile(path.join(root, 'examples/solutions.json'), 'utf8')); + await launch(); + const firstOrigin = new URL(page.url()).origin; + await check('installed-packaged-startup-and-default-user-data', async () => { + const facts = await app.evaluate(({ app, BrowserWindow }) => ({ packaged: app.isPackaged, executable: app.getPath('exe'), appPath: app.getAppPath(), userData: app.getPath('userData'), preferences: BrowserWindow.getAllWindows()[0].webContents.getLastWebPreferences() })); + assert.equal(facts.packaged, true); + assert.equal(normalize(facts.executable), normalize(executablePath)); + assert.ok(facts.appPath.endsWith('app.asar')); + assert.ok(preflight.AppData.some(item => normalize(item.Path) === normalize(facts.userData)), 'production default AppData, no QA override'); + assert.equal(facts.preferences.contextIsolation, true); + assert.equal(facts.preferences.nodeIntegration, false); + assert.equal(facts.preferences.sandbox, true); + assert.equal(facts.preferences.webSecurity, true); + assert.equal(facts.preferences.allowRunningInsecureContent, false); + evidence.application = { ...facts, preferences: { contextIsolation: facts.preferences.contextIsolation, nodeIntegration: facts.preferences.nodeIntegration, sandbox: facts.preferences.sandbox, webSecurity: facts.preferences.webSecurity, allowRunningInsecureContent: facts.preferences.allowRunningInsecureContent } }; + evidence.installedAssetHashes = {}; + for (const relative of ['app.js', 'styles.css', 'data/lesson-visuals.js']) { + const actual = await page.evaluate(async relative => { + const response = await fetch(`/${relative}`); + if (!response.ok) throw new Error(`Installed asset ${relative}: HTTP ${response.status}`); + return response.text(); + }, relative); + assert.equal(actual, await readFile(path.join(root, 'dist', relative), 'utf8'), `installed ${relative} matches the frozen source`); + evidence.installedAssetHashes[relative] = createHash('sha256').update(actual).digest('hex'); + } + await openLesson(core, 'theory'); + assert.equal(await page.locator('.theory-visual').count(), 1, 'new theory data ships inside the installed package'); + await openLesson(core); + const runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); + assert.equal(runtime.mode, 'local'); + assert.equal(runtime.consent.accepted, false); + assert.equal(runtime.runtimes.some(item => item.available), false); + assert.equal(await page.locator('#run-code').isDisabled(), true); + await screenshot('installed-first-launch'); + }); + + await check('installed-real-tool-registration-and-consent', async () => { + await settings(); + await mockDirectorySelection(jdk); + await page.locator('#jdk-register-21').click(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).runtimes.find(item => item.javaVersion === 21).available, true, 'packaged helper inspects real JDK21', 40000); + await eventually(() => page.locator('#jdk-register-21').isEnabled(), true, 'JDK registration finished'); + await mockDirectorySelection(maven); + await page.locator('#maven-register').click(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).maven.available, true, 'Maven distribution inspected'); + await page.locator('#execution-consent').check(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, true, 'explicit consent saved'); + await screenshot('installed-configured-settings'); + await page.locator('#desktop-settings-dialog [data-close-dialog="desktop-settings-dialog"]').first().click(); + assert.equal(requests.some(request => request.path === '/api/execute'), false); + }); + + await check('installed-jdk21-core-target8-and21-submissions', async () => { + for (const lesson of [core, modern]) { + await openLesson(lesson); + await editor().fill(solutions[lesson.exerciseId]); + await quiz(lesson); + const { result, request } = await execute('#submit-code'); + assert.equal(result.status, 'passed', result.stderr || result.compilerOutput); + assert.equal(result.targetVersion, coreExercises.find(exercise => exercise.id === lesson.exerciseId).minVersion); + assert.ok(result.total > 0 && result.passed === result.total && result.checks.every(check => check.passed)); + assert.equal(request.source, solutions[lesson.exerciseId]); + assert.equal(await badge('lab'), 'true'); + } + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await screenshot('installed-java21-submitted'); + }); + + await check('installed-spring-h2-preparation-not-learner-completion', async () => { + await openLesson(project); + await eventually(() => page.locator('#prepare-project').isEnabled(), true, 'packaged project metadata loaded'); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + const before = await source(); + const { result, request } = await execute('#prepare-project', 360000); + assert.equal(result.status, 'passed', result.stderr || result.stdout); + assert.equal(result.targetVersion, 17); + assert.equal(result.prepared, true); + assert.equal(request.source, undefined); + assert.equal(request.files, undefined); + assert.equal(await source(), before); + assert.equal(await badge('quiz'), 'false'); + assert.equal(await badge('lab'), 'false'); + await eventually(() => page.locator('#project-preparation-state').getAttribute('data-prepared'), 'true', 'preparation availability updated'); + assert.equal(await page.locator('#execution-output .execution-result-label').textContent(), '환경 준비 완료'); + await preparedEvidence(); + }); + + await check('installed-spring-h2-reference-submit', async () => { + const expectedFiles = {}; + for (const file of project.professionalExercise.files.filter(item => item.editable)) { + expectedFiles[file.path] = await readFile(path.join(root, 'curriculum/professional', file.referencePath), 'utf8'); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().fill(expectedFiles[file.path]); + } + await quiz(project); + const { result, request } = await execute('#submit-code', 180000); + assert.equal(result.status, 'passed', result.stderr || result.stdout); + assert.equal(result.targetVersion, 17); + assert.ok(result.total > 0 && result.passed === result.total && result.checks.every(check => check.passed)); + assert.deepEqual(request.files, expectedFiles); + assert.equal(Object.hasOwn(request.files, 'pom.xml'), false); + assert.equal(await badge('lab'), 'true'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await screenshot('installed-spring-h2-submitted'); + }); + + await check('installed-restart-preserves-progress-theme-and-drafts', async () => { + await openLesson(core); + const draft = `${solutions[core.exerciseId]}\n// installed restart draft\n`; + await editor().fill(draft); + await page.locator('#editor-theme-select').selectOption('contrast'); + await openLesson(project); + const file = projectExercise.files.find(item => item.editable); + const fileDraft = `${file.source}\n// installed project restart draft\n`; + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().fill(fileDraft); + await openLesson(modern, 'theory'); + const stored = () => page.evaluate(keys => Object.fromEntries(keys.map(key => [key, window.javaEduDesktop.storage.getItem(key)])), [storageKey, preferencesKey]); + const before = await stored(); + await app.close(); + app = null; + await launch(); + const after = await stored(); + evidence.origins = [firstOrigin, new URL(page.url()).origin]; + assert.notEqual(evidence.origins[0], evidence.origins[1]); + assert.equal(after[preferencesKey], before[preferencesKey]); + for (const field of ['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'notes']) assert.deepEqual(JSON.parse(after[storageKey])[field], JSON.parse(before[storageKey])[field], `${field} persists across the port change`); + await openLesson(core); + assert.equal(await source(), draft); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'contrast'); + await openLesson(project); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + assert.equal(await source(), fileDraft); + assert.equal(await page.locator('#prepare-project').getAttribute('data-prepared'), 'false'); + await openLesson(modern); + assert.equal(await badge('quiz'), 'true'); + assert.equal(await badge('lab'), 'true'); + await screenshot('installed-restarted-achievement'); + }); + + await check('installed-minimum-window200-settings-close-is-visible', async () => { + await settings(); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(720, 560)); + await eventually(() => page.evaluate(() => innerWidth), width => width < 800, 'minimum native window size applied'); + await page.locator('html').evaluate(node => { node.style.fontSize = '200%'; }); + const close = page.locator('#desktop-settings-dialog .dialog-actions [data-close-dialog="desktop-settings-dialog"]'); + await close.evaluate(node => node.scrollIntoView({ behavior: 'instant', block: 'end' })); + const visibility = await close.evaluate(node => { const r = node.getBoundingClientRect(); const d = node.closest('dialog').getBoundingClientRect(); return { width: innerWidth, height: innerHeight, rootFont: getComputedStyle(document.documentElement).fontSize, button: { top: r.top, bottom: r.bottom, left: r.left, right: r.right }, dialog: { top: d.top, bottom: d.bottom } }; }); + assert.equal(visibility.rootFont, '32px'); + assert.ok(visibility.button.top >= 0 && visibility.button.bottom <= visibility.height && visibility.button.top >= visibility.dialog.top && visibility.button.bottom <= visibility.dialog.bottom, JSON.stringify(visibility)); + assert.ok(visibility.button.left >= 0 && visibility.button.right <= visibility.width); + evidence.minimumSettingsViewport = visibility; + await screenshot('installed-settings-minimum200-close-button'); + await close.click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'hidden' }); + }); + assert.deepEqual(errors, []); + assert.ok(requests.every(request => new URL(request.origin).hostname === '127.0.0.1')); +} + +try { await main(); } +catch (error) { + if (!results.some(result => result.status === 'FAIL')) results.push({ name: 'environment-or-preflight', status: 'BLOCKED', message: error.message }); + console.error(error.stack); + process.exitCode = 1; +} finally { + await app?.close().catch(() => {}); + evidence.finishedAt = new Date().toISOString(); + evidence.results = results; + evidence.uncaughtBrowserErrors = errors; + evidence.apiRequests = requests; + await mkdir(artifacts, { recursive: true }); + await writeFile(path.join(artifacts, `installed-results-${Date.now()}.json`), JSON.stringify(evidence, null, 2)); + console.log(JSON.stringify({ results, artifacts, application: evidence.application, origins: evidence.origins }, null, 2)); +} From 606168787eb3afdd68413ff68d3dcfd484e5ba2e Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:28:21 +0900 Subject: [PATCH 15/29] =?UTF-8?q?docs(web):=20=ED=94=84=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EC=B6=9C=EC=8B=9C=EC=99=80=20=EA=B2=80=EC=A6=9D=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=83=81=ED=83=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +++++++++------- docs/deployment.md | 10 +++++++++- docs/desktop-qa-report.md | 2 +- docs/development-handoff.md | 40 ++++++++++++++++--------------------- docs/installation.md | 2 +- docs/milestones.md | 6 +++--- 6 files changed, 41 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index c910ef1..8b13003 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 다른 언어 경험자의 Java 전환 학습부터 현직 개발자의 OOP·백엔드 심화까지. -[![Java](https://img.shields.io/badge/Java-8%20%7C%2011%20%7C%2017%20%7C%2021-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/java-version-matrix.md) [![Windows](https://img.shields.io/badge/Desktop-Windows%20x64-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) [![Lessons](https://img.shields.io/badge/Lessons-34-806223?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) [![Status](https://img.shields.io/badge/Status-v0.2%20in%20development-806223?style=flat-square)](https://github.com/jbsim999/java-edu/issues) [![CI](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml/badge.svg?branch=feat%2Fjava-learning-mvp)](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml?query=branch%3Afeat%2Fjava-learning-mvp) +[![Java](https://img.shields.io/badge/Java-8%20%7C%2011%20%7C%2017%20%7C%2021-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/java-version-matrix.md) [![Windows](https://img.shields.io/badge/Desktop-Windows%20x64-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) [![Lessons](https://img.shields.io/badge/Lessons-34-806223?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) [![Status](https://img.shields.io/badge/Status-v0.2.0%20preview-806223?style=flat-square)](https://github.com/jbsim999/java-edu/issues) [![CI](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml/badge.svg?branch=feat%2Fjava-learning-mvp)](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml?query=branch%3Afeat%2Fjava-learning-mvp) [교육 로드맵](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) · [개발 소스](https://github.com/jbsim999/java-edu/tree/feat/java-learning-mvp) · [기여 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/CONTRIBUTING.md) · [버그·제안](https://github.com/jbsim999/java-edu/issues) @@ -20,7 +20,7 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교 수업은 **이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩** 순서로 진행합니다. Java 언어·표준 API는 Oracle/OpenJDK 공식 문서를, 프레임워크·라이브러리는 각 프로젝트의 공식 문서를 기준으로 합니다. 실무 주제에는 출처를 기록한 채용 공고의 기술 요건도 연결합니다. -> **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. Windows v0.2 설치 파일은 최종 검증 전으로 아직 릴리스하지 않았습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 34수업·이론 도식 12개·편집기 테마가 배포돼 있습니다. +> **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. [Windows v0.2.0 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)를 설치해 학습할 수 있습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 34수업·이론 도식 12개·편집기 테마가 배포돼 있습니다. ## 학습실 미리보기 @@ -59,7 +59,9 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교 ## 빠르게 시작하기 -현재는 개발 소스에서 실행할 수 있습니다. **Node.js 22 이상**이 필요하며 Windows 앱의 Java 실습에는 **JDK**, Spring 실습에는 **Maven 3.9 이상**도 준비합니다. +**Windows 학습자:** [v0.2.0 설치 파일](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)을 내려받아 설치하고 앱에서 JDK 폴더를 등록합니다. Spring 실습에는 Maven 3.9 이상도 등록합니다. JDK/Maven은 설치 파일에 포함하지 않습니다. [설치 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) + +**개발 소스 실행:** Node.js 22 이상이 필요합니다. ```sh git clone --branch feat/java-learning-mvp https://github.com/jbsim999/java-edu.git @@ -90,7 +92,7 @@ npm run desktop npm run build:desktop ``` -`desktop-release/`에 Windows x64 NSIS 설치 파일을 생성합니다. 현재 빌드는 코드 서명되지 않았으며 설치·제거 및 설치된 앱의 최종 검증이 남아 있습니다. 공개 설치 파일은 검증 후 [Releases](https://github.com/jbsim999/java-edu/releases)에 게시할 예정입니다. +`desktop-release/`에 Windows x64 NSIS 설치 파일을 생성합니다. 배포된 v0.2.0은 실제 설치·앱 실행·제거 검증을 통과한 코드 서명되지 않은 프리뷰입니다. 직접 재빌드한 파일은 배포 파일과 별도로 검증해야 합니다. [설치 파일과 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0) @@ -119,7 +121,7 @@ npm run build:desktop 실무 검사는 `JAVA_HOME`·`MAVEN_HOME` 설정이 필요합니다. 기본 검사는 Redis/Kafka를 제외합니다. 브라우저 검사는 실행 제공자의 실제 동작 검증과 구분합니다. -2026-10-12 기준, 로컬에서 기본 20실무프로젝트의 기준 검사 **54개**와 개발 앱 **9개 흐름**을 확인했습니다. 최신 CI는 Windows 로컬 실행 작업의 실패가 남아 있으며 설치 패키지와 최종 시각 검수도 미완료입니다. CI 배지는 개발 브랜치의 실제 결과를 표시합니다. [검증 기록](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/verification.md) · [앱 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/desktop-qa-report.md) +2026-10-12 기준, 출시 소스 `6ba4b8a`의 [CI 12작업](https://github.com/jbsim999/java-edu/actions/runs/38213755330)이 모두 성공했습니다. 실제 설치 앱 **8/8**, 설치·제거, 독립 시각 검수 PASS. 이론 도식 **19/19**, 기존 브라우저 회귀 **49/49**, 기본 20실무프로젝트 **54개 JUnit 검사**도 통과했습니다. CI 배지는 개발 브랜치의 현재 결과를 표시하며, 검증 범위와 미확인은 기록을 따릅니다. [검증 기록](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/verification.md) · [앱 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/desktop-qa-report.md) ## 문서와 로드맵 @@ -131,6 +133,7 @@ npm run build:desktop | 이론 도식의 선정과 검수 | [34수업 평가·12개 채택 근거](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/theory-visuals.md) · [독립 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/theory-visual-qa-report.md) | | 채용 공고 조사 근거 | [출처·표본·조사 범위](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/job-evidence.md) | | 구조와 실행 경계 | [아키텍처](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/architecture.md) | +| 설치된 앱의 실행·제거 검증 | [설치 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installed-desktop-qa-report.md) | | 현재 작업·출시 상태 | [마일스톤](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/milestones.md) · [개발 인계](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/development-handoff.md) | - [x] Java 코어 12수업과 직접 코딩 과제 @@ -138,9 +141,9 @@ npm run build:desktop - [x] Windows 로컬 JDK/Maven 실행과 진도 저장 구현 - [x] 실무 22수업과 기본 20프로젝트의 기준 검증 - [x] 이론 도식 12개와 공식 출처·텍스트 설명 -- [ ] Windows CI 실패 해결·설치/제거 검증·최종 시각 검수 +- [x] Windows CI·설치/제거 검증·최종 시각 검수 - [x] 34수업·이론 도식 웹 교재 배포 -- [ ] Windows v0.2 릴리스 +- [x] [Windows v0.2.0 프리뷰 릴리스](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0) - [ ] 선택 과정의 Redis/Kafka 실제 연동 검증 ## 기여와 문의 diff --git a/docs/deployment.md b/docs/deployment.md index d5e8d79..140f5ad 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -21,7 +21,7 @@ 다음 배포는 현재 프로젝트 ID·접근 범위를 재사용하고 새 source SHA의 CI·독립 검수를 확인한 뒤 저장된 버전으로 배포한다. 이전 버전 재배포 시에도 저장된 정확한 버전 ID와 당시 계약을 확인한다. 공개 접근 전환이나 별도 실행 자원은 추가 결정 대상이다. -## M4.1 편집기 배포 및 v0.2 미배포 상태 +## M4.1 당시 편집기 배포 상태 M4.1 GitHub 소스 `a4d277e10f6c9e03b6a14f4fb6a11a2672987b54`, CI38204310833 전체6작업 성공. Sites source SHA `de02f178b7e236805e4e1594b7f7f4634ff0dd1e`, 버전 `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_91e1ae45c3f88191b32bc7c994e34ad2`, 배포 `appgdep_6acc654d2f788191b0fcec1f82f8738a`가 2026-10-12 04:43:08 UTC에 succeeded를 반환했다. 같은 개인 전용 URL에 코어12수업과 문법 강조·3테마를 제공한다. @@ -44,3 +44,11 @@ M4.1 당시에는 Windows v0.2.0 설치 파일의 로컬 빌드만 완료됐고 공식 Sites helper로 연 기존 체크아웃에 GitHub 검증 커밋의 파일260개를 복사하고 Git blob SHA를 전부 대조했다. 정확히 같은 소스를 `npm ci`(audit0)→Worker 빌드→Worker 정적·인증 검사→source commit/push→archive 순서로 준비했다. 이전 opening result의 SHA는 체크아웃 기준 정보이며, 배포에는 helper가 새로 반환한 pushed source SHA만 전달했다. 자동 승인 검토가 초기 패키징 입력을 오래된 SHA·만료 가능 인증정보로 판단해 거절했으나, 260파일 일치 확인·공식 helper 계약 확인·새 인증정보 후 재검토에서 진행됐다. 토큰은 저장·출력하지 않았다. 웹 관련 8개 CI 작업과 내용·기능·시각 검수는 PASS다. 전체 CI의 Windows4 실패는 감독 PowerShell의 .NET CodeDom 임시 경로 초기화에서 발생하며 Windows 설치형 수정·검증에서 추적한다. 실무 웹은 교재·파일 편집·ZIP 다운로드를 제공하고 Spring을 호스팅에서 실행하지 않는다. 실제 인증 세션 Java 제출·실기기·학습 효과는 이번 배포 검증에 포함하지 않는다. + +## Windows v0.2.0 프리뷰 출시 + +2026-10-12 07:21:26 UTC에 [GitHub v0.2.0 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)를 게시했다. 태그와 릴리스 target은 검증 소스 `6ba4b8aa124f5ce78e79240253946c9fdac3e770`이며 후속 검증 문서 커밋과 구분한다. [CI38213755330](https://github.com/jbsim999/java-edu/actions/runs/38213755330) 12작업이 모두 성공했다. 앞선 웹 배포 당시 Windows4 실패는 이 소스에서 해결·재검증됐다. + +설치 EXE와 SHA256SUMS가 모두 uploaded 상태다. EXE 크기는111753633바이트, GitHub asset digest는 `sha256:6d4354e0b4a5c8b471591f0ee29c106f9ec65c2561847416555c38e623fd2f74`로 로컬 검증 파일과 일치한다. SHA256SUMS 파일의 digest도 `sha256:54694a213fadad9a0414c805d7346aa1f930f6ebb7380afbc9808ccf0edd0ffe`로 일치한다. + +실제 NSIS 설치·설치 앱8/8·정상 제거·데이터 보존 및 독립 시각 검수 PASS. 설치 ASAR/helper/exe와 빌드본 동일성, 검토 소스·생성 파일 대조를 확인했다. [설치 QA](installed-desktop-qa-report.md)에 검사 경계·초기 스크립트 오류·실제 사용 도구·미검증을 기록했다. 설치 파일은 코드 서명되지 않은 프리뷰다. Redis/Kafka 실제 연동·SmartScreen·실기기·스크린리더는 이번 출시 검증에 포함하지 않는다. 기존 웹의 소유자 전용 접근과 구현 PR의 미병합 상태를 유지한다. diff --git a/docs/desktop-qa-report.md b/docs/desktop-qa-report.md index 93847b4..e472420 100644 --- a/docs/desktop-qa-report.md +++ b/docs/desktop-qa-report.md @@ -1,6 +1,6 @@ # M5·M6 독립 기능 QA -검증일: 2026-10-12. 기능 판정: **PASS**. 독립 시각 검수는 별도 담당자의 최종 판정을 기다린다. 이 보고서의 앱 검증 대상은 개발 모드 Electron이며, 설치 프로그램 검증과 배포 환경 검증은 포함하지 않는다. +검증일: 2026-10-12. 기능 판정: **PASS**. 당시 시각 검수는 별도 게이트였다. 이후 최종 시각 PASS 및 실제 설치 앱8/8·제거 결과는 [설치 QA](installed-desktop-qa-report.md)를 따른다. 이 보고서의 앱 검증 대상은 개발 모드 Electron이며, 설치 프로그램 검증과 배포 환경 검증은 포함하지 않는다. ## 환경과 실행 경계 diff --git a/docs/development-handoff.md b/docs/development-handoff.md index 1486a09..79779a6 100644 --- a/docs/development-handoff.md +++ b/docs/development-handoff.md @@ -1,41 +1,35 @@ # Java Edu 개발 인계 — 2026-10-12 -프로젝트 폴더는 이 문서의 상위 저장소 루트 `java-learning-lab`이다. 원격은 https://github.com/jbsim999/java-edu, 브랜치는 `feat/java-learning-mvp`, PR은 https://github.com/jbsim999/java-edu/pull/1 이다. 파일 이동이나 새 clone 없이 현재 폴더를 로컬 프로젝트의 주 폴더로 등록하면 된다. 새 대화는 AGENTS.md, CONTRIBUTING.md, 이 문서를 먼저 읽는다. +프로젝트 루트는 이 문서의 상위 저장소 java-learning-lab이다. 원격은 https://github.com/jbsim999/java-edu, 구현 브랜치는 `feat/java-learning-mvp`, PR은 https://github.com/jbsim999/java-edu/pull/1 이다. main에는 README와 소개 이미지가 반영돼 있고, 구현 PR은 병합하지 않았다. 새 대화는 AGENTS.md·CONTRIBUTING.md·이 문서를 먼저 읽는다. -## 승인된 목표 +## 승인된 목표와 현재 결과 -- 다른 언어 경험자·현직자 대상. OOP·타입·동시성 심화와 공식 Java8/11/17/21 기준. -- 이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩. 클래스 전체·메서드·로직과 다중 파일 과제. -- Windows 설치형 + 같은 웹 교재. 기본 실행은 학습자 PC의 JDK/Maven/H2이며 호스팅 컨테이너 서버가 필요하지 않다. -- 마일스톤 커밋·푸시 및 검증된 앱·웹의 배포는 사용자 승인됨. 강제 푸시·PR 병합·유료 구매·사이트 공개 범위 변경은 승인되지 않음. +다른 언어 경험자·현직자를 대상으로 공식 Java8/11/17/21, 깊은 OOP·타입·동시성 및 실무를 제공한다. 이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩의34수업·68문항·34과제다. Spring/REST/Lombok/MyBatis/JPA/트랜잭션/Security/OIDC/JWT/관측/빌드·운영과 선택 Redis/Kafka를 포함한다. -## 구현과 실제 검증 +Windows 설치형+웹 교재를 제공한다. 기본 실무20프로젝트는 학습자 JDK17/21·Maven·H2로 실행하며 별도 호스팅 컨테이너가 필요하지 않다. Java 코어는 등록한 실제 JDK8/11/17/21을 사용한다. 문법 색상·3테마·다중 파일 편집·진도와 초안 저장을 구현했다. 웹 코어는 Wandbox JVM21이며 교재 기준·컴파일 대상·실행 JVM을 구분한다. 앱은 Wandbox를 호출하지 않는다. -Java 코어12수업 + 실무22수업 = 총34수업·68문항·34코딩 과제. Spring/REST/Lombok/MyBatis/JPA/트랜잭션/Security/OIDC/JWT/Redis/Kafka/관측/빌드·운영을 포함한다. Java 코드 문법 색상, 다크·라이트·고대비 테마, 읽기 전용 보조 파일과 파일별 초안, 진도·테마의 재시작 저장을 구현했다. +## 출시와 검증 -실무 기본20프로젝트를 JDK21/컴파일 대상17/Maven3.10.0에서 실제 실행해 기준 답안54개 JUnit 검사가 통과하고 미완성 starter20개가 실패했다. 독립 리뷰가 현재129소스 파일·부모 POM과 실제 실행 복사본의 해시 및 보고서를 대조했다. Redis/Kafka2개는 컴파일만 확인했고 실제 서비스 연동은 미검증이다. +Windows [v0.2.0 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)는 검증 소스 `6ba4b8aa124f5ce78e79240253946c9fdac3e770`을 대상으로 게시했다. [CI38213755330](https://github.com/jbsim999/java-edu/actions/runs/38213755330) 12작업 모두 성공: 콘텐츠·브라우저, Java4JDK, Windows 로컬4JDK, 실무17/21. Windows 관리자·코드페이지1252에서 로컬16/16과 각 실제 JVM56/61/67/73검사·한글 진단 PASS. 기본20프로젝트54JUnit PASS와 미완성 starter20개 실패 확인. -독립 기능 QA: 실제 개발 Electron9/9, 웹 실무 모의API4/4, 기존 브라우저49개 회귀 PASS. 로컬 계약16/16, 공용 Node35 PASS/실제 외부 제공자1 SKIP. 독립 코드 리뷰의 미해결 P1/P2는 없다. 새 링크 정책은 실제 교재 출처90개·공고5개·UI/설치 링크를 검사했다. 정확한 검사 구분과 이미지 경로는 docs/desktop-qa-report.md 및 docs/local-execution.md를 따른다. +설치 파일 `desktop-release/Java-Edu-Setup-0.2.0-x64.exe`, 111753633바이트, SHA256 `6d4354e0b4a5c8b471591f0ee29c106f9ec65c2561847416555c38e623fd2f74`. 서명되지 않은 Windows x64 NSIS다. 실제 현재 사용자 설치 → 설치된 앱8/8(JDK21 코어·Spring/H2 제출·재시작 저장) → 제거 및 독립 시각 검수 PASS. 설치 ASAR/helper/exe는 빌드본과 동일하다. 검토 파일195개+생성2개를 대조했으며 package.json은 builder가 개발 필드를 제거한 뒤의 런타임 필드로 비교했다. [설치 QA](installed-desktop-qa-report.md)와 [검증 기록](verification.md)을 따른다. -최종 NSIS 빌드는 성공했고 `desktop-release/Java-Edu-Setup-0.2.0-x64.exe`가 있다. 크기111738936바이트, SHA256 `4e437a43afba366bab838b05ef0911acdbe8e95c2d5fec6461fb9fa409d02a21`. 서명되지 않은 Windows x64 설치 파일이다. 설치·제거 및 설치된 바이너리 실행은 아직 수행하지 않았다. `win-unpacked/Java Edu.exe` 역시 별도 패키지 실행 검증이 남는다. +테스트 설치는 제거했다. 테스트 이전에 없던 AppData 경로도 원래 부재 상태로 복원했다. 검사 데이터는 저장소 내부 work/installed-desktop-qa/app-data-first-run 및 app-data-final-run에 보관한다. 해당 작업 자료는 Git에 포함하지 않는다. 설치 파일을 재빌드하면 해시와 검증이 달라지므로 기존 릴리스 파일을 교체하지 않는다. -## 배포 상태와 남은 작업 +웹 https://java-edu-lab-jbsim999.junbo1218.chatgpt.site 는 소유자 전용이며34수업·12도식·테마가 배포됐다. GitHub 웹 소스 `a0d12571dcca5574d19b6ffdc35cd1a1093790d4`, Sites source `88e92c4a6889c621f710027f924450d540040460`, 배포 `appgdep_6acc7e4ad0c88191a5c9c2e721d984a1`, 2026-10-12 06:29:47 UTC succeeded. 후속 Windows 감독 helper 수정은 웹 UI·교재를 변경하지 않았다. [배포 기록](deployment.md)을 따른다. -현재 웹은 https://java-edu-lab-jbsim999.junbo1218.chatgpt.site 에 M4.1 편집기 테마까지 배포돼 있다. 마지막 기존 GitHub 출시 커밋은 `a4d277e10f6c9e03b6a14f4fb6a11a2672987b54`, 기존 CI38204310833 전부 성공. 새34수업 웹과 Windows v0.2.0 릴리스는 아직 게시하지 않았다. +34수업의 시각 자료를 평가해12개 채택·22개 보류했다. 공식 출처26개를 실제 대조했고 내용·코드·시각 검수 PASS, 도식19/19·기존 브라우저49/49 PASS다. [선정 근거](theory-visuals.md)를 따른다. 테스트의 기본 산출물은 저장소 내부 work/로 정리했고 경로 영향 브라우저1/1·실무 웹4/4 PASS다. -독립 시각 검수 및 설치 검증 에이전트가 실제 서비스 사용량 한도 응답으로 마지막 게이트를 끝내지 못했다. 완료된 기능 검사를 설치 파일·최종 시각 승인으로 바꾸지 않는다. 한도 해제 후 다음을 수행한다. +## 남은 범위와 제약 -1. `git status`와 이 문서 이후 커밋·CI 결과를 확인한다. 사용자 변경을 보존한다. -2. docs/desktop-qa-report.md의 실제 Electron 및 Edge 화면을 독립 시각 담당자가 검토한다. 최초 실패 이미지를 최종 정상 근거로 사용하지 않는다. -3. 별도 설치 검증: workspace/work/installed-java-edu-test 에 현재 사용자 NSIS 설치 → 설치된 앱 실제 core JDK21 제출·Spring/H2 tx-proxy → 종료/재시작 저장 → 해당 테스트 설치만 제거. 기존 AppData/바로가기/제거 정보가 있으면 보존한다. 설치 전 APPDATA/java-edu와 APPDATA/Java Edu는 존재하지 않았으나 현재 상태를 다시 확인한다. production은 JAVA_EDU_QA_DATA 우회를 허용하지 않는다. -4. 새 CI: 콘텐츠/Worker/브라우저, Java8/11/17/21, Windows 로컬8/11/17/21, 실무17/21을 확인한다. 새 CI는 정의만 완료됐으며 결과를 따로 확인해야 한다. -5. 같은 검증 커밋으로 Sites 공식 source helper를 통해34수업을 빌드·패키징·배포한다. 프로젝트 ID `appgprj_6acc4fec8bb48191a296197d2d55c1e3`, 기존 소유자 전용 접근을 유지한다. Sites source SHA와 GitHub SHA는 별개다. Sites 체크아웃은 workspace/work/java-edu-site-source. 토큰을 파일·출력·명령 인수에 기록하지 않는다. -6. 같은 검증 커밋을 v0.2.0으로 GitHub Release에 게시하고 설치 EXE 및 SHA256 파일을 첨부한다. PR 본문·README·milestones·verification·deployment를 실제 결과로 갱신한다. PR은 병합하지 않는다. +Redis/Kafka 실제 서비스 연동, 실제 모바일·스크린리더·OS IME/클립보드·SmartScreen 다운로드 흐름, 장기 부하, 호스팅 인증 세션의 실제 Java 제출은 미검증이다. 앱과 웹 진도는 자동 동기화하지 않는다. 학습 코드는 사용자 PC 권한으로 실행하며 OS 보안 샌드박스가 아니다. Windows helper의 OS 기본 TEMP는 현재 ANSI 코드페이지로 표현 가능해야 한다. 강제 부모 종료 시 helper 임시 폴더가 남을 수 있다. [로컬 실행 계약](local-execution.md)에 정확한 경계를 기록했다. + +마일스톤 커밋·푸시 및 검증된 배포는 승인됐지만 강제 푸시·PR 병합·유료 구매·사이트 공개 전환·라이선스 지정은 승인되지 않았다. 문서 후속 커밋과 실제 검증·패키징 소스를 구분한다. ## 개발 환경 -Node22+, Windows, `npm ci` 후 `npm run dev` 또는 `npm run desktop`. 생성된 editor.bundle/professional.js/ZIP은 Git에 포함하지 않으며 빌드 스크립트로 재생성한다. `npm run build:desktop`은 최종 소스에서 설치 파일을 재빌드한다. +Node22+, Windows, npm ci 후 npm run dev 또는 npm run desktop. 생성 번들·실무 데이터·ZIP은 빌드로 재생성한다. npm run build:desktop은 별도 신규 설치 파일을 만든다. -이 작업 환경은 상속된 NODE_TLS_REJECT_UNAUTHORIZED=0 값이 있었다. 네트워크/빌드 실행에서는 반드시1로 설정하고 Node22.17은 NODE_OPTIONS=--use-system-ca 또는 node --use-system-ca로 시스템 CA를 사용한다. 인증서 검사 해제를 오류 해결책으로 사용하지 않는다. +이 작업 환경의 상속 NODE_TLS_REJECT_UNAUTHORIZED=0은 사용하지 않는다. 네트워크/빌드에서는1로 설정하며 Node22.17은 NODE_OPTIONS=--use-system-ca를 사용한다. 인증서 검사를 해제하지 않는다. -로컬 도구 경로는 시스템 변경 없이 workspace/work/toolchains/jdk21/jdk-21.0.12.1+1 및 workspace/work/toolchains/maven/apache-maven-3.10.0 이다. JDK8은 C:/Program Files/Java/java-1.8.0-openjdk-1.8.0.265-1. 앱 등록은 폴더 선택 방식이다. 설치 앱·실제 JDK11/17·실제 Redis/Kafka·실제 모바일·IME·기기 간 동기화의 미검증 범위를 유지한다. +현재 검증 도구는 프로젝트 이동 전의 읽기 전용 폴더 C:/Users/USER/Documents/Codex/2026-10-12/new-chat/work/toolchains/jdk21/jdk-21.0.12.1+1 및 같은 toolchains/maven/apache-maven-3.10.0이다. JDK8은 C:/Program Files/Java/java-1.8.0-openjdk-1.8.0.265-1이다. CI의 실제 JDK11/17 검사와 이 로컬 도구 경로를 구분한다. 일반 학습자는 앱의 폴더 선택으로 자신의 도구를 등록한다. diff --git a/docs/installation.md b/docs/installation.md index 24b1d1c..e3edc61 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,6 +1,6 @@ # Windows 설치와 학습 시작 -Windows 10/11 x64용 현재 사용자 설치 앱이다. 설치 파일과 SHA-256은 이 저장소의 GitHub Releases에 함께 제공한다. 앱은 Java와 Maven을 포함하지 않으며 시스템 PATH를 바꾸지 않는다. +Windows 10/11 x64용 현재 사용자 설치 앱이다. [v0.2.0 설치 파일과 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)을 함께 제공한다. 앱은 Java와 Maven을 포함하지 않으며 시스템 PATH를 바꾸지 않는다. ## 준비 diff --git a/docs/milestones.md b/docs/milestones.md index 0a3c0bd..e9e3c80 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -9,13 +9,13 @@ | M3 | 실제 JVM21 외부 실행·샘플/제출 API·Worker 패키징 | API 단위 검사·실제 코드 실행·오류/버전 계약·독립 리뷰·커밋/푸시 | 완료: `fe68aef` 원격 반영·CI PASS | | M4 | 프로토타입 통합·배포 | 반응형·키보드·진도·오래된 응답 무효화·합성 화면·CI 확인·출시 커밋/푸시·배포 성공 | 완료: `165e113` 원격 반영·CI PASS·private 배포 성공 | | M4.1 | Java 문법 색상·3테마·편집 기능 | 구문·브라우저 회귀·선택 대비·새 체크아웃 빌드·독립 코드/시각 검수 | 완료: `a4d277e` 원격·CI PASS·private 배포 성공 | -| M5 | Windows 설치형·로컬 JDK8/11/17/21·Maven 실행 | 세션·도구 등록·프로세스 정리·앱 재시작 진도·실제 로컬 실행·설치 파일 | 구현·개발 앱9/9·로컬16/16 PASS, 설치·제거 및 최종 시각 gate 남음 | +| M5 | Windows 설치형·로컬 JDK8/11/17/21·Maven 실행 | 세션·도구 등록·프로세스 정리·앱 재시작 진도·실제 로컬 실행·설치 파일 | 완료: `6ba4b8a` CI12 성공, 설치 앱8/8·설치/제거·독립 시각 PASS, v0.2.0 프리뷰 출시 | | M6 | Spring/REST/Lombok/MyBatis/테스트 대표 수업 | 실제 의존성·다중 파일·DB·HTTP 코딩 실습 | 22실무수업 통합, 기본20프로젝트 기준54JUnit PASS·starter 실패 확인 | -| M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | 구현·독립 코드/출처 PASS, 웹 교재 배포 완료; Redis/Kafka 실제 서비스 미검증·Windows 미출시 | +| M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | 구현·독립 코드/출처 PASS, 웹 교재 배포 완료; Redis/Kafka 실제 서비스 미검증·Windows v0.2.0 프리뷰 출시 | | M7.1 | 이론 도식과 GitHub 프로젝트 소개 | 34수업 채택 평가·공식 출처·독립 코드/기능/시각 검수·README 실제 화면 | main 소개 반영, 12도식 채택·22보류; 도식19/19·회귀49/49·내용/시각 PASS, `a0d1257` 원격·웹 배포 성공 | M4는 전체 교육 과정 완료를 뜻하지 않는다. 2026-10-12 사용자는 **Windows 설치형 + 웹 교재**를 선택했다. 학습자 로컬 PC에서 Java/Spring/H2를 실행하며, 호스팅 컨테이너 서버는 기본 과정에 필요하지 않다. Redis/Kafka 실제 연동은 별도 선택 환경이다. 웹의 기존 코어 실행은 Wandbox 실제 JVM21을 사용한다. 교재 버전·컴파일 대상·실제 JVM을 구분한다. 마일스톤마다 관련 파일만 stage하고 강제 푸시하지 않는다. 배포는 검증한 커밋의 동일 소스를 사용한다. M4.1 검증: 브라우저 고유49개를 전체47개와 추가/수정 영향 검사로 나눠 확인, 토큰 단위4개 PASS, 전체 Node31 PASS/실제 제공자1 SKIP. 선택 상태 최저 대비4.859/4.810/5.772:1. 생성 번들이 없는 작업 복사본에서 npm ci→build→Worker 검사→Node 검사 PASS. npm audit은 scoped global-agent 보완 후0건이며 실제 설치 파일 빌드는 M5에서 검사한다. 상세 범위·미확인은 docs/editor-qa-report.md를 따른다. -M5–M7 현재 source를 인계용으로 통합 보존한다. 설치 NSIS 빌드는 성공했으나 설치된 앱 승인과 마지막 독립 시각 검수는 에이전트 사용량 한도로 미완료다. 새 CI의 네 Windows JVM 및 두 실무 JVM 작업은 실제 결과를 별도로 확인해야 한다. 이 단계의 커밋·푸시를 최종 출시 성공으로 표현하지 않는다. 이어갈 조건은 [개발 인계](development-handoff.md)를 따른다. +M5–M7 출시 게이트는 이후 완료했다. 출시 소스 6ba4b8a의 CI12작업, 실제 설치 앱8/8, 설치·제거·시각 검수 PASS. [v0.2.0 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)와34수업 웹 교재를 배포했다. 선택 Redis/Kafka 실제 연동, 실기기·스크린리더·장기 부하는 미검증이며 프리뷰 범위를 유지한다. 최신 상태는 [개발 인계](development-handoff.md)를 따른다. From ffb77751b45d360804090a46fe7efa2e105ab81d Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:56:08 +0900 Subject: [PATCH 16/29] =?UTF-8?q?feat(desktop):=20Java=EC=99=80=20Maven?= =?UTF-8?q?=EC=9D=84=20=ED=8F=AC=ED=95=A8=ED=95=9C=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/build-config.cjs | 10 +- desktop/bundled-runtime.cjs | 132 ++++++++++++++++++ desktop/main.cjs | 4 +- desktop/toolchain-provenance.json | 46 +++++++ dist/app.js | 65 +++++++-- dist/index.html | 6 +- dist/styles.css | 19 ++- docs/bundled-runtime.md | 38 ++++++ package-lock.json | 4 +- package.json | 4 +- scripts/bundle-runtime.mjs | 105 ++++++++++++++ server/local/engine.mjs | 5 +- server/local/settings.mjs | 36 +++-- tests/local/bundled-runtime-smoke.mjs | 72 ++++++++++ tests/local/bundled-runtime.test.mjs | 190 ++++++++++++++++++++++++++ 15 files changed, 704 insertions(+), 32 deletions(-) create mode 100644 desktop/bundled-runtime.cjs create mode 100644 desktop/toolchain-provenance.json create mode 100644 docs/bundled-runtime.md create mode 100644 scripts/bundle-runtime.mjs create mode 100644 tests/local/bundled-runtime-smoke.mjs create mode 100644 tests/local/bundled-runtime.test.mjs diff --git a/desktop/build-config.cjs b/desktop/build-config.cjs index caaf61b..e36452c 100644 --- a/desktop/build-config.cjs +++ b/desktop/build-config.cjs @@ -1,11 +1,17 @@ +const path = require('node:path'); +const { loadBundledRuntime } = require('./bundled-runtime.cjs'); + module.exports = { appId: 'kr.javaedu.desktop', productName: 'Java Edu', asar: true, asarUnpack: ['desktop/job-runner.ps1'], + extraResources: [{ from: 'work/desktop-bundle', to: 'toolchains', filter: ['**/*'] }], + beforePack: async () => { await loadBundledRuntime(path.join(__dirname, '..', 'work', 'desktop-bundle')); }, + afterPack: async ({ appOutDir }) => { await loadBundledRuntime(path.join(appOutDir, 'resources', 'toolchains')); }, directories: { output: 'desktop-release' }, files: ['desktop/**', 'server/**', 'dist/**', '!dist/server/**', '!dist/.openai/**', 'curriculum/professional/**', 'package.json', '!**/target/**', '!**/node_modules/**'], - win: { target: [{ target: 'nsis', arch: ['x64'] }], artifactName: 'Java-Edu-Setup-${version}-${arch}.${ext}', signAndEditExecutable: false }, - nsis: { oneClick: false, perMachine: false, allowElevation: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true, createStartMenuShortcut: true, runAfterFinish: false, deleteAppDataOnUninstall: false }, + win: { target: [{ target: 'nsis', arch: ['x64'] }, { target: 'zip', arch: ['x64'] }], artifactName: 'Java-Edu-${version}-${arch}.${ext}', signAndEditExecutable: false }, + nsis: { artifactName: 'Java-Edu-Setup-${version}-${arch}.${ext}', oneClick: false, perMachine: false, allowElevation: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true, createStartMenuShortcut: true, runAfterFinish: false, deleteAppDataOnUninstall: false }, publish: null, }; diff --git a/desktop/bundled-runtime.cjs b/desktop/bundled-runtime.cjs new file mode 100644 index 0000000..bde7e94 --- /dev/null +++ b/desktop/bundled-runtime.cjs @@ -0,0 +1,132 @@ +const { createHash } = require('node:crypto'); +const { createReadStream } = require('node:fs'); +const { lstat, readFile, readdir, realpath } = require('node:fs/promises'); +const path = require('node:path'); + +const JAVA_VERSIONS = [8, 11, 17, 21]; +const MAX_FILES = 12_000; +const MAX_MANIFEST_BYTES = 8 * 1024 * 1024; +const SHA256 = /^[a-f0-9]{64}$/; +const BUNDLE_ERROR = '앱에 포함된 Java/Maven 파일을 확인할 수 없습니다. 원본 설치 파일을 다시 설치하거나 ZIP을 새 폴더에 풀어 주세요.'; + +function failure(detail) { + const error = new Error(`${BUNDLE_ERROR} (${detail})`); + error.code = 'invalid_bundled_runtime'; + return error; +} + +function safeRelative(value) { + if (typeof value !== 'string' || !value || value.length > 700 || /[\\\x00-\x1f<>:"|?*]/.test(value) + || value.split('/').some((part) => !part || part === '.' || part === '..' || /[. ]$/.test(part) || /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(part))) throw failure('invalid relative path'); + return value; +} + +function httpsUrl(value) { + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && !url.port && !url.hash; + } catch { return false; } +} + +function validateProvenance(value) { + if (!value || typeof value !== 'object' || typeof value.distribution !== 'string' || !value.distribution.trim() + || typeof value.version !== 'string' || !value.version.trim() || !httpsUrl(value.sourceUrl) + || !SHA256.test(value.archiveSha256) || typeof value.license !== 'string' || !value.license.trim() + || !Array.isArray(value.licenseFiles) || !value.licenseFiles.length || value.licenseFiles.length > 40 + || (value.sourceCodeUrl !== undefined && !httpsUrl(value.sourceCodeUrl)) + || (value.redistributionSourceUrl !== undefined && !httpsUrl(value.redistributionSourceUrl)) + || (value.redistributionSourceFiles !== undefined && (!Array.isArray(value.redistributionSourceFiles) || !value.redistributionSourceFiles.length || value.redistributionSourceFiles.length > 40 || !value.redistributionSourceUrl))) throw failure('missing distribution provenance'); + for (const name of value.licenseFiles) safeRelative(name); + for (const name of value.redistributionSourceFiles || []) safeRelative(name); +} + +function manifestIdentity(manifest) { + const { id: _id, ...contents } = manifest; + return createHash('sha256').update(JSON.stringify(contents)).digest('hex'); +} + +async function sha256File(file) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +} + +/** No links or other special files may redirect packaged toolchains outside their root. */ +async function inventoryTree(root, { excludeManifest = false } = {}) { + const files = []; + async function visit(directory, prefix = '') { + for (const item of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en'))) { + const relative = safeRelative(prefix ? `${prefix}/${item.name}` : item.name); + if (excludeManifest && relative === 'manifest.json') continue; + const file = path.join(directory, item.name); + const info = await lstat(file); + if (info.isSymbolicLink()) throw failure('linked distribution file'); + if (info.isDirectory()) await visit(file, relative); + else if (info.isFile()) { + if (files.length >= MAX_FILES) throw failure('too many distribution files'); + files.push({ path: relative, size: info.size, sha256: await sha256File(file) }); + } else throw failure('unsupported distribution file'); + } + } + await visit(root); + return files; +} + +/** Check all packaged bytes before using metadata. The manifest is integrity data, not a signature. */ +async function inspectBundledRuntime(directory, { required = true } = {}) { + if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw failure('absolute bundle root required'); + const rootInfo = await lstat(directory).catch((error) => { if (!required && error.code === 'ENOENT') return null; throw error; }); + if (!rootInfo) return null; + if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) throw failure('invalid bundle directory'); + const root = await realpath(directory); + const manifestPath = path.join(root, 'manifest.json'); + const info = await lstat(manifestPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_MANIFEST_BYTES) throw failure('invalid manifest file'); + let manifest; + try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); } catch { throw failure('invalid manifest JSON'); } + if (manifest.schemaVersion !== 1 || manifest.platform !== 'win32' || manifest.arch !== 'x64' + || !Array.isArray(manifest.jdks) || !manifest.jdks.length || manifest.jdks.length > JAVA_VERSIONS.length + || !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > MAX_FILES + || !SHA256.test(manifest.id) || manifest.id !== manifestIdentity(manifest)) throw failure('unsupported manifest'); + const versions = new Set(); + for (const jdk of manifest.jdks) { + validateProvenance(jdk); + if (!JAVA_VERSIONS.includes(jdk.major) || versions.has(jdk.major) || jdk.home !== `jdks/${jdk.major}`) throw failure('invalid JDK layout'); + versions.add(jdk.major); + } + if (!versions.has(21)) throw failure('default Java 21 missing'); + validateProvenance(manifest.maven); + if (manifest.maven.home !== 'maven' || !/^3\.([9]|[1-9]\d+)\.\d+$/.test(manifest.maven.version)) throw failure('invalid Maven layout or version'); + const expected = new Map(); + const insensitive = new Set(); + for (const file of manifest.files) { + const name = safeRelative(file.path); + if (!SHA256.test(file.sha256) || !Number.isSafeInteger(file.size) || file.size < 0 || expected.has(name) || insensitive.has(name.toLowerCase()) + || !['THIRD-PARTY-NOTICES.txt', ...manifest.jdks.map((jdk) => `${jdk.home}/`), 'maven/'].some((prefix) => prefix.endsWith('/') ? name.startsWith(prefix) : name === prefix)) throw failure('invalid file inventory'); + expected.set(name, file); + insensitive.add(name.toLowerCase()); + } + for (const jdk of manifest.jdks) { + for (const name of ['bin/java.exe', 'bin/javac.exe', 'release', ...jdk.licenseFiles]) if (!expected.has(`${jdk.home}/${name}`)) throw failure('missing JDK or license file'); + } + for (const name of ['bin/m2.conf', ...manifest.maven.licenseFiles]) if (!expected.has(`maven/${name}`)) throw failure('missing Maven or license file'); + if (!expected.has('THIRD-PARTY-NOTICES.txt') || !manifest.files.some((file) => /^maven\/boot\/plexus-classworlds-[A-Za-z0-9.-]+\.jar$/.test(file.path)) + || !expected.has(`maven/lib/maven-core-${manifest.maven.version}.jar`)) throw failure('incomplete Maven distribution'); + const actual = await inventoryTree(root, { excludeManifest: true }); + if (actual.length !== expected.size || actual.some((file) => expected.get(file.path)?.sha256 !== file.sha256 || expected.get(file.path)?.size !== file.size)) throw failure('distribution integrity mismatch'); + return { + id: manifest.id, + root, + jdks: manifest.jdks.map((jdk) => ({ version: jdk.major, home: path.join(root, ...jdk.home.split('/')), description: `${jdk.distribution} ${jdk.version} · 앱 포함`, distribution: jdk.distribution, distributionVersion: jdk.version })), + maven: { home: path.join(root, 'maven'), version: manifest.maven.version }, + fileCount: actual.length, + bytes: actual.reduce((total, file) => total + file.size, 0), + }; +} + +async function loadBundledRuntime(directory, options) { + try { return await inspectBundledRuntime(directory, options); } + catch (error) { if (error.code === 'invalid_bundled_runtime') throw error; throw failure('missing or unreadable distribution file'); } +} + +module.exports = { loadBundledRuntime, manifestIdentity, inventoryTree, validateProvenance }; diff --git a/desktop/main.cjs b/desktop/main.cjs index 4cfdb91..ca116f9 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -2,6 +2,7 @@ const { app, BrowserWindow, dialog, ipcMain, session, shell } = require('electro const path = require('node:path'); const { pathToFileURL } = require('node:url'); const { createDesktopStorage } = require('./storage.cjs'); +const { loadBundledRuntime } = require('./bundled-runtime.cjs'); const { externalReferenceUrl } = require('./reference-policy.cjs'); const { mkdirSync } = require('node:fs'); @@ -35,7 +36,8 @@ async function initialize() { import(pathToFileURL(path.join(root, 'server/local/engine.mjs'))), import(pathToFileURL(path.join(root, 'server/local/http.mjs'))), ]); - settings = createSettingsStore(dataRoot); + const bundledRuntime = await loadBundledRuntime(app.isPackaged ? path.join(process.resourcesPath, 'toolchains') : path.join(root, 'work', 'desktop-bundle'), { required: app.isPackaged }); + settings = createSettingsStore(dataRoot, { bundledRuntime }); await settings.initialize(); const storage = createDesktopStorage(dataRoot); engine = await createLocalEngine({ dataRoot, projectRoot: path.join(root, 'curriculum/professional'), settings }); diff --git a/desktop/toolchain-provenance.json b/desktop/toolchain-provenance.json new file mode 100644 index 0000000..4d963e3 --- /dev/null +++ b/desktop/toolchain-provenance.json @@ -0,0 +1,46 @@ +{ + "checkedAt": "2026-10-12", + "jdks": [ + { + "major": 21, + "distribution": "Eclipse Temurin", + "version": "21.0.12.1+1", + "sourceUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_windows_hotspot_21.0.12.1_1.zip", + "archiveSha256": "f9d6e191ab098c0d416e7d588a24420a8621cd2f4720dab2459b8b7b2d2d8b4e", + "license": "GPL-2.0-with-classpath-exception", + "licenseFiles": [ + "legal/java.base/LICENSE", + "legal/java.base/ASSEMBLY_EXCEPTION", + "NOTICE" + ], + "sourceCodeUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", + "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", + "redistributionSourceFiles": [ + "OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", + "Third-Party-Sources-0.3.0.zip/temurin-build-e6ba7dec3d07654074559310376a3ae89da5f4ac.tar.gz" + ] + } + ], + "maven": { + "distribution": "Apache Maven", + "version": "3.10.0", + "sourceUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/binaries/apache-maven-3.10.0-bin.zip", + "archiveSha256": "1f6d9909266510f039f59aa0e13dcd2c66da85f043e56276e41f2918f8bddaff", + "license": "Apache-2.0 and bundled third-party licenses", + "licenseFiles": [ + "LICENSE", + "NOTICE", + "lib/org.eclipse.sisu.inject.license", + "lib/org.eclipse.sisu.plexus.license", + "lib/javax.annotation-api.license" + ], + "sourceCodeUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/source/apache-maven-3.10.0-src.zip", + "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", + "redistributionSourceFiles": [ + "Third-Party-Sources-0.3.0.zip/apache-maven-3.10.0-src.zip", + "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.inject-1.1.0-sources.jar", + "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.plexus-1.1.0-sources.jar", + "Third-Party-Sources-0.3.0.zip/javax.annotation-api-1.3.2-sources.jar" + ] + } +} diff --git a/dist/app.js b/dist/app.js index fcf166d..c3a54b5 100644 --- a/dist/app.js +++ b/dist/app.js @@ -1155,6 +1155,14 @@ function localRuntimes(exercise) { return (state.runtime?.runtimes || []).filter((runtime) => runtime.available && VERSIONS.includes(Number(runtime.javaVersion)) && Number(runtime.javaVersion) >= minimum).sort((a, b) => b.javaVersion - a.javaVersion); } +function hasBundledRuntime() { + return Boolean(state.runtime?.bundledRuntime || state.runtime?.runtimes?.some((runtime) => runtime.source === 'bundled') || state.runtime?.maven?.source === 'bundled'); +} + +function runtimeSourceLabel(tool) { + return tool?.source === 'bundled' ? '앱에 포함' : tool?.available ? '직접 등록' : '등록하지 않음'; +} + function runtimeFor(exercise) { if (!isDesktop) return 21; const available = localRuntimes(exercise); @@ -1203,7 +1211,7 @@ function renderRuntime() { line.append(el('strong', '', isProject(exercise) && !isDesktop ? '실행 위치 · Windows 앱' : runtimeVersion ? `실제 실행 JVM · Java ${runtimeVersion}` : '실제 실행 JVM · JDK 등록 필요'), el('span', '', `컴파일 대상 · Java ${exercise?.minVersion || 8}`)); card.append(line); if (isProject(exercise) && !isDesktop) { - card.append(el('p', '', '웹에서 이론·문제·다중 파일 코드를 학습하고, Windows 앱에서 등록한 JDK와 Maven으로 테스트를 실행하세요. 웹과 앱의 학습 기록은 각각 저장됩니다.')); + card.append(el('p', '', '웹에서 이론·문제·다중 파일 코드를 학습하고, Windows 앱에 포함된 Java 21과 Maven으로 테스트를 실행하세요. 웹과 앱의 학습 기록은 각각 저장됩니다.')); const actions = el('div', 'project-access-actions'); actions.append(actionButton('Windows 앱 안내', 'button button-secondary', () => openDialog('install-dialog'), 'project-desktop-guide')); if (/^\/downloads\/[a-z0-9-]+\.zip$/.test(exercise.downloadUrl || '')) { @@ -1220,7 +1228,7 @@ function renderRuntime() { const select = el('select'); select.id = 'runtime-version-select'; select.dataset.testid = select.id; const available = localRuntimes(exercise); if (!available.length) select.append(el('option', '', '등록된 호환 JDK 없음')); - available.forEach((runtime) => { const option = el('option', '', `Java ${runtime.javaVersion}`); option.value = String(runtime.javaVersion); select.append(option); }); + available.forEach((runtime) => { const option = el('option', '', `Java ${runtime.javaVersion}${runtime.source ? ` · ${runtimeSourceLabel(runtime)}` : ''}`); option.value = String(runtime.javaVersion); select.append(option); }); select.value = String(runtimeVersion || ''); select.disabled = !available.length || Boolean(state.pending) || state.cancelling || state.settingsBusy; select.addEventListener('change', () => { @@ -1230,11 +1238,13 @@ function renderRuntime() { document.getElementById('runtime-version-select')?.focus({ preventScroll: true }); announce(`실제 실행 JDK를 Java ${state.runtimeVersion}로 변경했습니다. 프로젝트 준비 상태를 확인하세요.`); }); - row.append(label, select, actionButton('실행 설정', 'button button-secondary', openDesktopSettings, 'runtime-settings-open')); - card.append(row, el('p', '', state.runtimeMessage || '이 PC의 등록 JDK로 실행합니다. 학습 기준 버전·컴파일 대상·실제 실행 JVM을 따로 확인하세요.')); + const needsConsent = !state.runtime?.consent?.accepted; + const selectedRuntime = available.find((runtime) => Number(runtime.javaVersion) === runtimeVersion); + row.append(label, select, actionButton(needsConsent ? '실행 권한 확인' : '실행 설정', `button ${needsConsent ? 'button-primary' : 'button-secondary'}`, openDesktopSettings, 'runtime-settings-open')); + card.append(row, el('p', '', state.runtimeMessage || (selectedRuntime?.source === 'bundled' ? '앱에 포함된 JDK를 사용합니다. 컴파일 대상과 실제 실행 JVM은 위에 따로 표시됩니다.' : '이 PC의 등록 JDK로 실행합니다. 학습 기준 버전·컴파일 대상·실제 실행 JVM을 따로 확인하세요.'))); if (isProject(exercise)) { const project = state.projectInfo.get(exercise.id); - const preparation = el('p', 'project-preparation', projectPrepared(exercise) ? '환경 준비 완료 · 현재 JDK와 Maven으로 테스트할 수 있습니다.' : '환경 준비를 누르면 고정 기준 답안으로 Maven 의존성을 내려받고 환경을 검증합니다. 이후 학습자 테스트는 오프라인으로 실행합니다.'); + const preparation = el('p', 'project-preparation', projectPrepared(exercise) ? '환경 준비 완료 · 현재 JDK와 Maven으로 테스트할 수 있습니다.' : '처음 환경 준비에는 인터넷 연결이 필요합니다. 고정 기준 답안으로 Maven 라이브러리를 자동으로 내려받고 환경을 검증합니다. 이후 학습자 테스트는 저장된 라이브러리로 실행합니다.'); preparation.id = 'project-preparation-state'; preparation.dataset.prepared = String(projectPrepared(exercise)); card.append(preparation); if (project?.status === 'error') card.append(el('p', '', project.message), actionButton('프로젝트 다시 확인', 'runtime-retry', () => loadProject(exercise, true), 'project-retry')); @@ -1280,7 +1290,8 @@ function acceptRuntime(runtime) { state.runtime = { ...runtime, supportedTargets: runtime.supportedTargets.map(Number).filter((version) => VERSIONS.includes(version)) }; const available = runtime.available === true && (isDesktop || Number(runtime.runtime?.javaVersion) === 21); state.runtimeStatus = available ? 'ready' : 'unavailable'; - state.runtimeMessage = available ? '' : typeof runtime.reason === 'string' ? runtime.reason : typeof runtime.notice === 'string' ? runtime.notice : '실행 환경 설정을 확인해 주세요.'; + const reasons = { execution_consent_required: '실행 도구를 사용하려면 실행 권한을 확인하고 동의해 주세요.', jdk_required: '실행 설정에서 사용할 JDK를 등록해 주세요.' }; + state.runtimeMessage = available ? '' : Object.hasOwn(reasons, runtime.reason) ? reasons[runtime.reason] : typeof runtime.reason === 'string' ? runtime.reason : typeof runtime.notice === 'string' ? runtime.notice : '실행 환경 설정을 확인해 주세요.'; } async function loadRuntime() { @@ -1334,23 +1345,49 @@ async function loadProject(exercise, retry = false) { function renderDesktopSettings() { const body = document.getElementById('desktop-settings-body'); if (!isDesktop || !body) return; + const bundled = hasBundledRuntime(); + const externalTools = state.runtime?.runtimes?.some((runtime) => runtime.available && runtime.source !== 'bundled') || (state.runtime?.maven?.available && state.runtime.maven.source !== 'bundled'); + const advancedOpen = document.getElementById('desktop-advanced-settings')?.open ?? Boolean(externalTools); + const description = document.getElementById('desktop-settings-description'); + if (description) description.textContent = !state.runtime ? '실행 도구를 확인하고 있습니다. 코드는 실행·제출 버튼을 눌렀을 때 실행됩니다.' : bundled ? '앱에 포함된 도구로 시작하세요. 동의 후 실행·제출 버튼을 누르면 코드를 실행합니다.' : '설치된 JDK와 Maven의 폴더를 선택하세요. 시스템 PATH를 바꾸지 않으며, 코드는 실행·제출 버튼을 눌렀을 때 실행됩니다.'; body.replaceChildren(); + if (bundled) { + const jdk = state.runtime.runtimes.find((runtime) => Number(runtime.javaVersion) === 21); + const maven = state.runtime.maven; + const ready = jdk?.available && maven?.available; + const included = ready && jdk.source === 'bundled' && maven.source === 'bundled'; + const card = el('section', `settings-bundle${ready ? '' : ' is-unavailable'}`); + card.id = 'desktop-bundle-status'; card.setAttribute('aria-labelledby', 'desktop-bundle-title'); + const heading = el('h3', '', included ? '별도 설치 없이 시작하세요' : ready ? '사용 중인 실행 도구' : '실행 도구를 확인해 주세요'); + heading.id = 'desktop-bundle-title'; + const tools = el('ul', 'settings-bundle-tools'); + for (const [name, tool] of [['Java 21', jdk], [`Maven${maven?.version ? ` ${maven.version}` : ''}`, maven]]) { + const item = el('li'); item.append(el('strong', '', name), el('span', '', runtimeSourceLabel(tool))); tools.append(item); + } + card.append(heading, tools, el('p', '', included ? 'Java 8·11·17·21 대상의 코드를 컴파일할 수 있습니다. 기본 실제 실행 JVM은 Java 21입니다.' : '직접 등록한 도구가 있으면 해당 설정을 유지합니다. 사용할 JDK와 Maven은 아래 고급 설정에서 확인할 수 있습니다.'), el('p', '', 'Spring 등 실무 수업은 처음 환경 준비 시 인터넷으로 필요한 라이브러리를 자동 다운로드합니다.')); + body.append(card); + } const tools = el('div', 'settings-tools'); for (const version of VERSIONS) { const runtime = state.runtime?.runtimes?.find((item) => Number(item.javaVersion) === version); const row = el('div', 'settings-tool-row'); const copy = el('div'); - copy.append(el('strong', '', `JDK ${version}`), el('p', 'settings-tool-path', runtime?.available ? runtime.path || '등록됨' : '등록하지 않음')); - const button = actionButton(runtime?.available ? '변경' : '폴더 선택', 'button button-secondary', () => changeDesktopSettings(() => desktop.chooseJdk(version)), `jdk-register-${version}`); + copy.append(el('strong', '', `JDK ${version}${bundled && !runtime?.available ? ' · 선택 사항' : ''}`)); + if (runtime?.source) copy.append(el('p', 'settings-tool-source', runtimeSourceLabel(runtime))); + copy.append(el('p', 'settings-tool-path', runtime?.available ? runtime.path || '등록됨' : '등록하지 않음')); + const button = actionButton(runtime?.source === 'bundled' ? '다른 폴더 선택' : runtime?.available ? '변경' : '폴더 선택', 'button button-secondary', () => changeDesktopSettings(() => desktop.chooseJdk(version)), `jdk-register-${version}`); button.setAttribute('aria-label', `JDK ${version} 설치 폴더 선택`); button.disabled = state.settingsBusy; row.append(copy, button); tools.append(row); } const row = el('div', 'settings-tool-row'); const copy = el('div'); - copy.append(el('strong', '', `Maven${state.runtime?.maven?.version ? ` ${state.runtime.maven.version}` : ' 3.9+'}`), el('p', 'settings-tool-path', state.runtime?.maven?.available ? state.runtime.maven.path || '등록됨' : '등록하지 않음')); - const maven = actionButton(state.runtime?.maven?.available ? '변경' : '폴더 선택', 'button button-secondary', () => changeDesktopSettings(() => desktop.chooseMaven()), 'maven-register'); + copy.append(el('strong', '', `Maven${state.runtime?.maven?.version ? ` ${state.runtime.maven.version}` : ' 3.9+'}`)); + if (state.runtime?.maven?.source) copy.append(el('p', 'settings-tool-source', runtimeSourceLabel(state.runtime.maven))); + copy.append(el('p', 'settings-tool-path', state.runtime?.maven?.available ? state.runtime.maven.path || '등록됨' : '등록하지 않음')); + const maven = actionButton(state.runtime?.maven?.source === 'bundled' ? '다른 폴더 선택' : state.runtime?.maven?.available ? '변경' : '폴더 선택', 'button button-secondary', () => changeDesktopSettings(() => desktop.chooseMaven()), 'maven-register'); maven.setAttribute('aria-label', 'Maven 설치 폴더 선택'); maven.disabled = state.settingsBusy; - row.append(copy, maven); tools.append(row); body.append(tools); + row.append(copy, maven); tools.append(row); + if (!bundled) body.append(tools); const consent = el('div', 'settings-consent'); consent.append(el('h3', '', '이 PC에서 코드 실행'), el('p', '', 'Java 코드는 현재 PC의 사용자 권한으로 파일과 네트워크에 접근할 수 있습니다. 이 앱은 격리된 보안 샌드박스가 아닙니다. 신뢰하는 코드를 직접 확인한 뒤 실행하세요.')); const label = el('label'); @@ -1362,6 +1399,12 @@ function renderDesktopSettings() { consent.append(label); body.append(consent); const status = el('p', 'settings-status', state.settingsBusy ? '설정을 확인하고 있습니다…' : state.settingsMessage); status.id = 'desktop-settings-status'; status.setAttribute('role', 'status'); body.append(status); + if (bundled) { + const advanced = el('details', 'settings-advanced'); advanced.id = 'desktop-advanced-settings'; advanced.open = advancedOpen; + const summary = el('summary', '', '고급 설정 · 다른 JDK 또는 Maven 사용'); summary.id = 'desktop-advanced-summary'; + advanced.append(summary, el('p', '', '실제 Java 8·11·17 JVM을 비교하거나 다른 도구를 사용할 때만 직접 등록하세요. 기본 학습에는 추가 설치와 폴더 선택이 필요하지 않습니다.'), tools); + body.append(advanced); + } } async function changeDesktopSettings(operation, mutation = true) { diff --git a/dist/index.html b/dist/index.html index faede90..acd632c 100644 --- a/dist/index.html +++ b/dist/index.html @@ -70,10 +70,10 @@

-

LEARNING GUIDE

이 학습실을 사용하는 방법

다른 언어 경험자의 Java 전환과 현직 개발자의 심화 학습을 함께 지원합니다. 이론 → 예제 → 문제 → 실무 → 코드 실습 순서로 진행해 보세요.

  • 학습 기준: Java 8·11·17·21. 선택 버전에 맞는 수업을 표시합니다.
  • 객관식 성취: 한 수업의 모든 문제에 정답을 제출하면 기록됩니다.
  • 실습 통과: 현재 작성 코드로 제출한 테스트가 모두 통과하면 기록됩니다. 두 조건을 모두 충족하면 수업 완료입니다.
  • 설계 기록: 나의 판단을 정리하는 메모입니다. 자동 평가나 정답 판정이 아닙니다.
  • 실행 환경: 웹의 코어 실습은 실제 JVM 21에서 실행합니다. Windows 앱은 PC에 등록한 JDK를 선택해 코어와 실무 프로젝트를 실행합니다. 컴파일 대상과 실제 JVM은 실행 화면에 따로 표시됩니다.
  • 저장: 웹은 현재 브라우저에, Windows 앱은 이 PC의 앱 데이터에 코드·메모·진도를 저장합니다. 앱 기록은 재시작 후에도 유지됩니다. 웹과 앱의 기록은 서로 동기화되지 않습니다.

코드 예제와 실습에서 Java 구문 강조와 3가지 테마를 사용할 수 있습니다. 실습은 줄 번호·자동 들여쓰기·괄호 짝 표시를 지원합니다. 색상은 코드의 구문을 구분하며 컴파일 결과를 대신하지 않습니다.

+

LEARNING GUIDE

이 학습실을 사용하는 방법

다른 언어 경험자의 Java 전환과 현직 개발자의 심화 학습을 함께 지원합니다. 이론 → 예제 → 문제 → 실무 → 코드 실습 순서로 진행해 보세요.

  • 학습 기준: Java 8·11·17·21. 선택 버전에 맞는 수업을 표시합니다.
  • 객관식 성취: 한 수업의 모든 문제에 정답을 제출하면 기록됩니다.
  • 실습 통과: 현재 작성 코드로 제출한 테스트가 모두 통과하면 기록됩니다. 두 조건을 모두 충족하면 수업 완료입니다.
  • 설계 기록: 나의 판단을 정리하는 메모입니다. 자동 평가나 정답 판정이 아닙니다.
  • 실행 환경: 웹의 코어 실습은 실제 JVM 21에서 실행합니다. Windows 통합 패키지에는 Java 21과 Maven이 포함되어 코어와 기본 실무 프로젝트를 실행할 수 있습니다. 실제 Java 8·11·17 JVM이 필요한 경우 고급 설정에서 직접 등록할 수 있습니다. 컴파일 대상과 실제 JVM은 실행 화면에 따로 표시됩니다.
  • 저장: 웹은 현재 브라우저에, Windows 앱은 이 PC의 앱 데이터에 코드·메모·진도를 저장합니다. 앱 기록은 재시작 후에도 유지됩니다. 웹과 앱의 기록은 서로 동기화되지 않습니다.

코드 예제와 실습에서 Java 구문 강조와 3가지 테마를 사용할 수 있습니다. 실습은 줄 번호·자동 들여쓰기·괄호 짝 표시를 지원합니다. 색상은 코드의 구문을 구분하며 컴파일 결과를 대신하지 않습니다.

LEARNING RECORDS

학습 기록을 초기화할까요?

이 브라우저의 진도, 파일별 작성 코드와 설계 메모를 모두 지웁니다. 현재 학습 기준 버전은 유지됩니다.

-

LOCAL EXECUTION

내 PC의 실행 환경

설치된 JDK와 Maven의 폴더를 선택하세요. 시스템 PATH를 바꾸거나 도구를 자동 설치하지 않습니다. 코드는 실행 버튼을 눌렀을 때 실행됩니다.

-

WINDOWS APP

내 PC에서 프로젝트까지

Windows 앱에서 등록한 실제 JDK로 코어 코드를 실행하고, Maven으로 Spring·데이터 접근·테스트 프로젝트를 검증합니다.

  • Windows x64와 학습에 필요한 JDK 8·11·17·21을 준비하세요.
  • 실무 프로젝트에는 Maven 3.9 이상이 필요합니다. 앱 설정에서 설치 폴더를 등록합니다.
  • 실행 권한에 동의한 뒤 프로젝트의 환경 준비를 누르세요. 처음에는 Maven Central에서 고정 의존성을 내려받습니다.
  • Java 코드는 PC의 파일·네트워크에 접근할 수 있습니다. 격리된 보안 샌드박스가 아니므로 신뢰하는 코드를 실행하세요.

Windows 설치 파일은 배포 준비 중입니다. 공개된 설치 파일은 GitHub 릴리스에서 확인할 수 있습니다. 웹과 앱의 학습 기록은 각각 저장됩니다.

GitHub 릴리스 확인 ↗
+

LOCAL EXECUTION

내 PC의 실행 환경

실행 도구를 확인하고 있습니다. 코드는 실행·제출 버튼을 눌렀을 때 실행됩니다.

+

WINDOWS APP

설치 한 번으로 코드 실습까지

Windows x64 통합 패키지에 Java 21과 Maven이 함께 들어 있습니다. 도구를 별도로 설치하거나 폴더를 선택하지 않고 코어와 기본 실무 프로젝트 20개를 시작할 수 있습니다.

  • EXE로 설치하거나 ZIP의 모든 파일을 풀고 Java Edu.exe를 실행하세요. 실행 파일만 따로 옮기면 동작하지 않습니다.
  • 앱의 실행 설정에서 권한에 동의하면 코어 실습을 바로 실행할 수 있습니다. 기본 실제 JVM은 Java 21이며 Java 8·11·17·21 컴파일 대상을 지원합니다.
  • Spring 등 기본 실무 수업은 처음 환경 준비 시 인터넷 연결이 필요합니다. 필요한 라이브러리를 Maven Central에서 자동 다운로드합니다.
  • Redis·Kafka 실제 서비스 연동은 선택 실습입니다. 해당 서버는 패키지에 포함되지 않습니다.
  • 실제 Java 8·11·17 JVM의 차이를 비교하려면 고급 설정에서 해당 JDK를 선택해 등록할 수 있습니다.
  • Java 코드는 PC의 파일·네트워크에 접근할 수 있습니다. 격리된 보안 샌드박스가 아니므로 신뢰하는 코드를 실행하세요.

통합 패키지의 버전과 다운로드 파일은 GitHub 릴리스에서 확인하세요. 웹과 앱의 학습 기록은 각각 저장됩니다.

diff --git a/dist/styles.css b/dist/styles.css index 9f1b7a1..37aa09e 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -499,12 +499,27 @@ ul, ol { padding-left: 1.35rem; } .compact-dialog { max-width: 460px; } .compact-dialog > .small-label { margin-bottom: .65rem; } .dialog-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: .6rem; margin-top: 1.5rem; } -.settings-dialog { width: min(660px, calc(100% - 2rem)); } +.settings-dialog { width: min(660px, calc(100% - 2rem)); word-break: keep-all; overflow-wrap: anywhere; } +.settings-bundle { margin: 1.1rem 0; padding: 1rem; border: 1px solid #cbdac4; border-radius: 9px; background: #f0f5ec; } +.settings-bundle h3 { font-size: 1rem; line-height: 1.6; color: var(--green); } +.settings-bundle p + p { margin-top: .45rem; } +.settings-bundle .settings-bundle-tools { display: flex; flex-wrap: wrap; gap: .6rem; list-style: none; padding: 0; margin: .75rem 0; } +.settings-bundle-tools li { display: flex; flex: 1 1 11rem; flex-wrap: wrap; align-items: baseline; gap: .15rem .6rem; min-width: 0; padding: .6rem .75rem; border: 1px solid #d5e0cd; border-radius: 7px; background: var(--card); } +.settings-bundle-tools li + li { margin: 0; } +.settings-bundle-tools strong { color: var(--green); font-size: .875rem; } +.settings-bundle-tools span { color: #586d4c; font-size: .75rem; } +.settings-bundle.is-unavailable { border-color: #dac9b8; background: #faf4eb; } +.settings-advanced { margin-top: 1rem; border-top: 1px solid var(--line); padding-top: .85rem; } +.settings-advanced summary { cursor: pointer; min-height: 44px; padding: .4rem 0; color: var(--green); font-size: .8125rem; font-weight: 650; line-height: 1.8; touch-action: manipulation; } +.settings-advanced > p { margin-top: .4rem; } +.settings-advanced .settings-tools { margin-bottom: 0; } +.settings-bundle, .settings-advanced { overflow-wrap: anywhere; } .settings-tools { display: grid; gap: .3rem; margin: 1.2rem 0; } .settings-tool-row { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1rem; padding: .8rem 0; border-bottom: 1px solid var(--line); } .settings-tool-row > div { flex: 1 1 15rem; min-width: 0; } .settings-tool-row strong { font-size: .875rem; } .settings-tool-row .button { font-size: .75rem; } +.settings-tool-source { font-size: .6875rem !important; margin-top: .2rem; } .settings-tool-path { font-family: var(--mono); font-size: .6875rem !important; overflow-wrap: anywhere; margin-top: .2rem; } .settings-consent { padding: 1rem; border: 1px solid #dac9b8; background: #faf4eb; border-radius: 9px; } .settings-consent h3 { font-size: .9375rem; margin-bottom: .4rem; } @@ -629,6 +644,8 @@ ul, ol { padding-left: 1.35rem; } .lab-actions .execution-state { margin-left: 0; flex-basis: 100%; font-size: .625rem; } .execution-output-body { padding: .85rem; } .app-dialog { padding: 1.3rem; } + .settings-dialog { width: calc(100% - 24px); max-width: calc(100% - 24px); padding: clamp(12px, 4vw, 1.3rem); } + .settings-bundle, .settings-consent { padding: clamp(12px, 4vw, 1rem); } .app-dialog h2 { font-size: 1.2rem; } .dialog-actions .button { flex: 1; } } diff --git a/docs/bundled-runtime.md b/docs/bundled-runtime.md new file mode 100644 index 0000000..1c9c8c0 --- /dev/null +++ b/docs/bundled-runtime.md @@ -0,0 +1,38 @@ +# v0.3 통합 Windows 패키지 + +학습자는 Java·Maven·Node.js를 따로 설치하거나 시스템 PATH를 변경하지 않는다. NSIS 설치 EXE 또는 전체 ZIP에 Electron 앱, Eclipse Temurin JDK21, Apache Maven3를 포함한다. ZIP은 전체 내용을 해제한 뒤 Java Edu.exe를 실행한다. exe 하나만 다른 폴더로 이동하면 동작하지 않는다. + +## 기본과 선택 도구 + +기본 도구는 Temurin21.0.12.1+1 Windows x64와 Maven3.10.0이다. 앱 내부 resources/toolchains에서 자동 등록하며 실제 실행 때 java·javac를 검증한다. Java8/11/17/21 교재와 컴파일 대상을 지원하지만 기본 실제 JVM은21이다. 네 실제 JVM을 모두 포함한 패키지라고 표현하지 않는다. 기존 사용자의 외부 JDK/Maven 선택을 보존하며 다른 실제 JDK8/11/17 등록은 고급 설정의 선택 기능이다. + +처음 학습자 코드 실행에는 명시적인 동의가 필요하다. 번들 발견·자동 등록이 학습 코드 자동 실행 권한은 아니다. 앱의 프로세스·파일 편집·명령 계약은 [로컬 실행 계약](local-execution.md)을 유지한다. + +코어 실습은 별도 의존성 다운로드 없이 실행한다. 기본20실무프로젝트는 첫 환경 준비에서 앱이 Maven Central의 고정 의존성을 내려받고 기준 테스트를 실행한다. 별도 프로그램 설치는 필요 없지만 이 준비에는 인터넷이 필요하다. 이후 학습자 Maven 제출은 기존 오프라인 모드다. Redis/Kafka 실제 연동은 선택 환경이며 이 패키지가 외부 서버까지 포함하지는 않는다. + +## 패키징과 무결성 + +개발자는 공식 Windows x64 JDK21·Maven ZIP의 체크섬을 확인하고 전체 내용을 해제한다. 원본 바이너리·legal·LICENSE·NOTICE·JAR 내부 고지를 수정하거나 제거하지 않는다. scripts/bundle-runtime.mjs에 두 배포 폴더와 출처 JSON을 전달해 저장소 내부 work/desktop-bundle을 생성한다. 기존 출력은 자동 덮어쓰지 않는다. binaries와 생성 bundle은 Git에 포함하지 않는다. + +출처 JSON은 jdks 배열과 maven 객체에 배포명·정확한 버전·공식 URL·archiveSha256·license·licenseFiles·sourceCodeUrl·redistributionSourceUrl·redistributionSourceFiles를 기록한다. 최종 manifest와 THIRD-PARTY-NOTICES는 앱의 resources/toolchains에 포함한다. 파일별 SHA256 inventory로 스테이징·빌드 전에 전체 파일을 대조한다. 이 목록은 코드 서명이나 OS 보안 샌드박스를 대신하지 않는다. + +```sh +node scripts/bundle-runtime.mjs --jdk21 --maven --metadata desktop/toolchain-provenance.json +node scripts/bundle-runtime.mjs --check +npm run build:desktop +``` + +빌드는 설치 EXE와 Windows x64 ZIP을 함께 만든다. 앱의 설치·ZIP 위치가 바뀌면 번들 항목은 현재 resources 기준으로 다시 해석한다. 외부 선택 경로를 임의로 번들 경로로 변경하지 않는다. 버전별 검사와 실제 설치·ZIP 실행 결과는 별도 QA 보고서에 기록한다. + +## 배포 출처·라이선스·대응 소스 + +JDK ZIP205073461바이트의 SHA256는 `f9d6e191ab098c0d416e7d588a24420a8621cd2f4720dab2459b8b7b2d2d8b4e`다. Maven ZIP10102082바이트의 SHA256는 `1f6d9909266510f039f59aa0e13dcd2c66da85f043e56276e41f2918f8bddaff`이며 Apache의 SHA512도 대조했다. [Temurin 릴리스](https://github.com/adoptium/temurin21-binaries/releases/tag/jdk-21.0.12.1%2B1)와 [Apache Maven 공식 다운로드](https://maven.apache.org/download.cgi)를 기준으로 원본을 확인했다. + +Temurin은 GPLv2와 Classpath Exception, Maven은 Apache License2.0 및 포함된 제3자 조건을 따른다. JDK NOTICE·legal243개 파일, Maven 루트LICENSE/NOTICE·lib/boot의 원본 license 파일과 JAR 내부 고지를 보존한다. [Adoptium FAQ](https://adoptium.net/docs/faq), [공급망 자료](https://adoptium.net/docs/slsa), [Apache License](https://www.apache.org/licenses/LICENSE-2.0)를 참고한다. 이 조건은 Java Edu 프로젝트 자체의 라이선스를 임의로 지정하지 않는다. + +같은 GitHub 릴리스에 다음 대응 소스를 함께 제공한다. 학습자는 이를 실행을 위해 따로 설치할 필요가 없다. + +- OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz: 공식 대응 소스, SHA256 `573057d03584ae793fb7ec9a14c76d826d9187a53efeefd99da47403a5308234`. 바이너리와 source commit `1c417fbfc2f70ab03a565b0af0a5a3c6f5e15ad6`이 일치한다. +- Third-Party-Sources-0.3.0.zip: 정확한 Temurin build commit `e6ba7dec3d07654074559310376a3ae89da5f4ac`, Maven3.10.0 소스, Sisu Inject/Plexus1.1.0 및 javax.annotation-api1.3.2 sources.jar, 출처·체크섬 manifest와 해당 라이선스 고지. + +JDK lib/src.zip만으로 전체 네이티브 코드와 빌드 소스를 대체하지 않는다. Maven 소스 ZIP도 EPL2/CDDL 조건의 위 제3자 JAR 소스를 대체하지 않는다. 교재 Maven 의존성 캐시는 번들에 넣지 않으며, 추후 포함할 경우 전체 전이 의존성과 플러그인의 별도 고지·소스 제공 조건을 검토한다. diff --git a/package-lock.json b/package-lock.json index 0c4701a..33989c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "java-edu", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "java-edu", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@codemirror/commands": "6.11.1", "@codemirror/lang-java": "6.0.2", diff --git a/package.json b/package.json index 571e167..7919787 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "java-edu", - "version": "0.2.0", + "version": "0.3.0", "main": "desktop/main.cjs", "private": true, "type": "module", @@ -9,6 +9,8 @@ "dev": "node scripts/build-editor.mjs && node scripts/build-professional.mjs && node scripts/serve.mjs", "build:editor": "node scripts/build-editor.mjs", "build:professional": "node scripts/build-professional.mjs", + "bundle:runtime": "node scripts/bundle-runtime.mjs", + "check:bundle": "node scripts/bundle-runtime.mjs --check", "desktop": "node scripts/build-editor.mjs && node scripts/build-professional.mjs && electron .", "build:desktop": "node scripts/build-editor.mjs && node scripts/build-professional.mjs && electron-builder --config desktop/build-config.cjs --win --x64", "test:local": "node --test tests/local/*.test.mjs", diff --git a/scripts/bundle-runtime.mjs b/scripts/bundle-runtime.mjs new file mode 100644 index 0000000..0b50252 --- /dev/null +++ b/scripts/bundle-runtime.mjs @@ -0,0 +1,105 @@ +// Build-time staging only. The desktop application never downloads or installs a toolchain. +import { cp, lstat, mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { randomUUID } from 'node:crypto'; +import { loadBundledRuntime, manifestIdentity, inventoryTree, validateProvenance } from '../desktop/bundled-runtime.cjs'; + +const projectRoot = fileURLToPath(new URL('../', import.meta.url)); +const destination = path.join(projectRoot, 'work', 'desktop-bundle'); +const { values } = parseArgs({ options: { + check: { type: 'boolean', default: false }, + metadata: { type: 'string' }, + maven: { type: 'string' }, + jdk8: { type: 'string' }, jdk11: { type: 'string' }, jdk17: { type: 'string' }, jdk21: { type: 'string' }, +}, strict: true }); + +function inside(root, child) { + const relative = path.relative(root, child); + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function record(value) { + validateProvenance(value); + return { distribution: value.distribution, version: value.version, sourceUrl: value.sourceUrl, archiveSha256: value.archiveSha256, + license: value.license, licenseFiles: [...value.licenseFiles], ...(value.sourceCodeUrl ? { sourceCodeUrl: value.sourceCodeUrl } : {}), + ...(value.redistributionSourceUrl ? { redistributionSourceUrl: value.redistributionSourceUrl } : {}), + ...(value.redistributionSourceFiles ? { redistributionSourceFiles: [...value.redistributionSourceFiles] } : {}) }; +} + +async function sourceDirectory(value) { + if (!value) throw new Error('검증한 JDK 21·Maven 폴더와 --metadata가 필요합니다.'); + const root = await realpath(path.resolve(value)); + if (!(await lstat(root)).isDirectory() || inside(root, destination)) throw new Error('배포본 폴더는 출력 디렉터리의 상위일 수 없습니다.'); + return root; +} + +async function copyDistribution(source, target) { + const originals = await inventoryTree(source); // Reject symlinks before copying a vendor distribution. + await cp(source, target, { recursive: true, dereference: false, force: false, errorOnExist: true }); + const copied = await inventoryTree(target); + if (JSON.stringify(originals) !== JSON.stringify(copied)) throw new Error('복사 전후 배포본 파일이 일치하지 않습니다.'); +} + +if (values.check) { + if (Object.entries(values).some(([key, value]) => key !== 'check' && value !== undefined)) throw new Error('--check는 다른 입력과 함께 사용할 수 없습니다.'); + const bundle = await loadBundledRuntime(destination); + console.log(JSON.stringify({ status: 'passed', directory: destination, id: bundle.id, javaVersions: bundle.jdks.map((jdk) => jdk.version), mavenVersion: bundle.maven.version, files: bundle.fileCount, bytes: bundle.bytes }, null, 2)); +} else { + if (!values.metadata || !values.jdk21 || !values.maven) throw new Error('사용법: node scripts/bundle-runtime.mjs --jdk21 <폴더> --maven <폴더> --metadata <배포 출처 JSON>'); + const existing = await lstat(destination).catch((error) => { if (error.code === 'ENOENT') return null; throw error; }); + if (existing) throw new Error('work/desktop-bundle이 이미 있습니다. --check로 검사하거나 기존 결과를 보존한 뒤 새로 준비하세요.'); + const metadataPath = path.resolve(values.metadata); + if ((await lstat(metadataPath)).size > 1024 * 1024) throw new Error('배포 출처 JSON이 너무 큽니다.'); + const metadata = JSON.parse(await readFile(metadataPath, 'utf8')); + if (!Array.isArray(metadata.jdks) || !metadata.jdks.length || metadata.jdks.length > 4) throw new Error('JDK 배포 출처가 필요합니다.'); + const jdks = []; + for (const major of [8, 11, 17, 21]) { + const source = values[`jdk${major}`]; + const entries = metadata.jdks.filter((jdk) => jdk.major === major); + if (entries.length > 1 || Boolean(source) !== (entries.length === 1)) throw new Error(`Java ${major} 폴더와 배포 출처가 일치하지 않습니다.`); + if (!source) continue; + const details = record(entries[0]); + if (!details.sourceCodeUrl) throw new Error('JDK 재배포의 정확한 소스 코드 위치를 기록하세요.'); + const root = await sourceDirectory(source); + const release = await readFile(path.join(root, 'release'), 'utf8'); + const installedVersion = /^JAVA_VERSION="([^"]+)"/m.exec(release)?.[1]; + const installedMajor = Number(installedVersion?.startsWith('1.') ? installedVersion.split('.')[1] : installedVersion?.split(/[.+_-]/)[0]); + if (installedMajor !== major || !/^OS_ARCH="(?:x86_64|amd64)"/m.test(release) || !/^OS_NAME="Windows"/m.test(release)) throw new Error('Windows x64용 요청 JDK 버전인지 확인하세요.'); + const exactVersions = [installedVersion, /^SEMANTIC_VERSION="([^"]+)"/m.exec(release)?.[1], /^JAVA_RUNTIME_VERSION="([^"]+)"/m.exec(release)?.[1]?.replace(/-LTS$/, '')]; + if (!exactVersions.includes(details.version)) throw new Error('기록한 JDK 배포 버전과 실제 release 파일이 일치하지 않습니다.'); + jdks.push({ major, home: `jdks/${major}`, ...details, source: root }); + } + if (metadata.jdks.length !== jdks.length) throw new Error('지원하지 않는 JDK 배포 출처가 있습니다.'); + const mavenSource = await sourceDirectory(values.maven); + const maven = { home: 'maven', ...record(metadata.maven) }; + await mkdir(path.dirname(destination), { recursive: true }); + const staging = path.join(path.dirname(destination), `desktop-bundle.partial-${randomUUID()}`); + await mkdir(staging); + try { + for (const jdk of jdks) await copyDistribution(jdk.source, path.join(staging, ...jdk.home.split('/'))); + await copyDistribution(mavenSource, path.join(staging, maven.home)); + const notices = ['Java Edu bundled third-party distributions', '', + 'These complete, unmodified vendor distributions retain their original licenses and notices.', + 'Archive checksums identify the original downloads; manifest.json records every packaged file.', + 'No vendor endorsement is implied.', '', + ...[...jdks, maven].flatMap((tool) => [ + `${tool.distribution} ${tool.version}`, `License: ${tool.license}`, `Download: ${tool.sourceUrl}`, `Original archive SHA-256: ${tool.archiveSha256}`, + ...(tool.sourceCodeUrl ? [`Corresponding source: ${tool.sourceCodeUrl}`] : []), + ...(tool.redistributionSourceUrl ? [`Redistributed corresponding source: ${tool.redistributionSourceUrl}`, `Source assets / contents: ${(tool.redistributionSourceFiles || []).join(', ')}`] : []), + `License and notice paths: ${tool.licenseFiles.map((file) => `${tool.home}/${file}`).join(', ')}`, '', + ]), + ].join('\n'); + await writeFile(path.join(staging, 'THIRD-PARTY-NOTICES.txt'), notices, 'utf8'); + const manifest = { schemaVersion: 1, platform: 'win32', arch: 'x64', jdks: jdks.map(({ source: _source, ...jdk }) => jdk), maven, files: await inventoryTree(staging) }; + manifest.id = manifestIdentity(manifest); + await writeFile(path.join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + const bundle = await loadBundledRuntime(staging); + await rename(staging, destination); + console.log(JSON.stringify({ status: 'passed', directory: destination, id: bundle.id, javaVersions: bundle.jdks.map((jdk) => jdk.version), mavenVersion: bundle.maven.version, files: bundle.fileCount, bytes: bundle.bytes }, null, 2)); + } catch (error) { + console.error(`불완전한 결과는 실행에 사용하지 않습니다. 검사 경로: ${staging}`); + throw error; + } +} diff --git a/server/local/engine.mjs b/server/local/engine.mjs index 5ae0324..6a0fbcc 100644 --- a/server/local/engine.mjs +++ b/server/local/engine.mjs @@ -133,8 +133,9 @@ export async function createLocalEngine({ dataRoot, projectRoot, settings, runne return { mode: 'local', provider: 'Local JDK', available: versions.length > 0 && value.executionConsent, runtime: { javaVersion: versions.at(-1) || null, compiler: 'registered-local-javac' }, supportedTargets: JAVA_VERSIONS.filter((target) => versions.some((version) => version >= target)), - runtimes: JAVA_VERSIONS.map((version) => ({ javaVersion: version, available: Boolean(value.jdks[version]), path: value.jdks[version]?.home || null })), - maven: { available: Boolean(value.maven), version: value.maven?.version || null, path: value.maven?.home || null }, + runtimes: JAVA_VERSIONS.map((version) => ({ javaVersion: version, available: Boolean(value.jdks[version]), path: value.jdks[version]?.home || null, source: value.jdks[version] ? value.jdks[version].source || 'external' : null })), + maven: { available: Boolean(value.maven), version: value.maven?.version || null, path: value.maven?.home || null, source: value.maven ? value.maven.source || 'external' : null }, + bundledRuntime: value.bundledRuntime || null, consent: { accepted: value.executionConsent, notice: EXECUTION_NOTICE }, notice: EXECUTION_NOTICE, dependencyNotice: DEPENDENCY_NOTICE, reason: !versions.length ? 'jdk_required' : !value.executionConsent ? 'execution_consent_required' : null, preparedProjects: [...prepared.values()].filter((entry) => value.jdks[entry.runtimeVersion]?.home === entry.jdkPath && value.maven?.home === entry.mavenPath && value.maven?.version === entry.mavenVersion), diff --git a/server/local/settings.mjs b/server/local/settings.mjs index b383675..04f6812 100644 --- a/server/local/settings.mjs +++ b/server/local/settings.mjs @@ -53,11 +53,11 @@ export async function inspectMaven(home) { return { home: mavenHome, launcher, configuration, version, verifiedAt: new Date().toISOString() }; } -export function createSettingsStore(dataRoot, { runner = runProcess } = {}) { +export function createSettingsStore(dataRoot, { runner = runProcess, bundledRuntime = null } = {}) { if (!path.isAbsolute(dataRoot)) throw new Error('An absolute app-owned data root is required.'); const settingsFile = path.join(dataRoot, 'settings.json'); const inspectionRoot = path.join(dataRoot, 'tool-checks'); - let settings = { schema: 1, jdks: {}, maven: null, executionConsent: false }; + let settings = { schema: 1, jdks: {}, maven: null, executionConsent: false, bundledRuntime: null }; let writeQueue = Promise.resolve(); async function save() { const snapshot = JSON.stringify(settings, null, 2); @@ -78,24 +78,42 @@ export function createSettingsStore(dataRoot, { runner = runProcess } = {}) { for (const version of JAVA_VERSIONS) { const entry = stored.jdks[version]; // Startup reads only metadata. Executables are probed after the user's selection or execution action. - if (entry && entry.version === version && typeof entry.home === 'string' && path.isAbsolute(entry.home)) { - settings.jdks[version] = { home: entry.home, version, java: path.join(entry.home, 'bin', executableName('java')), javac: path.join(entry.home, 'bin', executableName('javac')), verifiedAt: entry.verifiedAt, description: entry.description }; + // A persisted bundled path can be stale after reinstall/moving a ZIP. Never use it. + if (entry && entry.source !== 'bundled' && entry.version === version && typeof entry.home === 'string' && path.isAbsolute(entry.home)) { + settings.jdks[version] = { home: entry.home, version, java: path.join(entry.home, 'bin', executableName('java')), javac: path.join(entry.home, 'bin', executableName('javac')), verifiedAt: entry.verifiedAt, description: entry.description, source: 'external' }; } } - if (stored.maven?.home && path.isAbsolute(stored.maven.home)) settings.maven = await inspectMaven(stored.maven.home).catch(() => null); + if (stored.maven?.source !== 'bundled' && stored.maven?.home && path.isAbsolute(stored.maven.home)) { + const maven = await inspectMaven(stored.maven.home).catch(() => null); + if (maven) settings.maven = { ...maven, source: 'external' }; + } } } catch { /* An absent or malformed settings file starts with no configured tools. */ } + if (bundledRuntime) { + // Only desktop main supplies this verified, app-resource-owned object. It is not an IPC input. + const bundledMaven = await inspectMaven(bundledRuntime.maven.home); + if (bundledMaven.version !== bundledRuntime.maven.version) throw new LocalError('bundled_maven_mismatch', '앱에 포함된 Maven 버전을 확인할 수 없습니다.', 500); + for (const jdk of bundledRuntime.jdks) { + if (!JAVA_VERSIONS.includes(jdk.version) || !path.isAbsolute(jdk.home)) throw new LocalError('invalid_bundled_jdk', '앱에 포함된 JDK 경로를 확인할 수 없습니다.', 500); + if (!settings.jdks[jdk.version]) settings.jdks[jdk.version] = { + home: jdk.home, version: jdk.version, java: path.join(jdk.home, 'bin', executableName('java')), javac: path.join(jdk.home, 'bin', executableName('javac')), + description: jdk.description, source: 'bundled', bundleId: bundledRuntime.id, + }; + } + if (!settings.maven) settings.maven = { ...bundledMaven, source: 'bundled', bundleId: bundledRuntime.id }; + settings.bundledRuntime = { id: bundledRuntime.id, javaVersions: bundledRuntime.jdks.map((jdk) => jdk.version), mavenVersion: bundledMaven.version }; + } return this.snapshot(); }, snapshot() { return structuredClone(settings); }, async chooseJdk(home, version) { const result = await inspectJdk(home, version, { cwd: inspectionRoot, runner }); - settings.jdks[version] = result; + settings.jdks[version] = { ...result, source: 'external' }; await save(); return this.snapshot(); }, async chooseMaven(home) { - settings.maven = await inspectMaven(home); + settings.maven = { ...await inspectMaven(home), source: 'external' }; await save(); return this.snapshot(); }, @@ -109,8 +127,8 @@ export function createSettingsStore(dataRoot, { runner = runProcess } = {}) { const entry = settings.jdks[version]; if (!entry) throw new LocalError('jdk_required', `실행할 Java ${version} JDK를 먼저 등록하세요.`, 409); const verified = await inspectJdk(entry.home, version, { cwd: inspectionRoot, runner }); - settings.jdks[version] = verified; - return verified; + settings.jdks[version] = { ...verified, source: entry.source || 'external', ...(entry.bundleId ? { bundleId: entry.bundleId } : {}) }; + return settings.jdks[version]; }, }; } diff --git a/tests/local/bundled-runtime-smoke.mjs b/tests/local/bundled-runtime-smoke.mjs new file mode 100644 index 0000000..0e8e73e --- /dev/null +++ b/tests/local/bundled-runtime-smoke.mjs @@ -0,0 +1,72 @@ +// Opt-in actual Windows toolchain check: no chooseJdk/chooseMaven call, no external tool install. +import assert from 'node:assert/strict'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadBundledRuntime } from '../../desktop/bundled-runtime.cjs'; +import { createSettingsStore } from '../../server/local/settings.mjs'; +import { createLocalEngine } from '../../server/local/engine.mjs'; +import { exerciseDefinitions } from '../../server/exercises.mjs'; + +if (process.platform !== 'win32') throw new Error('The bundled distribution is Windows x64 only.'); +const root = fileURLToPath(new URL('../../', import.meta.url)); +const bundleRoot = path.join(root, 'work/desktop-bundle'); +const dataRoot = path.join(root, 'work', `bundled-runtime-smoke-${randomUUID()}`); +await mkdir(dataRoot); +const report = { startedAt: new Date().toISOString(), bundleRoot, dataRoot, results: [] }; +let engine; +try { + const bundle = await loadBundledRuntime(bundleRoot); + report.bundleId = bundle.id; + const settings = createSettingsStore(dataRoot, { bundledRuntime: bundle }); + const initial = await settings.initialize(); + assert.equal(initial.executionConsent, false); + assert.equal(initial.jdks[21].source, 'bundled'); + assert.equal(initial.maven.source, 'bundled'); + assert.deepEqual(Object.keys(initial.jdks), ['21']); + await settings.setConsent(true); + engine = await createLocalEngine({ dataRoot, projectRoot: path.join(root, 'curriculum/professional'), settings }); + assert.equal(engine.runtime().runtime.javaVersion, 21); + const solutions = JSON.parse(await readFile(path.join(root, 'examples/solutions.json'), 'utf8')); + for (const definition of exerciseDefinitions) { + const result = await engine.execute({ exerciseId: definition.id, action: 'submit', source: solutions[definition.id], requestId: `bundled-${definition.id}` }); + report.results.push({ exerciseId: definition.id, runtime: result.runtimeVersion, target: result.targetVersion, status: result.status, passed: result.passed, total: result.total }); + console.log(JSON.stringify(report.results.at(-1))); + if (result.status !== 'passed') console.error(result); + assert.equal(result.status, 'passed'); + assert.equal(result.runtimeVersion, 21); + assert.equal(result.targetVersion, definition.minVersion); + } + for (const lessonId of (process.env.JAVA_EDU_BUNDLE_SMOKE_PROJECT || '').split(',').filter(Boolean)) { + const exerciseId = `${lessonId}-project`; + const prepared = await engine.prepare({ exerciseId, requestId: `prepare-${lessonId}` }); + report.results.push({ exerciseId, action: 'prepare', status: prepared.status, passed: prepared.passed, total: prepared.total }); + console.log(JSON.stringify(report.results.at(-1))); + if (prepared.status !== 'passed') console.error(prepared); + assert.equal(prepared.status, 'passed'); + const lesson = JSON.parse(await readFile(path.join(root, 'curriculum/professional/lessons', lessonId, 'lesson.json'), 'utf8')); + const files = Object.fromEntries(await Promise.all(lesson.professionalExercise.files.filter((file) => file.editable).map(async (file) => [file.path, await readFile(path.join(root, 'curriculum/professional', file.referencePath), 'utf8')]))); + const result = await engine.execute({ exerciseId, action: 'submit', files, requestId: `submit-${lessonId}` }); + report.results.push({ exerciseId, action: 'submit', runtime: result.runtimeVersion, target: result.targetVersion, status: result.status, passed: result.passed, total: result.total }); + console.log(JSON.stringify(report.results.at(-1))); + if (result.status !== 'passed') console.error(result); + assert.equal(result.status, 'passed'); + } + const restarted = createSettingsStore(dataRoot, { bundledRuntime: await loadBundledRuntime(bundleRoot) }); + const restored = await restarted.initialize(); + assert.equal(restored.jdks[21].source, 'bundled'); + assert.equal(restored.maven.source, 'bundled'); + assert.equal(restored.executionConsent, true); + report.status = 'passed'; +} catch (error) { + report.status = 'failed'; + report.error = error.message; + throw error; +} finally { + await engine?.close(); + report.finishedAt = new Date().toISOString(); + const evidence = path.join(dataRoot, 'results.json'); + await writeFile(evidence, `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify({ evidence, status: report.status })); +} diff --git a/tests/local/bundled-runtime.test.mjs b/tests/local/bundled-runtime.test.mjs new file mode 100644 index 0000000..7ebbcb9 --- /dev/null +++ b/tests/local/bundled-runtime.test.mjs @@ -0,0 +1,190 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, rm, realpath, rename, symlink, cp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { inventoryTree, loadBundledRuntime, manifestIdentity } from '../../desktop/bundled-runtime.cjs'; +import { createSettingsStore } from '../../server/local/settings.mjs'; +import { createLocalEngine } from '../../server/local/engine.mjs'; + +async function scratch(t) { + const parent = await realpath(tmpdir()); + const root = await mkdtemp(path.join(parent, 'java-edu-bundle-test-')); + t.after(async () => { + const resolved = await realpath(root); + assert.equal(path.dirname(resolved), parent); + assert.ok(path.basename(resolved).startsWith('java-edu-bundle-test-')); + await rm(resolved, { recursive: true, force: true }); + }); + return root; +} + +async function fixture(root, name = 'toolchains') { + const directory = path.join(root, name); + const files = { + 'jdks/21/bin/java.exe': 'fixture Java runtime', 'jdks/21/bin/javac.exe': 'fixture Java compiler', + 'jdks/21/release': 'JAVA_VERSION="21.0.12.1"\nOS_ARCH="x86_64"\nOS_NAME="Windows"\n', + 'jdks/21/legal/java.base/LICENSE': 'fixture license', + 'maven/boot/plexus-classworlds-2.9.0.jar': 'fixture launcher', 'maven/bin/m2.conf': 'fixture config', + 'maven/lib/maven-core-3.10.0.jar': 'fixture Maven', 'maven/LICENSE': 'fixture Maven license', + 'THIRD-PARTY-NOTICES.txt': 'fixture provenance', + }; + for (const [relative, content] of Object.entries(files)) { + const file = path.join(directory, ...relative.split('/')); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content); + } + const manifest = { schemaVersion: 1, platform: 'win32', arch: 'x64', + jdks: [{ major: 21, home: 'jdks/21', distribution: 'Fixture JDK', version: '21.0.12.1+1', sourceUrl: 'https://example.org/jdk.zip', archiveSha256: 'a'.repeat(64), license: 'fixture-only', licenseFiles: ['legal/java.base/LICENSE'] }], + maven: { home: 'maven', distribution: 'Fixture Maven', version: '3.10.0', sourceUrl: 'https://example.org/maven.zip', archiveSha256: 'b'.repeat(64), license: 'fixture-only', licenseFiles: ['LICENSE'] }, + files: await inventoryTree(directory), + }; + await saveManifest(directory, manifest); + return { directory, manifest }; +} + +async function saveManifest(directory, manifest) { + manifest.id = manifestIdentity(manifest); + await writeFile(path.join(directory, 'manifest.json'), JSON.stringify(manifest)); +} + +const invalidBundle = { code: 'invalid_bundled_runtime' }; + +test('bundle metadata is resolved only from the verified current resources root', async (t) => { + const root = await scratch(t); + const { directory } = await fixture(root); + const bundle = await loadBundledRuntime(directory); + assert.equal(bundle.jdks[0].version, 21); + assert.equal(bundle.jdks[0].home, path.join(await realpath(directory), 'jdks', '21')); + assert.equal(bundle.maven.home, path.join(await realpath(directory), 'maven')); + assert.equal(bundle.fileCount, 9); + assert.equal(await loadBundledRuntime(path.join(root, 'absent'), { required: false }), null); + await assert.rejects(loadBundledRuntime(path.join(root, 'absent')), invalidBundle); +}); + +test('changed, missing and unlisted toolchain files fail integrity before registration', async (t) => { + const root = await scratch(t); + for (const scenario of ['changed', 'missing', 'extra', 'manifest']) { + const { directory } = await fixture(root, scenario); + if (scenario === 'changed') await writeFile(path.join(directory, 'jdks/21/bin/java.exe'), 'changed'); + if (scenario === 'missing') await rm(path.join(directory, 'maven/LICENSE')); + if (scenario === 'extra') await writeFile(path.join(directory, 'maven/extra.jar'), 'unlisted'); + if (scenario === 'manifest') { + const manifest = JSON.parse(await readFile(path.join(directory, 'manifest.json'))); + manifest.maven.version = '3.9.0'; + await writeFile(path.join(directory, 'manifest.json'), JSON.stringify(manifest)); + } + await assert.rejects(loadBundledRuntime(directory), invalidBundle, scenario); + } +}); + +test('self-consistent metadata cannot redirect homes, inventory paths or case aliases', async (t) => { + const root = await scratch(t); + const cases = [ + (manifest) => { manifest.jdks[0].home = '../outside'; }, + (manifest) => { manifest.files[0].path = '../outside/bin/java.exe'; }, + (manifest) => { manifest.files[0].path = 'jdks/21/bin/java.exe:stream'; }, + (manifest) => { manifest.files.push({ ...manifest.files[0], path: manifest.files[0].path.toUpperCase() }); }, + (manifest) => { manifest.jdks[0].licenseFiles = ['../LICENSE']; }, + ]; + for (const [index, mutate] of cases.entries()) { + const { directory, manifest } = await fixture(root, `invalid-${index}`); + mutate(manifest); + await saveManifest(directory, manifest); + await assert.rejects(loadBundledRuntime(directory), invalidBundle); + } +}); + +test('directory links in a bundle cannot point at an external toolchain', async (t) => { + const root = await scratch(t); + const { directory } = await fixture(root); + const outside = path.join(root, 'outside-bin'); + await rename(path.join(directory, 'jdks/21/bin'), outside); + const link = path.join(directory, 'jdks/21/bin'); + await symlink(outside, link, process.platform === 'win32' ? 'junction' : 'dir'); + try { await assert.rejects(loadBundledRuntime(directory), invalidBundle); } + finally { await rm(link); } +}); + +test('fresh bundled settings need no folder selection, no startup execution and explicit consent', async (t) => { + const root = await scratch(t); + const { directory } = await fixture(root); + const bundle = await loadBundledRuntime(directory); + let probes = 0; + const settings = createSettingsStore(path.join(root, 'data'), { bundledRuntime: bundle, runner: async () => { probes++; throw new Error('No startup process'); } }); + const value = await settings.initialize(); + assert.equal(probes, 0); + assert.equal(value.jdks[21].source, 'bundled'); + assert.equal(value.maven.source, 'bundled'); + assert.equal(value.executionConsent, false); + const engine = await createLocalEngine({ dataRoot: path.join(root, 'data'), settings, projects: [] }); + const runtime = engine.runtime(); + assert.equal(runtime.available, false); + assert.deepEqual(runtime.supportedTargets, [8, 11, 17, 21]); + assert.equal(runtime.runtime.javaVersion, 21); + assert.deepEqual(runtime.runtimes.filter((entry) => entry.available).map((entry) => [entry.javaVersion, entry.source]), [[21, 'bundled']]); + assert.equal(runtime.maven.source, 'bundled'); + assert.deepEqual(runtime.bundledRuntime, { id: bundle.id, javaVersions: [21], mavenVersion: '3.10.0' }); + await assert.rejects(engine.execute({ exerciseId: 'java-basics-lab', source: 'class Solution {}', action: 'run', requestId: 'no-consent' }), { code: 'execution_consent_required' }); + assert.equal(probes, 0); + await settings.setConsent(true); + assert.equal(engine.runtime().available, true); + await engine.close(); +}); + +test('reinstall and ZIP move repair bundled paths while old absolute paths are ignored', async (t) => { + const root = await scratch(t); + const { directory } = await fixture(root); + const dataRoot = path.join(root, 'data'); + const first = createSettingsStore(dataRoot, { bundledRuntime: await loadBundledRuntime(directory) }); + await first.initialize(); + await first.setConsent(true); + const saved = JSON.parse(await readFile(path.join(dataRoot, 'settings.json'))); + saved.jdks[21].home = path.join(root, 'attacker-selected-home'); + saved.maven.home = path.join(root, 'attacker-selected-maven'); + await writeFile(path.join(dataRoot, 'settings.json'), JSON.stringify(saved)); + const moved = path.join(root, 'moved toolchains 한글'); + await rename(directory, moved); + const next = createSettingsStore(dataRoot, { bundledRuntime: await loadBundledRuntime(moved) }); + const value = await next.initialize(); + assert.equal(value.jdks[21].home, path.join(await realpath(moved), 'jdks', '21')); + assert.equal(value.maven.home, path.join(await realpath(moved), 'maven')); + assert.equal(value.executionConsent, true); + const withoutBundle = createSettingsStore(dataRoot); + const absent = await withoutBundle.initialize(); + assert.deepEqual(absent.jdks, {}); + assert.equal(absent.maven, null); +}); + +test('legacy or explicit external selections are preserved across bundled startup and probes', async (t) => { + const root = await scratch(t); + const { directory } = await fixture(root); + const externalJdk = path.join(root, 'external-jdk21'); + const externalMaven = path.join(root, 'external-maven'); + await cp(path.join(directory, 'jdks/21'), externalJdk, { recursive: true }); + if (process.platform !== 'win32') { + await cp(path.join(externalJdk, 'bin/java.exe'), path.join(externalJdk, 'bin/java')); + await cp(path.join(externalJdk, 'bin/javac.exe'), path.join(externalJdk, 'bin/javac')); + } + await cp(path.join(directory, 'maven'), externalMaven, { recursive: true }); + const dataRoot = path.join(root, 'data'); + await mkdir(dataRoot); + await writeFile(path.join(dataRoot, 'settings.json'), JSON.stringify({ schema: 1, executionConsent: true, jdks: { 21: { home: externalJdk, version: 21 } }, maven: { home: externalMaven } })); + const optionsSeen = []; + const runner = async (executable, _args, options) => { + optionsSeen.push(options); + return { reason: 'exited', exitCode: 0, stdout: executable.includes('javac') ? 'javac 21.0.12.1' : '', stderr: executable.includes('javac') ? '' : 'openjdk version "21.0.12.1"' }; + }; + const settings = createSettingsStore(dataRoot, { runner, bundledRuntime: await loadBundledRuntime(directory) }); + const value = await settings.initialize(); + assert.equal(value.jdks[21].source, 'external'); + assert.equal(value.jdks[21].home, externalJdk); + assert.equal(value.maven.home, await realpath(externalMaven)); + assert.equal(value.maven.source, 'external'); + assert.equal(optionsSeen.length, 0); + const verified = await settings.verifyJdk(21); + assert.equal(verified.source, 'external'); + assert.equal(verified.home, await realpath(externalJdk)); + assert.equal(optionsSeen.length, 2); + for (const options of optionsSeen) for (const name of ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', '_JAVA_OPTIONS', 'MAVEN_ARGS', 'CLASSPATH']) assert.equal(Object.hasOwn(options.env, name), false); +}); From 0e1ff5eb38484d1594d4fd1c231d4dbe229b44e8 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:30:04 +0900 Subject: [PATCH 17/29] =?UTF-8?q?fix(desktop):=20=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=EC=8B=9C=20=EB=B2=88=EB=93=A4=20=EC=9B=90=EB=B3=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/build-config.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/build-config.cjs b/desktop/build-config.cjs index e36452c..c6b90d0 100644 --- a/desktop/build-config.cjs +++ b/desktop/build-config.cjs @@ -12,6 +12,7 @@ module.exports = { directories: { output: 'desktop-release' }, files: ['desktop/**', 'server/**', 'dist/**', '!dist/server/**', '!dist/.openai/**', 'curriculum/professional/**', 'package.json', '!**/target/**', '!**/node_modules/**'], win: { target: [{ target: 'nsis', arch: ['x64'] }, { target: 'zip', arch: ['x64'] }], artifactName: 'Java-Edu-${version}-${arch}.${ext}', signAndEditExecutable: false }, - nsis: { artifactName: 'Java-Edu-Setup-${version}-${arch}.${ext}', oneClick: false, perMachine: false, allowElevation: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true, createStartMenuShortcut: true, runAfterFinish: false, deleteAppDataOnUninstall: false }, + // Preserve vendor ARM64 files: the bundled NSIS 7z decoder predates the ARM64 codec. + nsis: { artifactName: 'Java-Edu-Setup-${version}-${arch}.${ext}', differentialPackage: false, useZip: true, oneClick: false, perMachine: false, allowElevation: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true, createStartMenuShortcut: true, runAfterFinish: false, deleteAppDataOnUninstall: false }, publish: null, }; From 3a745cb2ed01eddc5295f7c92bd994026d696c45 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:06:06 +0900 Subject: [PATCH 18/29] =?UTF-8?q?docs(desktop):=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=82=AC=EC=9A=A9=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=EC=99=80=20=EC=B6=9C=EC=8B=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 +- docs/architecture.md | 2 +- docs/bundled-desktop-qa-report.md | 82 +++++ docs/bundled-runtime.md | 2 +- docs/development-handoff.md | 12 + docs/installation.md | 28 +- docs/local-execution.md | 6 +- docs/milestones.md | 1 + docs/product-plan.md | 2 +- docs/verification.md | 14 + tests/bundled-desktop.mjs | 515 ++++++++++++++++++++++++++++++ 11 files changed, 660 insertions(+), 33 deletions(-) create mode 100644 docs/bundled-desktop-qa-report.md create mode 100644 tests/bundled-desktop.mjs diff --git a/README.md b/README.md index 8b13003..b1b9662 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 다른 언어 경험자의 Java 전환 학습부터 현직 개발자의 OOP·백엔드 심화까지. -[![Java](https://img.shields.io/badge/Java-8%20%7C%2011%20%7C%2017%20%7C%2021-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/java-version-matrix.md) [![Windows](https://img.shields.io/badge/Desktop-Windows%20x64-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) [![Lessons](https://img.shields.io/badge/Lessons-34-806223?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) [![Status](https://img.shields.io/badge/Status-v0.2.0%20preview-806223?style=flat-square)](https://github.com/jbsim999/java-edu/issues) [![CI](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml/badge.svg?branch=feat%2Fjava-learning-mvp)](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml?query=branch%3Afeat%2Fjava-learning-mvp) +[![Java](https://img.shields.io/badge/Java-8%20%7C%2011%20%7C%2017%20%7C%2021-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/java-version-matrix.md) [![Windows](https://img.shields.io/badge/Desktop-Windows%20x64-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) [![Lessons](https://img.shields.io/badge/Lessons-34-806223?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) [![Status](https://img.shields.io/badge/Status-v0.3.0%20preview-806223?style=flat-square)](https://github.com/jbsim999/java-edu/issues) [![CI](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml/badge.svg?branch=feat%2Fjava-learning-mvp)](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml?query=branch%3Afeat%2Fjava-learning-mvp) [교육 로드맵](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) · [개발 소스](https://github.com/jbsim999/java-edu/tree/feat/java-learning-mvp) · [기여 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/CONTRIBUTING.md) · [버그·제안](https://github.com/jbsim999/java-edu/issues) @@ -20,7 +20,7 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교 수업은 **이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩** 순서로 진행합니다. Java 언어·표준 API는 Oracle/OpenJDK 공식 문서를, 프레임워크·라이브러리는 각 프로젝트의 공식 문서를 기준으로 합니다. 실무 주제에는 출처를 기록한 채용 공고의 기술 요건도 연결합니다. -> **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. [Windows v0.2.0 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)를 설치해 학습할 수 있습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 34수업·이론 도식 12개·편집기 테마가 배포돼 있습니다. +> **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. [Windows v0.3.0 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)를 설치해 학습할 수 있습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 34수업·이론 도식 12개·편집기 테마가 배포돼 있습니다. ## 학습실 미리보기 @@ -37,7 +37,7 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교 | 검수한 이론 도식 | 참조·상태·호출·보안 조건을 설명하는 12개 도식과 공식 참고자료 | | 직접 코딩 | 클래스 골격, 메서드 본문, 다중 파일 프로젝트에 코드 작성 | | 읽기 쉬운 편집기 | 클래스·타입·변수·메서드 문법 색상, 다크·라이트·고대비 테마 | -| 로컬 Java 실행 | Windows 앱에서 등록한 JDK로 컴파일·실행·제출·취소 | +| 로컬 Java 실행 | 별도 설치 없이 포함된 JDK 21로 컴파일·실행·제출·취소 | | 실무 프로젝트 | Spring/H2·REST·MyBatis·JPA·보안 과제를 고정 테스트로 확인 | | 이어서 학습 | 진도·메모·코드 초안·테마 저장 및 앱 재시작 후 복원 | @@ -59,7 +59,7 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교 ## 빠르게 시작하기 -**Windows 학습자:** [v0.2.0 설치 파일](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)을 내려받아 설치하고 앱에서 JDK 폴더를 등록합니다. Spring 실습에는 Maven 3.9 이상도 등록합니다. JDK/Maven은 설치 파일에 포함하지 않습니다. [설치 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) +**Windows 학습자:** [v0.3.0 통합 패키지](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)에서 **설치 EXE 또는 ZIP 중 하나**를 받습니다. EXE는 설치 후 실행하고, ZIP은 전체 내용을 풀고 `Java Edu.exe`를 실행합니다. Java·Maven·Node.js가 포함되어 별도 설치와 폴더 등록이 필요 없습니다. Spring 실습의 첫 환경 준비에는 인터넷이 필요합니다. [설치 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) **개발 소스 실행:** Node.js 22 이상이 필요합니다. @@ -83,16 +83,18 @@ npm run dev npm run desktop ``` -앱의 실행 설정에서 JDK 폴더와 Maven 폴더를 선택하고 코드 실행에 동의합니다. 실무 과제는 **환경 준비 → 파일 편집 → 실행·제출** 순서로 진행합니다. [Windows 사용 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) +개발 실행에 번들을 준비하지 않았다면 앱에서 자신의 JDK/Maven을 등록합니다. 배포 패키지는 도구를 자동 등록하며, 학습 코드 실행에는 명시적인 동의가 필요합니다. 실무 과제는 **환경 준비 → 파일 편집 → 실행·제출** 순서로 진행합니다. [Windows 사용 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md)
Windows 설치 파일 직접 빌드 ```sh +node scripts/bundle-runtime.mjs --jdk21 --maven --metadata desktop/toolchain-provenance.json +npm run check:bundle npm run build:desktop ``` -`desktop-release/`에 Windows x64 NSIS 설치 파일을 생성합니다. 배포된 v0.2.0은 실제 설치·앱 실행·제거 검증을 통과한 코드 서명되지 않은 프리뷰입니다. 직접 재빌드한 파일은 배포 파일과 별도로 검증해야 합니다. [설치 파일과 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0) +공식 원본 ZIP의 체크섬을 대조하고 전체 배포 폴더를 준비한 뒤 빌드합니다. `desktop-release/`에 Windows x64 NSIS 설치 EXE와 전체 ZIP을 생성합니다. 직접 재빌드한 파일은 배포 파일과 별도로 검증해야 합니다. [번들·출처·대응 소스 계약](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-runtime.md) · [배포 파일과 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)
@@ -100,12 +102,12 @@ npm run build:desktop | 구분 | Windows 앱 | 웹 교재 | | --- | --- | --- | -| Java 코어 | 등록한 실제 JDK 8·11·17·21 사용 | Wandbox의 JVM 21, 과제별 최소 지원 버전으로 컴파일 | -| Spring 프로젝트 | 컴파일 대상 17, 실제 JDK 17/21·Maven 사용 | 교재·파일 편집·ZIP 다운로드 | +| Java 코어 | 기본 포함 JVM 21, 컴파일 대상 8·11·17·21; 다른 실제 JDK는 선택 등록 | Wandbox의 JVM 21, 과제별 최소 지원 버전으로 컴파일 | +| Spring 프로젝트 | 컴파일 대상 17, 포함 JDK 21·Maven 3.10.0 사용 | 교재·파일 편집·ZIP 다운로드 | | 코드 전송 | 작성 코드를 Wandbox로 전송하지 않음 | 코어 실행·제출 시 Wandbox로 전송 | | 학습 기록 | 앱 데이터에 저장 | 해당 브라우저에 저장 | -웹과 앱 간 자동 동기화는 제공하지 않습니다. 실무 환경 준비에는 의존성 다운로드가 필요합니다. [로컬 실행 계약](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/local-execution.md) +웹과 앱 간 자동 동기화는 제공하지 않습니다. 실무 첫 환경 준비에는 앱 내부의 의존성 다운로드가 필요하며, 처음부터 모든 실무가 완전 오프라인인 패키지는 아닙니다. 실제 JDK 8·11·17 추가 등록은 고급 설정의 선택 기능입니다. [로컬 실행 계약](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/local-execution.md) ## 개발과 검증 @@ -121,7 +123,7 @@ npm run build:desktop 실무 검사는 `JAVA_HOME`·`MAVEN_HOME` 설정이 필요합니다. 기본 검사는 Redis/Kafka를 제외합니다. 브라우저 검사는 실행 제공자의 실제 동작 검증과 구분합니다. -2026-10-12 기준, 출시 소스 `6ba4b8a`의 [CI 12작업](https://github.com/jbsim999/java-edu/actions/runs/38213755330)이 모두 성공했습니다. 실제 설치 앱 **8/8**, 설치·제거, 독립 시각 검수 PASS. 이론 도식 **19/19**, 기존 브라우저 회귀 **49/49**, 기본 20실무프로젝트 **54개 JUnit 검사**도 통과했습니다. CI 배지는 개발 브랜치의 현재 결과를 표시하며, 검증 범위와 미확인은 기록을 따릅니다. [검증 기록](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/verification.md) · [앱 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/desktop-qa-report.md) +2026-10-12 기준, 통합 패키지 소스 `0e1ff5e`의 [CI 12작업](https://github.com/jbsim999/java-edu/actions/runs/38223955175)이 모두 성공했습니다. 최종 실제 ZIP **10/10**, NSIS 설치 앱 **10/10**, 원본 도구 604파일 대조·설치/제거·데이터 보존 및 독립 시각 검수 PASS. 기본 실무 20프로젝트·54개 JUnit, 4실제 JDK 검사는 CI에서 별도로 통과했습니다. CI 배지는 개발 브랜치의 현재 결과를 표시하며 검증 범위와 미확인은 기록을 따릅니다. [통합 패키지 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-desktop-qa-report.md) · [검증 기록](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/verification.md) ## 문서와 로드맵 @@ -133,7 +135,7 @@ npm run build:desktop | 이론 도식의 선정과 검수 | [34수업 평가·12개 채택 근거](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/theory-visuals.md) · [독립 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/theory-visual-qa-report.md) | | 채용 공고 조사 근거 | [출처·표본·조사 범위](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/job-evidence.md) | | 구조와 실행 경계 | [아키텍처](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/architecture.md) | -| 설치된 앱의 실행·제거 검증 | [설치 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installed-desktop-qa-report.md) | +| 통합 ZIP·설치 앱 검증 | [번들 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-desktop-qa-report.md) · [이전 0.2 설치 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installed-desktop-qa-report.md) | | 현재 작업·출시 상태 | [마일스톤](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/milestones.md) · [개발 인계](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/development-handoff.md) | - [x] Java 코어 12수업과 직접 코딩 과제 @@ -143,7 +145,8 @@ npm run build:desktop - [x] 이론 도식 12개와 공식 출처·텍스트 설명 - [x] Windows CI·설치/제거 검증·최종 시각 검수 - [x] 34수업·이론 도식 웹 교재 배포 -- [x] [Windows v0.2.0 프리뷰 릴리스](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0) +- [x] Java·Maven 포함 Windows EXE와 전체 ZIP 패키징 +- [x] [Windows v0.3.0 통합 프리뷰 릴리스](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0) - [ ] 선택 과정의 Redis/Kafka 실제 연동 검증 ## 기여와 문의 @@ -154,4 +157,4 @@ npm run build:desktop ## 라이선스 -프로젝트 코드·교재의 라이선스는 아직 지정하지 않았습니다. 편집기에 포함된 외부 패키지의 라이선스 고지는 편집기 빌드에 포함하며 각 패키지의 조건을 따릅니다. +프로젝트 코드·교재의 라이선스는 아직 지정하지 않았습니다. 편집기·JDK·Maven과 포함된 제3자 구성 요소의 고지를 원본대로 보존하며 각 조건을 따릅니다. 대응 JDK·빌드·제3자 소스도 같은 릴리스에서 제공합니다. 학습자는 EXE 또는 ZIP 하나만 받으면 됩니다. [출처와 소스](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-runtime.md) diff --git a/docs/architecture.md b/docs/architecture.md index 87db1b1..cb8b575 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ 브라우저→같은 origin의 실행 API→외부 Wandbox 컴파일/실행 API로 연결한다. 제공자 endpoint·compiler·옵션은 서버가 고정한다. 교육 콘텐츠가 가진 과제 ID·버전·trusted checks를 서버가 결정하며 클라이언트의 기대값·컴파일 옵션을 신뢰하지 않는다. -웹 실행의 실제 JVM은21(`openjdk-jdk-21+35`). `--release`로8/11/17/21의 언어/API 대상 컴파일을 검사해도 실행 JVM은21이다. 설치형은 등록한 실제 JDK8/11/17/21을 사용하며 CI에서도 네 런타임을 분리한다. 2026-10-12 웹 확인용 코드는 `--release\n8`과 package-private `class Main`으로 컴파일하고 `JAVA_EDU_OK`·`21-internal`을 출력했다. Java8 obsolete 경고와 종료0을 함께 확인했다. 이 결과는 전체 과제 검증을 대신하지 않는다. +웹 실행의 실제 JVM은21(`openjdk-jdk-21+35`). `--release`로8/11/17/21의 언어/API 대상 컴파일을 검사해도 실행 JVM은21이다. 설치형 v0.3은 포함된 실제 JDK21을 자동 등록하며, 다른 실제 JDK8/11/17/21 등록은 선택 기능이다. CI에서는 네 실제 런타임을 분리한다. [번들 계약](bundled-runtime.md)을 따른다. 2026-10-12 웹 확인용 코드는 `--release\n8`과 package-private `class Main`으로 컴파일하고 `JAVA_EDU_OK`·`21-internal`을 출력했다. Java8 obsolete 경고와 종료0을 함께 확인했다. 이 결과는 전체 과제 검증을 대신하지 않는다. 주 파일은prog.java이므로 template의 Solution과 Main은 package-private다. 사용자 Solution과 trusted Main harness를 합쳐 실행한다. 호출·반환 타입·컴파일 대상·샘플/제출 검사는 배포 소스에서 고정한다. 결과는 실행ID·작성 코드 snapshot에 연결한다. 단순 stdout 문구만으로 정답을 판정하지 않는다. 교육용 행동 검증은 악의적 제출에 대한 완전한 부정행위 방지 시스템을 뜻하지 않는다. diff --git a/docs/bundled-desktop-qa-report.md b/docs/bundled-desktop-qa-report.md new file mode 100644 index 0000000..4a28a9b --- /dev/null +++ b/docs/bundled-desktop-qa-report.md @@ -0,0 +1,82 @@ +# 0.3 번들 Windows 앱 독립 QA + +상태: **PASS — 최신 ZIP 10/10·NSIS 10/10 기능, 설치·제거, 독립 시각 검수**. 2026-10-12에 같은 최종 제품으로 캡처 안정화 조건을 추가해 ZIP은 08:53:27.461–08:54:38.767 UTC, NSIS는 08:57:30.366–08:58:39.164 UTC에 실제 검증하고 각각 정상 종료했다. 총괄은 08:59:47 UTC에 제거·테스트 데이터 보관을 마쳤다. 아래 판정은 각 제품의 최신 10개 검사에 근거하며 이전 실행이나 0.2 결과를 합산하지 않는다. 초기 환경 검사·이전 ZIP·첫 NSIS 무결성 실패 이력은 따로 보존한다. + +실행한 테스트 SHA-256은 두 제품 모두 `1b3cede082f77048135e87537c97cedef5b87bcce73644f040aa66dbd9f62969`다. 최신 결과는 `work/bundled-desktop-qa/zip/bundled-results-1791795278768.json`과 `work/bundled-desktop-qa/nsis/bundled-results-1791795519165.json`이다. 실제 Electron 패키지·번들 JVM/Maven을 사용했으며 API·실행 결과를 모의 처리하지 않았다. + +독립 시각 검수에서 이전 재시작 화면의 상단 빈 띠를 발견해 추가 검증했다. 기존 helper는 toast 숨김만 기다렸지만, 새 helper는 실제 scrollX/Y·viewport·문서 크기·주요 영역 bounds·내부 scrollTop의 600ms 연속 안정과 캡처 전후 일치를 확인한다. ZIP은 scrollY가 923→1185→1201로 이동한 뒤 332–945ms 동안, NSIS는 923→1178→1201로 이동한 뒤 293–906ms 동안 동일하게 유지됐다. 안정 시 두 제품 모두 사이드바 y=24px·편집기 y=429.63px였다. 재시작 기본·확인 PNG 네 장의 SHA-256은 모두 `04fc6d5a3c960b5c3febbc26c2a90c659fbfa770b5248fdf16bf7c40918c20aa`이며 직접 확인한 네 이미지에 이전 211/248px의 상단 빈 띠가 없다. 같은 제품에서 지속적인 레이아웃 결함은 재현되지 않았고, 기존 캡처의 이동 완료 대기 누락으로 판단한다. 화면을 맞추는 스크롤·스타일·초점 조작이나 제품 변경은 추가하지 않았다. 각 결과 JSON의 `captures`에 샘플을 보존했다. + +## 실제 결과 + +| 검사 | ZIP | NSIS | +| --- | --- | --- | +| 외부 도구 경로 없이 번들 자동 등록 | PASS; JVM 21·Maven 3.10.0, source bundled | 동일 PASS | +| 동의 전 실행·폴더 선택 없음 | PASS; 학습자 요청 0회, 폴더 선택 0회 | 동일 PASS | +| Java 8 대상 제출 | 실제 JVM 21에서 6/6 | 실제 JVM 21에서 6/6 | +| Java 11 대상 제출 | 실제 JVM 21에서 5/5 | 실제 JVM 21에서 5/5 | +| Java 17 대상 제출 | 실제 JVM 21에서 6/6 | 실제 JVM 21에서 6/6 | +| Java 21 대상 제출 | 실제 JVM 21에서 6/6 | 실제 JVM 21에서 6/6 | +| Spring/H2 환경 준비 | 실제 Maven 2/2; 초안·미완료 성취 유지 | 동일 PASS | +| Spring/H2 학생 제출 | 실제 Maven 2/2; --offline 확인 | 동일 PASS | +| 실제 앱 재시작 | 포트 52415→61427; 등록 경로·동의·진도·초안·테마 보존 | 포트 63593→60542; 동일 보존 PASS | +| 최소 창·문서 글꼴 200% | 704×521 viewport; 상단·도구 카드·닫기 버튼 전체 가시성, 가로 넘침 없음·닫기 PASS | 동일 PASS | + +각 제품에서 실제 관측한 코어 java/javac 명령 8개와 Maven 명령 2개 모두 해당 제품의 `resources/toolchains` 내부 파일을 사용했다. 각 javac의 `--release` 값은 8/11/17/21에 일치했다. 준비의 실제 Maven 명령에는 `--offline`이 없고 학생 제출에는 있었다. 두 앱 자식의 PATH/JAVA_HOME/MAVEN_HOME는 실제로 모두 부재였고 기본 사용자 데이터는 `C:/Users/USER/AppData/Roaming/java-edu`였다. 기록된 브라우저 오류는 각각 0건이며 앱 API 요청은 해당 앱의 loopback origin뿐이었다. + +각 제품의 최신 폴더에 `bundled-first-launch-settings.png`, `bundled-settings-minimum200-{top,tools,close}.png`, 코어 대상별 제출, Spring/H2 준비·제출, `bundled-restarted-achievement.png`와 `bundled-restarted-achievement-confirmation.png`를 저장했다. 재시작 캡처는 자연스럽게 스크롤된 편집기 위치이며, 저장된 객관식·실습 성취 자체는 UI의 실제 data-complete 및 저장값으로 별도 검증했다. 모든 최신 원본을 독립 시각 담당에게 전달했다. + +총괄의 최신 `work/bundled-desktop-qa/package-zip.json`은 검토 커밋 `0e1ff5eb38484d1594d4fd1c231d4dbe229b44e8`의 원본 197개·생성 2개·번들 604개(355,610,126 bytes)가 빌드 및 최종 ZIP과 일치함을 기록한다. 번들 ID는 `548c060050b821dbcb99fd90e3f9e992e47e6ab3899d12551c9d7fb965581760`, 최종 ASAR SHA-256은 `8ee1c5df3de2472ff18a31e45cb6054cedd17b89389db8e579aea87c8c7c51e2`, 앱 exe SHA-256은 `d7e0bfab510476dc433b175624a10dd4753c725c4d72949f6870a2af7b09c043`다. 패키지 전체 바이트 대조는 총괄 수행이며 이 보고서의 실제 앱 실행과 구분한다. + +총괄의 최신 `installation.json`은 08:56:18 UTC에 최종 NSIS가 지정 특수문자 경로에 exit 0으로 설치되었음을 기록한다. 설치 EXE SHA-256은 `b3e0a56b339fc3946cd80c194b9b5a544fdb3d5044277fc00acd6a9a11e8eeb8`이다. `package-nsis.json`도 같은 소스 197개·생성 2개·번들 604개 및 동일 ASAR/exe/helper 바이트 일치를 확인한다. QA는 실제 앱을 종료했으며 설치·제거·레지스트리 변경·AppData 보관을 직접 수행하지 않았다. + +총괄의 최신 `uninstallation.json`은 08:59:47 UTC에 NSIS exit 0, 전용 설치 경로 없음, 제거 등록 0개, 설치 바로가기 0개, 학습 데이터 보존을 확인한다. 이후 총괄은 사전 부재·실제 실행 결과의 userData·생성 시점·종료 상태를 대조해 이번 ZIP 데이터를 `app-data-zip-stability`, NSIS 데이터를 `app-data-nsis-stability`로 보관했다. 각 `-archive.json`은 해당 최신 실행 JSON과 정확한 원래 userData를 연결한다. 원래 사용자 AppData 두 경로가 없던 상태를 복원했다는 총괄 확인을 받았다. 이 보관·제거는 총괄 수행이다. + +## 패키징 수정 이력 + +같은 최종 소스의 안정화 전 검사는 ZIP 08:35:21–08:36:23 UTC, NSIS 08:39:30–08:40:31 UTC에 각각 10/10 PASS였다. ZIP 결과 `bundled-results-1791794183707.json`의 테스트 SHA는 `8d82e6512ef70728b726d0ff6c4560db24bc887fa48b0510e5782ed3c0ba70f8`, NSIS 결과 `bundled-results-1791794431439.json`의 테스트 SHA는 `f6eab97f0ab4e86ce5910cd6e4550b91347463ec0e2af3ac4f70114aa258ebe6`이다. 당시 PNG·JSON은 `{zip,nsis}-source-0e1ff5-before-stability`에 별도 보존했다. 당시 NSIS에는 최소 창·200% 도구 카드 중간 검사와 정확한 설치 경로 guard를 추가했다. 총괄의 당시 설치 시각은 08:37:45, 제거 시각은 08:41:27 UTC였다. 데이터는 `app-data-zip-source-0e1ff5`·`app-data-nsis-final`에 보존했고 과거 제거 근거도 총괄이 별도 보존했다. 이 초기 재시작 PNG는 최종 안정 화면의 근거로 사용하지 않는다. + +이전 소스 `ffb77751b45d360804090a46fe7efa2e105ab81d`의 ZIP은 08:15:00–08:16:02 UTC에 10/10 PASS였다. 결과 `bundled-results-1791792962539.json`과 모든 당시 화면은 `work/bundled-desktop-qa/zip-source-ffb77751`에도 별도 보존했다. 당시 ASAR는 `a8c775223188330b4f2e9afee77513b6629fbdab50b611841ce5b37fdacbd8cb`, exe는 `f8dfdcd01c5dcef6a8b0769a394c86f0ba0b1e07d594fbabc01a230799e32b70`이었다. + +그 뒤 총괄이 첫 NSIS 설치 산출물의 번들 파일 604개 중 602개만 존재함을 발견해 무결성 FAIL로 중단했다. 해당 NSIS의 앱 기능 QA는 실행하지 않았다. 총괄이 NSIS 압축 옵션 `differentialPackage:false`, `useZip:true`를 적용해 새 소스로 재빌드했고 전체 바이트 대조를 통과한 최종 ZIP을 위와 같이 재검증했다. 이전 기능 PASS를 새 패키지 검증으로 대체하지 않았다. 첫 NSIS 실패와 새로운 설치·무결성 검증은 총괄 근거에 따라 구분한다. + +## 실행 조건과 소유권 + +총괄이 실제 사용자 AppData 부재를 `work/bundled-desktop-qa/preflight.json`에 확인하고 ZIP 해제·NSIS 설치를 수행한다. QA는 총괄이 지정한 실행 파일만 시작하며 사용자 데이터 경로를 우회하지 않는다. 앱 정상 종료 뒤 생성된 QA 데이터 보관과 설치 제거도 총괄 담당이다. 기존 사용자 AppData가 발견되면 앱 실행 전 중단한다. + +순서는 ZIP → 총괄 데이터 보관 → NSIS다. ZIP은 `work/bundled-zip/한글 학습 & %JAVA_HOME%`를 포함한 실제 경로에서 실행한다. 최종 NSIS는 총괄이 지정한 `work/한글 설치 & %JAVA_HOME%/Java Edu.exe`를 실행한다. 검사 파일은 `tests/bundled-desktop.mjs`, 근거는 `work/bundled-desktop-qa/{zip,nsis}`에 둔다. + +```powershell +$env:NODE_TLS_REJECT_UNAUTHORIZED='1' +$env:NODE_OPTIONS='--use-system-ca' +node tests/bundled-desktop.mjs --kind zip --executable '' +node tests/bundled-desktop.mjs --kind nsis --executable '' +``` + +두 명령 사이에는 총괄의 사용자 데이터 보관·다음 제품 준비 확인이 필요하다. 환경변수 `BUNDLED_PACKAGE_KIND`, `BUNDLED_APP_EXE`로 같은 입력을 전달할 수도 있다. + +## 수용 조건 + +| 영역 | 각 제품에서 확인할 실제 행동 | +| --- | --- | +| 자동 등록 | JDK 21 및 Maven `source: bundled`; 리소스 경로가 해당 제품 내부이며 `bundledRuntime.id/javaVersions/mavenVersion` 일치 | +| 외부 도구 불필요 | 앱 자식 환경에서 PATH 명시적 빈 값, JAVA_HOME/MAVEN_HOME 제거, JDK/Maven 폴더 선택 호출 0회 | +| 동의 경계 | 최초 동의 false, 실행/제출 비활성; 앱·설정 진입과 동의 체크만으로 코드·환경 준비 요청 없음 | +| 코어 | Java 8/11/17/21 대상 기준 답안을 모두 실제 번들 JVM 21로 제출; 결과뿐 아니라 실행 명령의 번들 java/javac 경로 및 --release 대상 확인 | +| 실무 | tx-proxy의 실제 Spring/H2 환경 준비와 제출; 준비 시 학생 초안과 미완료 성취 보존; POM 제외한 학생 파일만 제출 | +| Maven 모드 | UI가 만든 일시적 command JSON을 읽기 전용 관찰; 번들 Maven launcher/home 사용, 준비에는 --offline 없음, 학생 제출에는 --offline 있음 | +| 재시작 | 실제 종료·다른 포트 재시작; 번들 경로/source, 동의, 진도, 테마, 코어·파일별 초안 보존; 실제 편집기 끝의 초안 주석 확인 | +| 화면 | 기본 설정의 번들 표시, 최소 창 720×560·문서 글꼴 200%의 설정 상단·도구 카드·닫기 버튼; 가로 넘침과 실제 닫기; scroll/bounds 안정 캡처 | + +폴더 선택 API는 호출 사실을 기록하고 실패시키는 감시만 사용하며 경로 값을 주입하지 않는다. 앱 API·Java·Maven 응답은 모의 처리하지 않는다. 클립보드도 대체하지 않고 실행 요청 원문·실제 저장값·편집기의 표시를 대조한다. 실제 OS 폴더 대화상자 조작과 클립보드 작동은 검사 범위가 아니다. + +실행 중인 이 앱의 command JSON은 읽기만 하며 AppData 파일을 직접 작성·삭제하지 않는다. 최초 Maven 환경 준비의 의존성 다운로드에는 정상 네트워크가 필요하다. 준비 후 학생 제출의 Maven 오프라인 옵션을 확인하는 것은 모든 Java 코드의 네트워크 차단을 의미하지 않는다. + +## 현재 실제 결과와 미확인 + +최신 ZIP·NSIS의 실제 번들 JVM/Maven 제출과 데이터 보존은 모두 위와 같이 통과했고 총괄의 제거·데이터 보관도 완료했다. 재시작 화면의 빈 띠는 안정화 후 두 제품에서 재현되지 않았다. 독립 시각 담당도 최신 재시작 원본·확인 화면과 최소 창·200% 도구 카드·닫기 버튼을 직접 검토하여 양쪽 제품 PASS를 전달했다. 기존 0.2 검사와 제품 소스는 수정하지 않았다. 각 제품의 실무 패키지 검증은 대표 Spring/H2 tx-proxy를 대상으로 하며 기본 20개 실무 프로젝트 전부를 이 검사에서 다시 실행하는 것은 아니다. + +첫 ZIP 앱 시작은 환경 확인에서 BLOCKED되었다. 자식 환경 객체에서 PATH를 삭제해도 Node/Windows가 부모 PATH를 복원하는 동작을 관찰했으며, 별도 무해한 Node 자식 프로세스에서도 shell:false 조건으로 재현했다. PATH 생략 시 기존 경로가 남고 명시적 빈 문자열일 때 길이 0이었다. 제품 실행 실패로 판정하지 않으며 학습자 코드는 실행하지 않았다. 테스트는 PATH를 명시적 빈 값으로 전달하고 실제 Electron 자식의 빈 값을 확인하도록 보완했다. JAVA_HOME/MAVEN_HOME와 QA_DATA 등은 계속 제거한다. 최초 근거 `zip/bundled-results-1791792262438.json`은 보존한다. + +두 번째 환경 검사는 PATH 키가 반드시 존재하면서 빈 값이어야 한다는 과도한 기대값으로 중단됐다. Playwright가 사용하는 Windows shell:true 조건을 별도 Node 자식으로 재현하니 빈 PATH 키 자체가 제거됐으며, shell:false에서는 빈 키가 유지됐다. 검사 조건을 ‘PATH 부재 또는 빈 값’으로 고쳤고 실제 환경값을 실패 여부와 무관하게 먼저 기록한다. 두 번째 실행도 학습자 요청 전 중단·정상 종료되었으며 `zip/bundled-results-1791792702100.json`과 기본 사용자 데이터 출처를 보존한다. + +화면 근거는 실제 Windows 패키지 앱의 Electron viewport 캡처이며 물리적 모바일 기기 검사가 아니다. SmartScreen의 인터넷 다운로드 경고 흐름, OS 클립보드, 번들 밖 JDK 및 Redis/Kafka 외부 서비스 실행은 이 검사에 포함하지 않는다. diff --git a/docs/bundled-runtime.md b/docs/bundled-runtime.md index 1c9c8c0..ece96f1 100644 --- a/docs/bundled-runtime.md +++ b/docs/bundled-runtime.md @@ -22,7 +22,7 @@ node scripts/bundle-runtime.mjs --check npm run build:desktop ``` -빌드는 설치 EXE와 Windows x64 ZIP을 함께 만든다. 앱의 설치·ZIP 위치가 바뀌면 번들 항목은 현재 resources 기준으로 다시 해석한다. 외부 선택 경로를 임의로 번들 경로로 변경하지 않는다. 버전별 검사와 실제 설치·ZIP 실행 결과는 별도 QA 보고서에 기록한다. +빌드는 설치 EXE와 Windows x64 ZIP을 함께 만든다. NSIS는 differentialPackage:false·useZip:true로 내부 ZIP을 사용한다. 첫 빌드의7z ARM64 필터 두 파일을 NSIS의19.00 해제기가 누락한 실제 무결성 실패를 확인해, 원본 파일을 모두 유지하는 ZIP 경로로 수정했다. [electron-builder 공식 옵션](https://www.electron.build/v26/docs/nsis/)을 따른다. 자동 업데이트는 제공하지 않으며 차등 업데이트용 패키지를 생성하지 않는다. 앱의 설치·ZIP 위치가 바뀌면 번들 항목은 현재 resources 기준으로 다시 해석한다. 외부 선택 경로를 임의로 번들 경로로 변경하지 않는다. 버전별 검사와 실제 설치·ZIP 실행 결과는 별도 QA 보고서에 기록한다. ## 배포 출처·라이선스·대응 소스 diff --git a/docs/development-handoff.md b/docs/development-handoff.md index 79779a6..21d37ed 100644 --- a/docs/development-handoff.md +++ b/docs/development-handoff.md @@ -1,3 +1,15 @@ +# Java Edu 개발 인계 — v0.3 통합 패키지 + +최신 앱 소스는 `0e1ff5eb38484d1594d4fd1c231d4dbe229b44e8`, 원격은 jbsim999/java-edu, 구현 브랜치는 feat/java-learning-mvp이며 PR1은 미병합이다. [v0.3 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)는 Windows x64 EXE·전체 ZIP에 Temurin21.0.12.1+1·Maven3.10.0·Electron/Node를 포함한다. 학습자의 별도 도구 설치·폴더 등록은 필요 없다. 기본 실제 JVM은21이며 컴파일 대상8·11·17·21과 구분한다. 다른 실제 JDK 등록은 고급 설정의 선택 기능이고 기존 외부 설정을 보존한다. 기본20실무 첫 준비에는 인터넷이 필요하며 Redis/Kafka 서버는 포함하지 않는다. + +CI38223955175의12작업, 최종 실제 ZIP10/10·NSIS10/10, 도구604개 전체 무결성·원본197+생성2파일 대조, 코드·기능·시각 독립 검수 PASS. package.json은 builder가 제거한 개발 필드를 제외한 런타임 필드를 비교한다. 첫7z NSIS의2파일 누락은 내부 ZIP으로 수정해 한글 설치 경로에서 재검증했다. 설치 제거·학습 데이터 보존·원래 AppData 부재 복원 PASS. 검사 자료는 work/bundled-desktop-qa에 보존하며 Git에 넣지 않는다. [통합 QA](bundled-desktop-qa-report.md)·[번들 계약](bundled-runtime.md)·[설치 안내](installation.md)·[배포 기록](deployment.md)을 먼저 읽는다. + +웹은 기존 소유자 전용 URL에서 통합 설치 안내를 배포했다. 웹 관련 GitHub 소스ffb77751, Sites199443b5e185acdcd83bb3d4ca0a4ca43eefc3d8, 배포appgdep_6acc996c9de48191a896c90d291afd9f, 2026-10-12 08:25:24UTC succeeded. 후속0e1ff5는 desktop 압축 설정만 수정했으며 웹 UI·Worker는 동일하다. 공개 접근 전환·PR 병합·강제 푸시·유료 자원 구매·자체 라이선스 지정은 승인되지 않았다. + +개발자는 공식 ZIP 체크섬과 대응 소스를 검증한 뒤 scripts/bundle-runtime.mjs로 work/desktop-bundle을 생성한다. 빌드 전후604개 전체 SHA를 확인한다. NSIS differentialPackage:false/useZip:true를 유지한다. 새 빌드는 기존 릴리스와 별도로 검증한다. 학습자 코드의 명시적 동의·개인 IDE 권한·로컬 프로세스 계약을 유지한다. 기존v0.2 검증도 최신 통합 패키지 검증과 구분한다. + +아래는 v0.2 당시 개발 인계의 역사 기록이다. 도구 외부 등록과 이전 배포 표시는 현재v0.3 기본 설정을 대신하지 않는다. + # Java Edu 개발 인계 — 2026-10-12 프로젝트 루트는 이 문서의 상위 저장소 java-learning-lab이다. 원격은 https://github.com/jbsim999/java-edu, 구현 브랜치는 `feat/java-learning-mvp`, PR은 https://github.com/jbsim999/java-edu/pull/1 이다. main에는 README와 소개 이미지가 반영돼 있고, 구현 PR은 병합하지 않았다. 새 대화는 AGENTS.md·CONTRIBUTING.md·이 문서를 먼저 읽는다. diff --git a/docs/installation.md b/docs/installation.md index e3edc61..6bfe3fd 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,28 +1,28 @@ # Windows 설치와 학습 시작 -Windows 10/11 x64용 현재 사용자 설치 앱이다. [v0.2.0 설치 파일과 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)을 함께 제공한다. 앱은 Java와 Maven을 포함하지 않으며 시스템 PATH를 바꾸지 않는다. +Windows 10/11 x64용 [v0.3.0 통합 패키지와 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)를 제공한다. Electron/Node 실행 환경, Eclipse Temurin JDK21.0.12.1+1, Apache Maven3.10.0이 포함되어 학습자가 Java·Maven·Node.js를 따로 설치하거나 폴더를 등록할 필요가 없다. 시스템 PATH를 바꾸지 않는다. -## 준비 +## EXE 또는 ZIP으로 시작 -1. [Eclipse Temurin JDK 21](https://adoptium.net/temurin/releases/?version=21)을 설치하거나 ZIP을 풀어 둔다. 기존 JDK8/11/17/21도 등록할 수 있다. JRE가 아닌 java와 javac가 함께 있는 JDK가 필요하다. -2. Spring 등 실무 프로젝트에는 [Apache Maven 3.9 이상](https://maven.apache.org/download.cgi)을 준비한다. Java 코어 과제에는 Maven이 필요하지 않다. -3. Java Edu 설치 파일을 실행하고 설치 위치를 선택한다. 현재 사용자용으로 설치하며 관리자 권한을 요구하지 않는다. 배포 파일은 코드 서명되지 않았다. +1. 설치하려면 Java-Edu-Setup-0.3.0-x64.exe를 실행하고 설치 위치를 선택한다. 현재 사용자용이며 관리자 권한을 요구하지 않는다. +2. 설치 없이 사용하려면 Java-Edu-0.3.0-x64.zip을 **전체 해제**하고 Java Edu.exe를 실행한다. resources 등 함께 있는 파일을 보존한다. exe만 다른 폴더로 옮기면 실행되지 않는다. +3. 앱에 포함된 Java21·Maven이 자동으로 표시된다. 학습 코드 실행에 동의하고 코어 실습의 실행·제출을 사용한다. 앱 실행만으로 학습 코드가 실행되지는 않는다. +4. Spring/H2 실무는 **환경 준비 → 파일 편집 → 실행·제출** 순서다. 첫 환경 준비는 앱이 고정 의존성을 내려받으므로 인터넷이 필요하다. 별도 프로그램 설치는 필요 없으며 준비 후 학생 제출은 오프라인 모드다. 앱 재시작·도구 변경 후에는 준비 상태를 다시 확인한다. -## 앱에서 시작 +EXE와 ZIP 중 하나만 받는다. 릴리스의 대응 JDK·제3자 소스와 SHA256SUMS는 출처·검증용이며 학습 실행을 위해 설치하는 파일이 아니다. 배포 파일은 코드 서명되지 않은 프리뷰다. -1. 앱의 실행 환경 설정에서 JDK의 최상위 폴더를 선택한다. 앱이 java/javac 버전의 일치를 확인한다. -2. 실무 과제를 풀 때 Maven 최상위 폴더도 선택한다. -3. 로컬 학습 코드 실행에 동의한 후 실제 실행 JDK를 선택한다. 학습 코드에는 사용자 PC의 파일·네트워크 접근 권한이 있으므로 자신의 학습 코드를 실행한다. -4. Java 코어는 코드를 작성하고 실행·제출한다. 실무 프로젝트는 먼저 환경 준비를 눌러 고정된 의존성과 기준 테스트를 확인한 뒤 파일 탭에서 구현하고 제출한다. +## 버전과 고급 설정 -설치형의 작성 코드가 Wandbox로 전송되지는 않는다. 최초 환경 준비에는 Maven Central 다운로드가 필요하며 과제 실행은 오프라인 모드다. 진도·초안·메모·테마는 앱 데이터에 보존된다. 앱 제거 시에도 학습 데이터는 기본 보존한다. +Java8/11/17/21 교재와 컴파일 대상을 제공하되 기본 실제 JVM은21이다. 네 실제 JVM을 모두 포함한 패키지는 아니다. 다른 실제 JDK8/11/17 또는 기존 Maven을 사용하려는 사람만 고급 설정에서 등록한다. 기존 외부 도구 선택은 보존한다. 포함 도구는 앱 위치가 바뀌어도 현재 resources 기준으로 다시 등록된다. + +설치형의 작성 코드는 Wandbox로 전송하지 않는다. 학습 코드는 사용자 PC의 파일·네트워크 접근 권한으로 실행하며 앱은 OS 보안 샌드박스가 아니다. 진도·초안·메모·테마는 앱 데이터에 저장하고 제거 시에도 기본 보존한다. [로컬 실행 계약](local-execution.md) · [번들 출처와 대응 소스](bundled-runtime.md) · [ZIP·설치 앱 QA](bundled-desktop-qa-report.md) ## 실무 과정 -기본20개 프로젝트는 추가 DB 서버 없이 H2·MockMvc·로컬 테스트로 실행한다. Redis/Kafka 실제 연동 두 단원은 선택 환경이며 해당 교재의 고정 loopback 주소·포트를 사용한다. 공유 운영 서비스에 연결하지 않는다. Gradle/Docker/CI 비교 파일은 교재 자료이며 앱의 자동 실행 도구는 Maven이다. +기본20개 프로젝트는 추가 DB 서버 없이 H2·MockMvc·로컬 테스트로 실행한다. Redis/Kafka 실제 연동 두 단원은 선택 환경이며 해당 교재의 고정 loopback 주소·포트를 사용한다. 해당 서버는 패키지에 포함하지 않으며 실제 연동은 미검증이다. Gradle/Docker/CI 비교 파일은 교재 자료이며 앱의 실행 도구는 Maven이다. ## 웹에서 학습 -웹도 같은34단원의 이론·예제·퀴즈·메모·문법 강조 편집기를 제공한다. 실무 프로젝트는 ZIP을 내려받아 IDE에서 사용하거나 Windows 앱에서 실행한다. ZIP의 부모 pom.xml과 lessons/<단원>/starter·reference·grader의 상대 위치를 유지해야 한다. starter 또는 reference 폴더에서 mvn test를 수행한다. +웹도 같은34단원의 이론·예제·퀴즈·메모·문법 강조 편집기를 제공한다. 실무 프로젝트는 ZIP을 내려받아 IDE에서 사용하거나 Windows 앱에서 실행한다. 프로젝트 ZIP의 부모 pom.xml과 lessons/<단원>/starter·reference·grader의 상대 위치를 유지하고 starter 또는 reference에서 mvn test를 수행한다. -웹 Java 코어 실행은 Wandbox의 실제 JVM21로 연결하고8/11/17/21 컴파일 대상을 구분해 표시한다. 웹 진도는 해당 브라우저에 저장되며 앱과 자동 동기화되지 않는다. 현재 Sites 웹 배포는 소유자 전용 접근을 유지한다. +웹 Java 코어 실행은 Wandbox의 실제 JVM21이며 교재 기준·컴파일 대상·실제 JVM을 구분해 표시한다. 웹 진도는 해당 브라우저에 저장되며 앱과 자동 동기화되지 않는다. Sites 웹 배포는 소유자 전용 접근을 유지한다. 과거 v0.2는 도구 별도 등록 방식이며 통합 패키지와 구분한다. diff --git a/docs/local-execution.md b/docs/local-execution.md index b4b7232..3caebdc 100644 --- a/docs/local-execution.md +++ b/docs/local-execution.md @@ -4,8 +4,8 @@ Windows 설치형 앱과 웹 교재는 같은 교육 화면을 사용한다. 설 ## 설치와 도구 등록 -- Windows 10 이상 x64 현재 사용자용 NSIS 설치 파일. 관리자 권한·시스템 PATH 변경·자동 JDK/Maven 다운로드·자동 업데이트는 사용하지 않는다. -- 앱 설정에서 JDK 8·11·17·21의 설치 디렉터리와 Apache Maven 3.9 이상(Maven 3)의 디렉터리를 선택한다. java/javac 버전을 함께 검사한다. +- Windows 10 이상 x64 현재 사용자용 NSIS 설치 EXE 및 전체 ZIP. v0.3은 Temurin JDK21·Maven3.10.0·Electron/Node를 포함한다. 학습자의 별도 설치·시스템 PATH 변경·자동 업데이트는 필요하지 않다. [번들 계약](bundled-runtime.md)을 따른다. +- 포함 JDK21·Maven은 전체604파일 해시 검사 후 자동 등록한다. 고급 설정에서 다른 실제 JDK8·11·17·21과 Maven3.9 이상을 선택할 수 있고 기존 외부 설정을 보존한다. 실제 실행 때 java/javac 버전을 함께 검사한다. - 학습자 코드 실행에 대한 명시적인 동의가 필요하다. 앱을 여는 것만으로 학습 코드를 실행하지 않는다. 동의 문구는 파일·네트워크 접근 권한을 설명한다. 동의를 철회하면 진행 중인 실행도 취소한다. - 기본 실제 JVM은 등록한 JDK 중 수업의 최소 버전을 만족하는 가장 높은 버전이다. 실행 요청의 runtimeVersion으로 다른 등록 JDK를 선택할 수 있다. 교재의 Java 버전·컴파일 대상·실제 JVM을 구분한다. - 실무 프로젝트 환경 준비는 명시적인 버튼으로 고정 기준 답안의 Maven 테스트를 실행한다. Maven Central에서 고정 버전 의존성을 내려받는다. 이후 학습자 Maven 실행은 오프라인 모드다. 앱 재시작 또는 JDK 변경 뒤에는 환경 준비를 다시 확인한다. @@ -34,7 +34,7 @@ Electron은 contextIsolation=true, nodeIntegration=false, sandbox=true, webSecur 일반 fetch를 사용한다. Electron main이 인증 쿠키와 CSRF 헤더를 관리한다. -- GET `/api/runtime`: 기존 provider/available/runtime/supportedTargets/notice 외에 `mode:'local'`, `runtimes:[{javaVersion,available,path}]`, `maven:{available,version,path}`, `consent:{accepted,notice}`, `dependencyNotice`, `reason`, `limits`, `preparedProjects:[{exerciseId,runtimeVersion,jdkPath,mavenPath,mavenVersion}]`. 도구 미등록/동의 전에도 200이며 available=false다. 실제 실행에서 도구 버전을 다시 확인한다. 준비 상태는 현재 등록한 JDK 경로와 Maven 경로·버전에 일치하는 항목만 반환한다. +- GET `/api/runtime`: 기존 provider/available/runtime/supportedTargets/notice 외에 `mode:'local'`, `runtimes:[{javaVersion,available,path,source}]`, `maven:{available,version,path,source}`, `bundledRuntime:{id,javaVersions,mavenVersion}|null`, `consent:{accepted,notice}`, `dependencyNotice`, `reason`, `limits`, `preparedProjects:[{exerciseId,runtimeVersion,jdkPath,mavenPath,mavenVersion}]`. 도구 미등록/동의 전에도 200이며 available=false다. 실제 실행에서 도구 버전을 다시 확인한다. 준비 상태는 현재 등록한 JDK 경로와 Maven 경로·버전에 일치하는 항목만 반환한다. - GET `/api/local/projects/`: `{exerciseId,lessonId,title,profileId,runtimeRequirements,files:[{path,source,editable}],notice}`. 파일은 교재의 고정 allowlist에 해당한다. - POST `/api/execute`: 코어는 `{exerciseId,source,action:'run'|'submit',requestId,runtimeVersion?}`. 실무는 source 대신 `files:{고정상대경로:소스}`. 편집 가능 파일 전부를 제출하며 pom.xml·grader·명령·URL은 변경할 수 없다. - POST `/api/local/prepare`: `{exerciseId,requestId,runtimeVersion?}`. 기준 답안으로 환경 준비. 결과에 `prepared:boolean`이 추가된다. diff --git a/docs/milestones.md b/docs/milestones.md index e9e3c80..1df0bac 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -13,6 +13,7 @@ | M6 | Spring/REST/Lombok/MyBatis/테스트 대표 수업 | 실제 의존성·다중 파일·DB·HTTP 코딩 실습 | 22실무수업 통합, 기본20프로젝트 기준54JUnit PASS·starter 실패 확인 | | M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | 구현·독립 코드/출처 PASS, 웹 교재 배포 완료; Redis/Kafka 실제 서비스 미검증·Windows v0.2.0 프리뷰 출시 | | M7.1 | 이론 도식과 GitHub 프로젝트 소개 | 34수업 채택 평가·공식 출처·독립 코드/기능/시각 검수·README 실제 화면 | main 소개 반영, 12도식 채택·22보류; 도식19/19·회귀49/49·내용/시각 PASS, `a0d1257` 원격·웹 배포 성공 | +| M8 | 별도 도구 설치 없는 통합 EXE·ZIP | 공식 원본·고지/소스·604파일 무결성·실제 ZIP/NSIS·독립 검수·출시 | 완료: `0e1ff5e` CI12, 최종 ZIP10/10·NSIS10/10·설치/제거/데이터 보존·시각 PASS; v0.3 프리뷰 | M4는 전체 교육 과정 완료를 뜻하지 않는다. 2026-10-12 사용자는 **Windows 설치형 + 웹 교재**를 선택했다. 학습자 로컬 PC에서 Java/Spring/H2를 실행하며, 호스팅 컨테이너 서버는 기본 과정에 필요하지 않다. Redis/Kafka 실제 연동은 별도 선택 환경이다. 웹의 기존 코어 실행은 Wandbox 실제 JVM21을 사용한다. 교재 버전·컴파일 대상·실제 JVM을 구분한다. 마일스톤마다 관련 파일만 stage하고 강제 푸시하지 않는다. 배포는 검증한 커밋의 동일 소스를 사용한다. diff --git a/docs/product-plan.md b/docs/product-plan.md index c05f635..86c906c 100644 --- a/docs/product-plan.md +++ b/docs/product-plan.md @@ -44,7 +44,7 @@ Java 명세, 도구의 공식 계약, 교육자의 설계 지침을 구분한다 Windows 현재 사용자용 설치 앱과 웹 교재가 동일한 교육 화면을 공유한다. 코어12개와 실무22개 단원을 연결하고, 코드에는 Java 구문 색상과 다크·라이트·고대비 테마를 적용한다. 실무 단원의 Java/XML/SQL/설정 파일은 탭으로 탐색하며 학습자가 편집할 파일과 고정된 빌드·테스트를 구분한다. -설치형은 학습자가 등록한 로컬 JDK8/11/17/21와 Maven3.9+를 사용한다. 코드 실행은 PC에서 수행하고, 최초 환경 준비에서 고정된 Maven 의존성을 내려받은 뒤 과제는 오프라인으로 실행한다. 기본 DB는 H2이며 필수 Docker 서버를 요구하지 않는다. Redis/Kafka 실제 연동 단원에만 선택 자원을 안내한다. Gradle/Docker/CI 비교 자료와 실제 자동 실행 범위도 구분한다. +설치형 v0.3은 Temurin JDK21·Maven3.10.0·Electron/Node를 포함한 설치 EXE와 전체 ZIP을 제공한다. 학습자의 별도 도구 설치·폴더 등록 없이 자동 설정하며, 다른 실제 JDK8/11/17/21 또는 Maven 등록은 고급 설정의 선택 기능이다. 기존 외부 설정을 보존한다. 기본 실제 JVM은21이며 교재·컴파일 대상8/11/17/21과 구분한다. 코드 실행은 PC에서 수행하고, 최초 환경 준비에서 고정된 Maven 의존성을 내려받은 뒤 과제는 오프라인으로 실행한다. 기본 DB는 H2이며 필수 Docker 서버를 요구하지 않는다. Redis/Kafka 서버는 번들에 포함하지 않는 선택 환경이다. [통합 패키지 계약](bundled-runtime.md)을 따른다. Gradle/Docker/CI 비교 자료와 실제 자동 실행 범위도 구분한다. 웹은 같은 이론·예제·퀴즈·설계 메모·편집기 및 프로젝트 ZIP을 제공한다. 실무 프로젝트 실행 버튼은 설치형에서 사용한다. 웹의 기존 Java 코어 실행은 Wandbox를 유지한다. 설치형 진도·초안·테마는 앱 데이터에 보존하고, 웹 진도는 브라우저에 보존한다. 기기 사이 자동 동기화는 제공하지 않는다. diff --git a/docs/verification.md b/docs/verification.md index 0a9ce6e..715c1d2 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -59,3 +59,17 @@ JDK21·컴파일 대상17·Maven3.10.0에서 기본20실무프로젝트를 실 최종 NSIS 설치 파일 SHA256 `6d4354e0b4a5c8b471591f0ee29c106f9ec65c2561847416555c38e623fd2f74`. 실제 사용자용 설치·설치 앱8/8·제거·Windows 시각 PASS. ASAR에 포함된 검토 파일195개+생성2개 및 unpacked helper를 비교했다. package.json은 builder가 개발 명령·개발 의존성을 제거하므로 나머지 런타임 필드로 비교하고, 다른 소스는 원본 바이트로 비교했다. 설치된 ASAR/helper/exe는 빌드 산출물과 동일하다. 첫 검사 스크립트의 Maven 제공자 기대값 오류는 고친 후 전체8개를 재실행했으며 실패 기록을 보존했다. 기존 사용자 데이터 부재를 사전 확인한 뒤 테스트 데이터는 작업 폴더에 보관하고 환경을 복원했다. [설치 QA](installed-desktop-qa-report.md)를 따른다. 출시 게이트 판정은 **PASS — 프리뷰 범위**다. 실제 릴리스 게시 결과는 [배포 기록](deployment.md)에 기록한다. 프로젝트 이동 후 브라우저·개발 앱 검사의 기본 산출물 경로를 저장소 내부 work/로 정리했다. 경로 영향 검사 브라우저1/1·웹 실무4/4 PASS. 제한 환경의 최초 브라우저 시작 실패 후 허용된 실행 환경에서 재검사한 결과이며, 설치 앱8/8 검증과는 별개다. + +## M8 통합 Windows 패키지 v0.3 + +출시 소스 `0e1ff5eb38484d1594d4fd1c231d4dbe229b44e8`, [CI38223955175](https://github.com/jbsim999/java-edu/actions/runs/38223955175) 전체12작업 PASS. 같은 소스의38223948625도 전체12작업 PASS. 이전ffb 소스의38220565678 첫실무21 작업 실패는 검사기가 상세로그를 제공하지 않아 원인이 미확인이고, failed-job 재실행에서는 PASS였다. 이 이력을 최종 소스의 성공과 혼합하지 않는다. + +Temurin21·Maven3.10.0의 공식 ZIP 체크섬, 새해제본 전체, stage604파일355610126바이트를 검증했다. 실제 실행하지 않는 시작 자동등록과 코드 실행 동의를 분리했다. Node35 PASS/실제제공자1 SKIP, 로컬23/23 PASS, 작성자UI6/6·웹회귀4/4와 독립 코드/시각 PASS. 번들 실제 JVM21의12코어/73검사 및 새전용 Maven캐시에서 Spring/H2 준비2/2·오프라인제출2/2 PASS. 이는 설치제품 QA와 별도 검사다. + +최종 ZIP과 실제 NSIS 설치앱 각각10/10 PASS. 네컴파일대상8/11/17/21의 코어6/5/6/6, 실제 Maven 준비2/2와 오프라인제출2/2, 재시작저장, 최소창704×521·글자200%를 확인했다. 외부도구환경 없이 포함 resources의 java/javac·Maven만 사용하고 폴더선택은0회였다. API/Java/Maven/클립보드 응답을 모의하지 않았다. NSIS200%중간도구카드와닫기추가확인, 최종 실제합성화면 독립검수 PASS. [독립 QA](bundled-desktop-qa-report.md)를 따른다. + +빌드·실제ZIP·NSIS에서 원본197개·생성2개·vendor604개를 대조했다. package.json은 builder제거개발필드를 제외한 런타임필드로 비교하고 다른소스는 바이트로 비교했다. 최종 ASAR `8ee1c5df3de2472ff18a31e45cb6054cedd17b89389db8e579aea87c8c7c51e2`, exe `d7e0bfab510476dc433b175624a10dd4753c725c4d72949f6870a2af7b09c043`, helper `a5f0ae67ef016c916a92dc0f1afb2c1ed4a9c744798694536a241a5f861a9721`가 제품별로 일치한다. + +첫7z NSIS는604중602로 무결성FAIL이었다. 원본ARM64필터2파일이 압축에는 있지만19.00 NSIS해제기에서 누락됨을 독립진단·직접추출SHA로 확인했다. differentialPackage:false/useZip:true 최소수정독립리뷰 후 새빌드·한글/공백/&/%JAVA_HOME% 경로실제설치와604전체를 재검증했다. 미검증 파일을 누락허용하지 않았다. 첫ZIP10/10·환경guard중단2개·첫NSISFAIL 근거는보존했다. 수정전산출물은 출시하지 않는다. + +최종NSIS제거exit0,등록0·바로가기0·학습데이터보존PASS. 새테스트AppData를 work로보관하고 작업전두경로부재로복원했다. userData우회나기존개인설정삭제는하지않았다. Windows11 실제제품 검사이며 Windows10·SmartScreen·OS배율·실기기·스크린리더·Redis/Kafka실제연동은 미검증이다. 기본실무 최초준비는인터넷이필요하고 실제JVM21만기본포함한다. 판정 PASS — 통합프리뷰범위. 대응JDK/Temurin빌드/Maven/제3자소스와원본고지를같은릴리스에서제공한다. diff --git a/tests/bundled-desktop.mjs b/tests/bundled-desktop.mjs new file mode 100644 index 0000000..1b681ee --- /dev/null +++ b/tests/bundled-desktop.mjs @@ -0,0 +1,515 @@ +import assert from 'node:assert/strict'; +import { access, lstat, mkdir, readFile, readdir, realpath, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { _electron as electron } from 'playwright'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; + +// Manual packaged-app gate. The release owner installs/extracts the package and +// records absence of pre-existing user data before authorizing this script. +// This script never installs, uninstalls, deletes data, redirects userData, +// registers external tools, or replaces API/JVM/Maven/clipboard responses. +const root = path.resolve(fileURLToPath(new URL('../', import.meta.url))); +const option = (name, fallback) => { + const index = process.argv.indexOf(name); + return index === -1 ? fallback : process.argv[index + 1]; +}; +const kind = option('--kind', process.env.BUNDLED_PACKAGE_KIND); +const executableInput = option('--executable', process.env.BUNDLED_APP_EXE); +const executablePath = executableInput && path.resolve(executableInput); +const artifactsRoot = path.join(root, 'work/bundled-desktop-qa'); +const artifacts = path.join(artifactsRoot, kind === 'zip' || kind === 'nsis' ? kind : 'invalid-input'); +const preflightPath = path.join(artifactsRoot, 'preflight.json'); +const progressKey = 'java-edu:progress:v1'; +const preferencesKey = 'java-edu:editor-preferences:v1'; +const coreCases = [8, 11, 17, 21].map(version => coreLessons.find(lesson => lesson.minVersion === version)); +const project = professionalLessons.find(lesson => lesson.id === 'tx-proxy'); +const projectExercise = professionalExercises.find(exercise => exercise.id === project.exerciseId); +const results = []; +const errors = []; +const apiRequests = []; +const evidence = { + startedAt: new Date().toISOString(), packageKind: kind, executablePath, + scope: 'Actual production packaged Electron and bundled JDK/Maven; no API, Java, Maven or clipboard mocks. Folder-dialog calls are forbidden, counted and rejected without supplying a path.', + excluded: ['Native folder dialog operation', 'OS clipboard', 'SmartScreen downloaded-file flow', 'Physical mobile devices'], +}; +let app; +let page; +let runtime; +let userData; + +const normalize = value => path.resolve(value).toLowerCase(); +const inside = (child, parent) => normalize(child).startsWith(`${normalize(parent)}${path.sep}`); +const exists = file => access(file).then(() => true, () => false); +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); +const editor = () => page.locator('#code-editor [role="textbox"]'); +const badge = type => page.locator(`#lesson-achievements [data-achievement="${type}"]`).getAttribute('data-complete'); +const executionCount = () => apiRequests.filter(request => request.method === 'POST' && ['/api/execute', '/api/local/prepare'].includes(request.path)).length; + +async function eventually(read, expected, message, timeout = 15000) { + const until = Date.now() + timeout; + let value; + while (Date.now() < until) { + value = await read(); + if (typeof expected === 'function' ? expected(value) : value === expected) return value; + await pause(100); + } + assert.fail(`${message}: ${JSON.stringify(value)}`); +} + +async function captureGeometry() { + // Observe the actual viewport. Do not scroll, disable motion, change styles, + // blur the editor, or alter focus to obtain a more attractive screenshot. + return page.evaluate(() => { + const number = value => Math.round(value * 100) / 100; + const selectors = ['#site-header', '#intro', '.workspace-heading', '#workspace-grid', '#curriculum-sidebar', '#lesson-card', '#lesson-title', '#lesson-achievements', '.lesson-tabs', '#lesson-panel', '#code-editor', '#code-editor .cm-scroller', '#reference-column', '#desktop-settings-dialog[open]', '.settings-bundle-tools', '#desktop-settings-dialog .dialog-actions']; + return { + hash: location.hash, + activeStep: document.querySelector('[data-step][aria-selected="true"]')?.dataset.step, + scrollX, scrollY, width: innerWidth, height: innerHeight, + rootFont: getComputedStyle(document.documentElement).fontSize, + scrollBehavior: getComputedStyle(document.documentElement).scrollBehavior, + document: { width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }, + visualViewport: visualViewport && { width: number(visualViewport.width), height: number(visualViewport.height), pageTop: number(visualViewport.pageTop), offsetTop: number(visualViewport.offsetTop), scale: visualViewport.scale }, + nodes: Object.fromEntries(selectors.map(selector => { + const node = document.querySelector(selector); + if (!node || !node.getClientRects().length) return [selector, null]; + const rect = node.getBoundingClientRect(); + return [selector, { x: number(rect.x), y: number(rect.y), width: number(rect.width), height: number(rect.height), scrollLeft: number(node.scrollLeft), scrollTop: number(node.scrollTop), scrollWidth: node.scrollWidth, scrollHeight: node.scrollHeight }]; + })), + }; + }); +} + +async function stableCaptureGeometry(samples) { + const started = Date.now(); + let previous; + let stableSince = started; + while (Date.now() - started < 10000) { + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const geometry = await captureGeometry(); + samples.push({ elapsedMs: Date.now() - started, geometry }); + const fingerprint = JSON.stringify(geometry); + if (fingerprint !== previous) stableSince = Date.now(); + else if (Date.now() - stableSince >= 600) return geometry; + previous = fingerprint; + await pause(100); + } + assert.fail('viewport/scroll/major bounds did not remain stable for 600ms within 10s'); +} + +async function capture(name) { + const record = { name, file: path.join(artifacts, `${name}.png`), startedAt: new Date().toISOString(), samples: [] }; + evidence.captures ??= []; + evidence.captures.push(record); + await page.locator('#toast.is-visible').waitFor({ state: 'hidden', timeout: 6000 }); + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + record.before = await stableCaptureGeometry(record.samples); + await page.screenshot({ path: record.file, fullPage: false }); + record.after = await captureGeometry(); + assert.deepEqual(record.after, record.before, `${name}: geometry changed while capturing`); + if (name === 'bundled-restarted-achievement') { + // A second independently settled viewport provides visual evidence when a + // compositor frame disagrees with otherwise stable DOM geometry. + record.confirmationSamples = []; + record.confirmationBefore = await stableCaptureGeometry(record.confirmationSamples); + assert.deepEqual(record.confirmationBefore, record.before, 'restarted viewport remains at the same natural scroll position'); + record.confirmationFile = path.join(artifacts, `${name}-confirmation.png`); + await page.screenshot({ path: record.confirmationFile, fullPage: false }); + record.confirmationAfter = await captureGeometry(); + assert.deepEqual(record.confirmationAfter, record.confirmationBefore, 'restarted confirmation geometry is unchanged'); + } + record.finishedAt = new Date().toISOString(); +} + +async function assertNoFolderDialog() { + const calls = await app.evaluate(() => globalThis.__javaEduQAFolderDialogCalls); + evidence.folderDialogCalls = [...(evidence.folderDialogCalls || []), ...calls]; + assert.deepEqual(calls, [], 'bundled setup must not ask for a JDK or Maven directory'); +} + +async function check(name, body) { + const started = Date.now(); + console.log(`START ${kind} ${name}`); + try { + await body(); + if (app) await assertNoFolderDialog(); + results.push({ name, status: 'PASS', durationMs: Date.now() - started }); + console.log(`PASS ${kind} ${name}`); + } catch (error) { + if (page) await capture(`failure-${name}`).catch(() => {}); + results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - started }); + throw error; + } +} + +async function launch() { + const env = { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: '1' }; + const forbidden = new Set(['PATH', 'JAVA_HOME', 'MAVEN_HOME', 'M2_HOME', 'JDK_HOME', 'JAVA_EDU_QA_DATA', 'ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS']); + for (const name of Object.keys(env)) if (forbidden.has(name.toUpperCase())) delete env[name]; + // Node on Windows restores the parent's PATH when it is omitted, even with + // shell:false. An explicit empty value prevents that fallback and contains no + // host tool directory. JAVA_HOME/MAVEN_HOME remain absent. + env.PATH = ''; + app = await electron.launch({ executablePath, args: [], env, timeout: 90000 }); + app.context().on('request', request => { + const url = new URL(request.url()); + if (url.pathname.startsWith('/api/')) apiRequests.push({ origin: url.origin, path: url.pathname, method: request.method() }); + }); + await app.evaluate(({ dialog }) => { + globalThis.__javaEduQAFolderDialogCalls = []; + for (const method of ['showOpenDialog', 'showOpenDialogSync']) { + dialog[method] = (...args) => { + globalThis.__javaEduQAFolderDialogCalls.push({ method, title: args.find(arg => arg?.title)?.title || '' }); + const error = new Error('QA: bundled startup unexpectedly requested a folder'); + if (method === 'showOpenDialog') return Promise.reject(error); + throw error; + }; + } + }); + page = await app.firstWindow(); + page.setDefaultTimeout(15000); + page.on('pageerror', error => errors.push(error.message)); + await page.locator('#workspace-grid').waitFor({ state: 'visible', timeout: 60000 }); + evidence.application = await app.evaluate(({ app }) => ({ packaged: app.isPackaged, version: app.getVersion(), executable: app.getPath('exe'), appPath: app.getAppPath(), userData: app.getPath('userData') })); + const inherited = await app.evaluate((_electron, names) => Object.fromEntries(names.map(name => [name, Object.keys(process.env).filter(key => key.toUpperCase() === name).map(key => process.env[key])])), [...forbidden]); + evidence.sanitizedChildEnvironment = inherited; + // Playwright's Windows cmd launch removes empty environment keys, so either + // absence or an empty value proves that no host tool path was inherited. + assert.ok(inherited.PATH.every(value => value === ''), 'child PATH must be absent or empty'); + assert.ok(Object.entries(inherited).filter(([key]) => key !== 'PATH').every(([, values]) => values.length === 0), JSON.stringify(inherited)); +} + +async function openLesson(lesson, step = 'lab') { + await page.evaluate(hash => { location.hash = hash; }, `lesson=${lesson.id}&step=${step}&version=21`); + await eventually(() => page.locator('#lesson-title').textContent(), text => text?.includes(lesson.title), 'requested lesson is selected'); + await eventually(() => page.locator(`[data-step="${step}"]`).getAttribute('aria-selected'), 'true', 'requested learning stage is selected'); +} + +async function settings() { + await page.locator('#desktop-settings-open').click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'visible' }); + await eventually(() => page.locator('#execution-consent').isEnabled(), true, 'settings loaded'); +} + +async function closeSettings() { + await page.locator('#desktop-settings-dialog [data-close-dialog="desktop-settings-dialog"]').first().click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'hidden' }); +} + +const stored = () => page.evaluate(keys => Object.fromEntries(keys.map(key => [key, window.javaEduDesktop.storage.getItem(key)])), [progressKey, preferencesKey]); +const progress = async () => JSON.parse((await stored())[progressKey] || '{}'); + +async function quiz(lesson) { + await openLesson(lesson, 'quiz'); + for (const question of lesson.quiz) await page.locator(`#quiz-form [name="quiz-${question.id}"][value="${question.correctIndex}"]`).check(); + await page.locator('#quiz-submit').click(); + await eventually(() => badge('quiz'), 'true', 'quiz achievement recorded'); + await openLesson(lesson); +} + +// Observe only this app's transient command specifications; never modify them. +// These are the exact arguments consumed by the packaged Windows job runner. +function observeCommands() { + let stopped = false; + const seen = new Map(); + const jobs = path.join(userData, 'java-edu/jobs'); + const pending = (async () => { + while (!stopped) { + for (const job of await readdir(jobs, { withFileTypes: true }).catch(() => [])) { + if (!job.isDirectory() || !job.name.startsWith('job-')) continue; + const base = path.join(jobs, job.name); + for (const relative of ['', 'professional/lessons/tx-proxy/reference', 'professional/lessons/tx-proxy/starter']) { + const directory = path.join(base, relative); + for (const name of await readdir(directory).catch(() => [])) { + if (!/^\.command-[a-f0-9-]+\.json$/i.test(name)) continue; + const file = path.join(directory, name); + if (seen.has(file)) continue; + try { + const command = JSON.parse(await readFile(file, 'utf8')); + if (typeof command.executable === 'string' && Array.isArray(command.args)) seen.set(file, { executable: command.executable, args: command.args, cwd: command.cwd }); + } catch { /* The writer may be finishing this new file; retry next poll. */ } + } + } + } + await pause(100); + } + })(); + return { async stop() { stopped = true; await pending; return [...seen.values()]; } }; +} + +async function execute(button, timeout = 60000) { + const preparation = button === '#prepare-project'; + const pathname = preparation ? '/api/local/prepare' : '/api/execute'; + const pending = page.waitForResponse(response => new URL(response.url()).pathname === pathname && response.request().method() === 'POST', { timeout }); + void pending.catch(() => {}); + const observer = observeCommands(); + let commands; + let response; + try { await page.locator(button).click(); response = await pending; } + finally { commands = await observer.stop(); } + const result = await response.json(); + const request = response.request().postDataJSON(); + evidence.executions ??= []; + evidence.executions.push({ exerciseId: request.exerciseId, action: request.action || 'prepare', httpStatus: response.status(), commands, ...result }); + console.log('ACTUAL_BUNDLED_RESULT', JSON.stringify({ kind, exerciseId: request.exerciseId, action: request.action || 'prepare', status: result.status, provider: result.provider, runtimeVersion: result.runtimeVersion, targetVersion: result.targetVersion, passed: result.passed, total: result.total, commands: commands.length, error: result.error })); + await eventually(() => page.locator('#execution-output').getAttribute('data-status'), status => status !== 'running', 'execution result rendered'); + assert.equal(response.status(), 200); + assert.equal(result.provider, request.exerciseId === project.exerciseId ? 'Local Maven' : 'Local JDK'); + assert.equal(result.runtimeVersion, 21); + assert.equal(result.status, 'passed', result.stderr || result.compilerOutput || result.stdout); + assert.ok(result.total > 0 && result.passed === result.total && result.checks.every(check => check.passed)); + const jdk = runtime.runtimes.find(item => item.javaVersion === 21); + assert.ok(commands.length > 0, 'actual command specifications were observed'); + assert.ok(commands.every(command => inside(command.executable, jdk.path)), 'every Java/javac command uses this package JDK'); + if (request.exerciseId === project.exerciseId) { + const command = commands.find(command => command.args.includes('org.codehaus.plexus.classworlds.launcher.Launcher')); + assert.ok(command, 'actual Maven launcher invocation observed'); + assert.equal(command.args.includes('--offline'), !preparation, 'only explicit environment preparation permits Maven dependency downloads'); + assert.equal(normalize(command.args.find(arg => arg.startsWith('-Dmaven.home=')).slice('-Dmaven.home='.length)), normalize(runtime.maven.path)); + const classpath = command.args[command.args.indexOf('-classpath') + 1]; + assert.ok(inside(classpath, runtime.maven.path), 'actual Maven launcher comes from this package'); + } else { + const compiler = commands.find(command => path.basename(command.executable).toLowerCase() === 'javac.exe'); + assert.ok(compiler, 'actual bundled javac invocation observed'); + assert.equal(compiler.args[compiler.args.indexOf('--release') + 1], String(result.targetVersion)); + } + return { result, request }; +} + +async function verifyBundledRuntime(value) { + assert.equal(value.mode, 'local'); + assert.deepEqual(value.supportedTargets, [8, 11, 17, 21]); + assert.deepEqual(value.runtimes.filter(item => item.available).map(item => item.javaVersion), [21]); + const jdk = value.runtimes.find(item => item.javaVersion === 21); + assert.equal(jdk.source, 'bundled'); + assert.equal(value.maven.source, 'bundled'); + assert.equal(value.maven.available, true); + assert.ok(value.bundledRuntime?.id && typeof value.bundledRuntime.id === 'string'); + assert.deepEqual(value.bundledRuntime.javaVersions, [21]); + assert.equal(value.bundledRuntime.mavenVersion, value.maven.version); + const resources = path.join(path.dirname(executablePath), 'resources'); + assert.ok(inside(jdk.path, resources), 'automatically registered JDK is contained in this product'); + assert.ok(inside(value.maven.path, resources), 'automatically registered Maven is contained in this product'); + await access(path.join(jdk.path, 'bin/java.exe')); + await access(path.join(jdk.path, 'bin/javac.exe')); + await access(path.join(value.maven.path, 'boot')); +} + +async function main() { + assert.ok(kind === 'zip' || kind === 'nsis', 'pass --kind zip|nsis'); + assert.ok(executableInput && path.isAbsolute(executableInput), 'pass an explicitly approved absolute --executable path'); + assert.equal(path.basename(executablePath), 'Java Edu.exe'); + if (kind === 'nsis') assert.equal(normalize(executablePath), normalize(path.join(root, 'work/한글 설치 & %JAVA_HOME%/Java Edu.exe'))); + else { + assert.ok(inside(executablePath, path.join(root, 'work/bundled-zip'))); + for (const text of ['한글', ' ', '&', '%JAVA_HOME%']) assert.ok(executablePath.includes(text), `ZIP extraction path preserves literal ${text}`); + } + assert.ok((await lstat(executablePath)).isFile()); + assert.equal(normalize(await realpath(executablePath)), normalize(executablePath), 'approved package executable is not redirected'); + await mkdir(artifacts, { recursive: true }); + const preflight = JSON.parse((await readFile(preflightPath, 'utf8')).replace(/^\uFEFF/, '')); + assert.ok(Array.isArray(preflight.AppData) && preflight.AppData.length >= 2, 'root preflight of default application-data paths is required'); + assert.ok(preflight.AppData.every(item => item.Exists === false), 'do not overwrite existing user data'); + for (const item of preflight.AppData) assert.equal(await exists(item.Path), false, `STOP: existing application data at ${item.Path}`); + evidence.preflight = preflight; + evidence.executableSha256 = digest(await readFile(executablePath)); + evidence.asarSha256 = digest(await readFile(path.join(path.dirname(executablePath), 'resources/app.asar'))); + const solutions = JSON.parse(await readFile(path.join(root, 'examples/solutions.json'), 'utf8')); + await launch(); + const firstOrigin = new URL(page.url()).origin; + + await check('first-launch-automatic-bundled-tools-without-host-environment', async () => { + const facts = await app.evaluate(({ app, BrowserWindow }) => ({ packaged: app.isPackaged, version: app.getVersion(), executable: app.getPath('exe'), appPath: app.getAppPath(), userData: app.getPath('userData'), preferences: BrowserWindow.getAllWindows()[0].webContents.getLastWebPreferences() })); + userData = facts.userData; + evidence.application = facts; + assert.equal(facts.packaged, true); + assert.equal(facts.version, '0.3.0'); + assert.equal(normalize(facts.executable), normalize(executablePath)); + assert.ok(preflight.AppData.some(item => normalize(item.Path) === normalize(userData)), 'default production AppData only'); + for (const [key, value] of Object.entries({ contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, allowRunningInsecureContent: false })) assert.equal(facts.preferences[key], value); + runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); + evidence.runtimeBeforeConsent = runtime; + await verifyBundledRuntime(runtime); + assert.equal(runtime.consent.accepted, false); + assert.equal(runtime.available, false); + await openLesson(coreCases[0]); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + await settings(); + assert.equal(await page.locator('#execution-consent').isChecked(), false); + assert.match(await page.locator('#desktop-bundle-title').textContent(), /별도 설치 없이/); + assert.deepEqual(await page.locator('#desktop-bundle-status .settings-bundle-tools li span').allTextContents(), ['앱에 포함', '앱에 포함']); + assert.match(await page.locator('#desktop-bundle-status').textContent(), /환경 준비 시 인터넷/); + assert.equal(await page.locator('#desktop-advanced-settings').getAttribute('open'), null, 'optional external-tool settings start collapsed'); + assert.equal(executionCount(), 0, 'opening the app/settings does not run learner code or prepare a project'); + await capture('bundled-first-launch-settings'); + await page.locator('#desktop-advanced-summary').focus(); + await page.keyboard.press('Enter'); + await eventually(() => page.locator('#desktop-advanced-settings').evaluate(node => node.open), true, 'optional settings are keyboard accessible'); + for (const version of [8, 11, 17]) assert.match(await page.locator(`#jdk-register-${version}`).locator('..').textContent(), /선택 사항/); + await page.locator('#desktop-advanced-summary').focus(); + await page.keyboard.press('Enter'); + await eventually(() => page.locator('#desktop-advanced-settings').evaluate(node => node.open), false, 'optional settings close without choosing a directory'); + await closeSettings(); + }); + + await check('consent-enables-bundled-tools-without-directory-selection', async () => { + await settings(); + await page.locator('#execution-consent').check(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, true, 'explicit consent persisted'); + runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); + await verifyBundledRuntime(runtime); + await closeSettings(); + await eventually(() => page.locator('#run-code').isEnabled(), true, 'bundled runtime enables execution'); + assert.equal(executionCount(), 0, 'accepting consent alone does not execute code'); + }); + + for (const lesson of coreCases) await check(`bundled-jvm21-core-target${lesson.minVersion}`, async () => { + await openLesson(lesson); + await editor().fill(solutions[lesson.exerciseId]); + await quiz(lesson); + const { result, request } = await execute('#submit-code'); + assert.equal(result.targetVersion, lesson.minVersion); + assert.equal(request.source, solutions[lesson.exerciseId]); + assert.equal(await badge('lab'), 'true'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await capture(`bundled-core-target${lesson.minVersion}-passed`); + }); + + await check('spring-h2-explicit-online-preparation-not-achievement', async () => { + await openLesson(project); + await eventually(() => page.locator('#prepare-project').isEnabled(), true, 'project metadata and bundled tools ready'); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + const file = projectExercise.files.find(item => item.editable); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().fill(`${file.source}\n// preparation preserves this learner draft\n`); + await openLesson(project, 'theory'); + await openLesson(project); + const before = (await progress()).fileDrafts; + const { result, request } = await execute('#prepare-project', 360000); + assert.equal(result.targetVersion, 17); + assert.equal(result.prepared, true); + assert.equal(request.source, undefined); + assert.equal(request.files, undefined); + assert.deepEqual((await progress()).fileDrafts, before); + assert.equal(await badge('quiz'), 'false'); + assert.equal(await badge('lab'), 'false'); + await eventually(() => page.locator('#project-preparation-state').getAttribute('data-prepared'), 'true', 'environment marked prepared'); + assert.equal(await page.locator('#execution-output .execution-result-label').textContent(), '환경 준비 완료'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await capture('bundled-spring-h2-prepared'); + }); + + await check('spring-h2-actual-bundled-maven-offline-submit', async () => { + const expectedFiles = {}; + for (const file of project.professionalExercise.files.filter(item => item.editable)) { + expectedFiles[file.path] = await readFile(path.join(root, 'curriculum/professional', file.referencePath), 'utf8'); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().fill(expectedFiles[file.path]); + } + await quiz(project); + const { request } = await execute('#submit-code', 180000); + assert.deepEqual(request.files, expectedFiles); + assert.equal(Object.hasOwn(request.files, 'pom.xml'), false); + assert.equal(await badge('lab'), 'true'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await capture('bundled-spring-h2-submitted'); + }); + + await check('restart-retains-bundled-registration-progress-theme-and-drafts', async () => { + const core = coreCases[0]; + const marker = `bundled ${kind} restart draft`; + await openLesson(core); + await editor().fill(`${solutions[core.exerciseId]}\n// ${marker}\n`); + await page.locator('#editor-theme-select').selectOption('contrast'); + await openLesson(project); + const file = projectExercise.files.find(item => item.editable); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().fill(`${file.source}\n// ${marker} project\n`); + await openLesson(coreCases[3], 'theory'); + const before = await stored(); + const registered = await page.evaluate(() => window.javaEduDesktop.getSettings()); + await assertNoFolderDialog(); + await app.close(); app = null; + await launch(); + evidence.origins = [firstOrigin, new URL(page.url()).origin]; + assert.notEqual(evidence.origins[0], evidence.origins[1]); + const after = await stored(); + assert.equal(after[preferencesKey], before[preferencesKey]); + for (const field of ['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'notes']) assert.deepEqual(JSON.parse(after[progressKey])[field], JSON.parse(before[progressKey])[field], `${field} survives port change`); + runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); + await verifyBundledRuntime(runtime); + assert.equal(runtime.consent.accepted, true); + assert.deepEqual(runtime.bundledRuntime, registered.bundledRuntime); + assert.equal(normalize(runtime.runtimes.find(item => item.javaVersion === 21).path), normalize(registered.runtimes.find(item => item.javaVersion === 21).path)); + assert.equal(normalize(runtime.maven.path), normalize(registered.maven.path)); + evidence.runtimeAfterRestart = runtime; + await openLesson(core); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'contrast'); + await editor().press('Control+End'); + await eventually(() => editor().innerText(), text => text.includes(marker), 'restored core draft is visible in the actual editor'); + await openLesson(project); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().press('Control+End'); + await eventually(() => editor().innerText(), text => text.includes(`${marker} project`), 'restored project draft is visible in the actual editor'); + assert.equal(await page.locator('#prepare-project').getAttribute('data-prepared'), 'false'); + await openLesson(coreCases[3]); + assert.equal(await badge('quiz'), 'true'); + assert.equal(await badge('lab'), 'true'); + await capture('bundled-restarted-achievement'); + }); + + await check('bundled-settings-minimum-window-and-200-percent', async () => { + await settings(); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(720, 560)); + await eventually(() => page.evaluate(() => innerWidth), width => width < 800, 'minimum window applied'); + await page.locator('html').evaluate(node => { node.style.fontSize = '200%'; }); + const dialog = page.locator('#desktop-settings-dialog'); + await dialog.evaluate(node => { node.scrollTop = 0; }); + await capture('bundled-settings-minimum200-top'); + const includedTools = dialog.locator('.settings-bundle-tools'); + await includedTools.evaluate(node => node.scrollIntoView({ behavior: 'instant', block: 'center' })); + const toolsGeometry = await includedTools.evaluate(node => { + const dialog = node.closest('dialog').getBoundingClientRect(); + return { width: innerWidth, height: innerHeight, dialog: { top: dialog.top, bottom: dialog.bottom, left: dialog.left, right: dialog.right }, nodes: [...node.querySelectorAll('li, li strong, li span')].map(item => { const r = item.getBoundingClientRect(); return { text: item.textContent, top: r.top, bottom: r.bottom, left: r.left, right: r.right }; }) }; + }); + assert.ok(toolsGeometry.nodes.every(node => node.top >= Math.max(0, toolsGeometry.dialog.top) && node.bottom <= Math.min(toolsGeometry.height, toolsGeometry.dialog.bottom) && node.left >= toolsGeometry.dialog.left && node.right <= toolsGeometry.dialog.right), 'included Java/Maven names and source labels are fully visible at 200%'); + evidence.minimumBundledToolsViewport = toolsGeometry; + await capture('bundled-settings-minimum200-tools'); + const close = dialog.locator('.dialog-actions [data-close-dialog="desktop-settings-dialog"]'); + await close.evaluate(node => node.scrollIntoView({ behavior: 'instant', block: 'end' })); + const geometry = await close.evaluate(node => { const b = node.getBoundingClientRect(); const d = node.closest('dialog').getBoundingClientRect(); return { width: innerWidth, height: innerHeight, rootFont: getComputedStyle(document.documentElement).fontSize, button: { top: b.top, bottom: b.bottom, left: b.left, right: b.right }, dialog: { top: d.top, bottom: d.bottom, scrollWidth: node.closest('dialog').scrollWidth, clientWidth: node.closest('dialog').clientWidth } }; }); + assert.equal(geometry.rootFont, '32px'); + assert.ok(geometry.button.top >= Math.max(0, geometry.dialog.top) && geometry.button.bottom <= Math.min(geometry.height, geometry.dialog.bottom)); + assert.ok(geometry.button.left >= 0 && geometry.button.right <= geometry.width); + assert.ok(geometry.dialog.scrollWidth <= geometry.dialog.clientWidth + 1, 'dialog has no horizontal overflow'); + evidence.minimumSettingsViewport = geometry; + await capture('bundled-settings-minimum200-close'); + await close.click(); + await dialog.waitFor({ state: 'hidden' }); + }); + assert.deepEqual(errors, []); + assert.ok(apiRequests.every(request => new URL(request.origin).hostname === '127.0.0.1')); + await assertNoFolderDialog(); +} + +try { await main(); } +catch (error) { + if (!results.some(result => result.status === 'FAIL')) results.push({ name: 'environment-or-preflight', status: 'BLOCKED', message: error.message }); + console.error(error.stack); + process.exitCode = 1; +} finally { + await app?.close().catch(() => {}); + evidence.finishedAt = new Date().toISOString(); + evidence.results = results; + evidence.uncaughtBrowserErrors = errors; + evidence.apiRequests = apiRequests; + await mkdir(artifacts, { recursive: true }); + const resultFile = path.join(artifacts, `bundled-results-${Date.now()}.json`); + await writeFile(resultFile, JSON.stringify(evidence, null, 2)); + console.log(JSON.stringify({ results, resultFile, application: evidence.application, origins: evidence.origins }, null, 2)); +} From b2dda6d4f2225e5f2d9b31a5287539bd1b0685b6 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:12:12 +0900 Subject: [PATCH 19/29] =?UTF-8?q?docs(desktop):=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=EB=B7=B0=20=EA=B2=8C=EC=8B=9C=EC=99=80=20?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=20=EB=AC=B4=EA=B2=B0=EC=84=B1=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/deployment.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/deployment.md b/docs/deployment.md index 140f5ad..6d8f37b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -52,3 +52,21 @@ M4.1 당시에는 Windows v0.2.0 설치 파일의 로컬 빌드만 완료됐고 설치 EXE와 SHA256SUMS가 모두 uploaded 상태다. EXE 크기는111753633바이트, GitHub asset digest는 `sha256:6d4354e0b4a5c8b471591f0ee29c106f9ec65c2561847416555c38e623fd2f74`로 로컬 검증 파일과 일치한다. SHA256SUMS 파일의 digest도 `sha256:54694a213fadad9a0414c805d7346aa1f930f6ebb7380afbc9808ccf0edd0ffe`로 일치한다. 실제 NSIS 설치·설치 앱8/8·정상 제거·데이터 보존 및 독립 시각 검수 PASS. 설치 ASAR/helper/exe와 빌드본 동일성, 검토 소스·생성 파일 대조를 확인했다. [설치 QA](installed-desktop-qa-report.md)에 검사 경계·초기 스크립트 오류·실제 사용 도구·미검증을 기록했다. 설치 파일은 코드 서명되지 않은 프리뷰다. Redis/Kafka 실제 연동·SmartScreen·실기기·스크린리더는 이번 출시 검증에 포함하지 않는다. 기존 웹의 소유자 전용 접근과 구현 PR의 미병합 상태를 유지한다. + +## M8 Windows v0.3 통합 패키지와 웹 안내 배포 + +2026-10-12T09:10:19Z에 [v0.3.0 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)를 게시했다. 태그의 실제 commit과 릴리스 target은 `0e1ff5eb38484d1594d4fd1c231d4dbe229b44e8`로 일치한다. 후속 문서·QA 기록 커밋3a745cb와 구분한다. [CI38223955175](https://github.com/jbsim999/java-edu/actions/runs/38223955175)12작업, 최종 실제 ZIP10/10·NSIS10/10, 원본604도구·197소스/2생성물 대조, 코드·시각 독립 검수와 설치/제거·데이터 보존 PASS. package.json은 builder가 제거한 개발 필드를 제외한 런타임 필드로 비교했다. [통합 QA](bundled-desktop-qa-report.md)를 따른다. + +초안에서 다섯 첨부의 uploaded·크기·GitHub SHA256 digest를 로컬 파일과 대조한 뒤 공개하고, 공개 상태·태그·첨부를 다시 확인했다. EXE·ZIP과 대응 JDK·제3자 소스가 같은 릴리스에 함께 있다. 기존v0.2 파일은 교체하지 않았다. + +| 파일 | 바이트 | SHA256 | +| --- | ---: | --- | +| Java-Edu-0.3.0-x64.zip | 362369704 | `478889a5ab413349cc98f80801fb1f9a190ba6b1791f2c47de0d5fefcc0aa8bf` | +| Java-Edu-Setup-0.3.0-x64.exe | 362799820 | `b3e0a56b339fc3946cd80c194b9b5a544fdb3d5044277fc00acd6a9a11e8eeb8` | +| OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz | 115126841 | `573057d03584ae793fb7ec9a14c76d826d9187a53efeefd99da47403a5308234` | +| SHA256SUMS-0.3.0 | 388 | `b2490cb98bdd9d731a267354c8fd47e03aa29afe4cd16ec94f76fa4def603934` | +| Third-Party-Sources-0.3.0.zip | 4860928 | `4acbde9bafea55ab2334d20b69257c893cbb9b40aa084159d6e35cba2681756c` | + +웹 통합 설치 안내는 기존 소유자 전용 URL에 2026-10-12 08:25:24 UTC succeeded로 배포했다. Sites source `199443b5e185acdcd83bb3d4ca0a4ca43eefc3d8`, 버전 `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_60be210ba2a48191b036dc7530cad71f`, 배포 `appgdep_6acc996c9de48191a896c90d291afd9f`, archiveSHA256 `dd25ad4a14f440d3d4ad956b3332669ef0531724b74bf2cb7ea9cb38fa73e0c9`다. 공식 helper로 연 체크아웃에 GitHub ffb77751의268개 파일을 복사하고 Git blob을 전부 대조했다. npm ci(audit0)·Worker 빌드·Worker 검사 후 공식 source commit/push·package 결과의 SHA만 배포에 사용했다. Windows helper의 npm 실행 경로·Git Bash PATH·GNU tar Windows colon 처리를 확인한 뒤 같은 소스로 패키징했으며 도구를 수정하거나 TLS 검사를 해제하지 않았다. + +후속0e1ff5는 desktop 설치 압축 설정만 수정했고 웹 UI·Worker는 동일하다. 호스팅에서 Spring/JVM을 실행하는 기능은 추가하지 않았다. 배포 완료를 인증된 실제 웹 제출·실기기 검사로 표현하지 않는다. Windows 기본 실제 JVM21, 실무 첫 의존성 준비의 인터넷 필요, 선택 Redis/Kafka 미검증과 코드 서명되지 않은 프리뷰 범위를 유지한다. 구현 PR은 미병합이며 GitHub 공개와 웹 소유자 전용 접근을 구분한다. From 6ce0428a802be7a8a757a27a40853996a5b7fa04 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:50:41 +0900 Subject: [PATCH 20/29] =?UTF-8?q?feat(curriculum):=2034=EC=88=98=EC=97=85?= =?UTF-8?q?=20=EC=9D=BD=EA=B8=B0=20=EC=95=88=EB=82=B4=EC=99=80=20=EB=84=93?= =?UTF-8?q?=EC=9D=80=20=ED=95=99=EC=8A=B5=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/verify.yml | 3 + README.md | 22 +- .../lessons/build-gradle-ci/lesson.json | 76 +- .../lessons/build-maven/lesson.json | 75 +- .../lessons/flyway-migrations/lesson.json | 69 +- .../lessons/jpa-state/lesson.json | 77 +- .../lessons/json-validation/lesson.json | 77 +- .../lessons/jwt-validation/lesson.json | 79 +- .../lessons/kafka-idempotency/lesson.json | 77 +- .../lessons/lombok-values/lesson.json | 73 +- .../professional/lessons/mvc-rest/lesson.json | 75 +- .../lessons/mybatis-mapping/lesson.json | 77 +- .../lessons/observability/lesson.json | 77 +- .../lessons/oidc-identity/lesson.json | 76 +- .../lessons/optimistic-cancel/lesson.json | 71 +- .../lessons/orders-capstone/lesson.json | 85 +- .../lessons/redis-cache/lesson.json | 76 +- .../lessons/security-csrf/lesson.json | 72 +- .../lessons/spring-aop/lesson.json | 70 +- .../lessons/spring-di/lesson.json | 72 +- .../professional/lessons/sql-jdbc/lesson.json | 80 +- .../lessons/test-collaborators/lesson.json | 73 +- .../lessons/test-contracts/lesson.json | 72 +- .../professional/lessons/tx-proxy/lesson.json | 70 +- dist/app.js | 169 +++- dist/data/curriculum.js | 742 ++++++++++++++---- dist/index.html | 16 +- dist/styles.css | 172 ++-- docs/assets/learning-lab.png | Bin 126078 -> 109654 bytes docs/content-readability.md | 28 + docs/installation.md | 4 +- docs/readability-qa-report.md | 71 ++ package-lock.json | 4 +- package.json | 3 +- tests/bundled-desktop.mjs | 40 +- tests/local/reference-policy.test.mjs | 12 +- tests/readability.mjs | 687 ++++++++++++++++ tests/readability.test.mjs | 116 +++ 38 files changed, 3095 insertions(+), 643 deletions(-) create mode 100644 docs/content-readability.md create mode 100644 docs/readability-qa-report.md create mode 100644 tests/readability.mjs create mode 100644 tests/readability.test.mjs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index c58638e..98a32e4 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -58,6 +58,9 @@ jobs: - run: npm run test:theory env: QA_ARTIFACT_DIR: work/theory-independent-qa + - run: npm run test:readability + env: + QA_ARTIFACT_DIR: work/readability-qa professional: name: Professional projects (${{ matrix.java }}) runs-on: ubuntu-latest diff --git a/README.md b/README.md index b1b9662..24bc036 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,26 @@ ## 소개 -Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교재**를 함께 만드는 한국어 교육 프로젝트입니다. 문법을 넘어 객체의 책임·불변식, 타입과 동등성, 동시성, 트랜잭션을 코드와 행동 검사로 연결합니다. +Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교재**를 함께 만드는 한국어 교육 프로젝트입니다. 다른 언어 경험자와 현직 Java 개발자가 “메서드에 객체를 넘겼는데 왜 결과가 달라질까?”, “트랜잭션을 붙였는데 왜 데이터가 남을까?” 같은 질문을 실제 코드로 확인하도록 돕습니다. 수업은 **이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩** 순서로 진행합니다. Java 언어·표준 API는 Oracle/OpenJDK 공식 문서를, 프레임워크·라이브러리는 각 프로젝트의 공식 문서를 기준으로 합니다. 실무 주제에는 출처를 기록한 채용 공고의 기술 요건도 연결합니다. > **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. [Windows v0.3.0 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)를 설치해 학습할 수 있습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 34수업·이론 도식 12개·편집기 테마가 배포돼 있습니다. +## 설치하고 시작하기 + +**Windows x64용입니다. 아래에서 하나만 다운로드하세요.** + +**[설치 EXE 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.0/Java-Edu-Setup-0.3.0-x64.exe)** · **[설치 없이 사용하는 ZIP 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.0/Java-Edu-0.3.0-x64.zip)** + +1. **EXE:** 다운로드한 파일을 실행해 설치한 뒤 **Java Edu**를 엽니다. **ZIP:** 폴더 전체를 압축 해제한 뒤 `Java Edu.exe`를 엽니다. +2. 앱에서 수업을 선택하고 **이론 → 예시 → 문제 → 실무 → 코드 실습** 순서로 진행합니다. +3. Spring 실습은 **환경 준비**를 한 번 진행한 뒤 코드를 작성하고 실행·제출합니다. + +Java·Maven·Node.js가 포함돼 있어 따로 설치하거나 폴더를 등록할 필요가 없습니다. Spring 실습의 첫 환경 준비에는 인터넷이 필요합니다. ZIP 안의 EXE만 따로 옮기지 마세요. + +[자세한 설치 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) · [릴리스와 체크섬](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0) + ## 학습실 미리보기 ![Java Edu 학습실: 수업 목록, 과제 설명, Java 문법 색상과 직접 코딩 편집기](docs/assets/learning-lab.png) @@ -34,6 +48,8 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교 | --- | --- | | 공식 문서 기반 과정 | Java 8·11·17·21의 언어·API 차이와 공식 출처 확인 | | 깊이 있는 OOP | 객체의 책임·불변식, 다형성·합성, 동등성·변경 계약 탐구 | +| 실제 코드로 이해하기 | 34수업의 상황·용어·원문 코드·값 변화·결과 예측 해설 | +| 넓은 수업 본문 | 반복 안내는 펼쳐 보고, 화면 크기에 맞춰 본문과 코드 폭 확보 | | 검수한 이론 도식 | 참조·상태·호출·보안 조건을 설명하는 12개 도식과 공식 참고자료 | | 직접 코딩 | 클래스 골격, 메서드 본문, 다중 파일 프로젝트에 코드 작성 | | 읽기 쉬운 편집기 | 클래스·타입·변수·메서드 문법 색상, 다크·라이트·고대비 테마 | @@ -57,9 +73,7 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교 기본 실무 20프로젝트는 H2·로컬 테스트를 사용합니다. Redis/Kafka 두 단원은 별도 선택 환경이며 실제 서비스 연동은 아직 검증하지 않았습니다. OIDC 수업은 인증 주체 처리에 집중하고 전체 로그인 프로토콜 구현은 다루지 않습니다. [전체 로드맵](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) · [실무 과정 상세](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/professional-curriculum.md) -## 빠르게 시작하기 - -**Windows 학습자:** [v0.3.0 통합 패키지](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)에서 **설치 EXE 또는 ZIP 중 하나**를 받습니다. EXE는 설치 후 실행하고, ZIP은 전체 내용을 풀고 `Java Edu.exe`를 실행합니다. Java·Maven·Node.js가 포함되어 별도 설치와 폴더 등록이 필요 없습니다. Spring 실습의 첫 환경 준비에는 인터넷이 필요합니다. [설치 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) +## 개발 소스로 실행하기 **개발 소스 실행:** Node.js 22 이상이 필요합니다. diff --git a/curriculum/professional/lessons/build-gradle-ci/lesson.json b/curriculum/professional/lessons/build-gradle-ci/lesson.json index 6665cd2..63ab556 100644 --- a/curriculum/professional/lessons/build-gradle-ci/lesson.json +++ b/curriculum/professional/lessons/build-gradle-ci/lesson.json @@ -1,7 +1,7 @@ { "id": "build-gradle-ci", "title": "Gradle·Docker·CI의 재현 조건", - "description": "같은 업무 테스트를 Maven과 Gradle 설정에 연결하고 빌드 결과와 컨테이너 실행 조건을 비교합니다.", + "description": "테스트는 통과했는데 컨테이너가 실행할 JAR를 찾지 못할 수 있습니다. 같은 업무 코드를 검사하는 빌드 설정, 만들어진 파일, 실제 실행 JVM을 이어서 읽습니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,30 +10,70 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "빌드 성공이 배포 환경의 정상 실행까지 의미하려면 어떤 증거가 더 필요한가?", - "동일 업무 합계 계약", - "잘못된 수량·오버플로 거절", - "두 빌드의 테스트 경로 설명" + "BuildMain.total의 수량 검사와 19,900원 곱셈이 빌드 도구가 바뀌어도 같은 업무 기준이어야 함을 설명한다.", + "0 이하 수량과 int 범위를 넘는 곱셈이 각각 어디서 실패하는지 찾는다.", + "Maven과 Gradle이 같은 grader 테스트를 읽는 경로를 설명한다.", + "Java 17 컴파일 대상과 Dockerfile의 Java 21 실행 환경을 구분한다.", + "Maven 기준 검사와 아직 실행하지 않은 Gradle·Docker·CI 비교 자료를 구분한다." ], + "learningGuide": { + "scenario": "BuildMain의 계산 테스트를 통과한 뒤 컨테이너로 실행하려 합니다. Dockerfile에는 Java 21이 보이고 수업의 컴파일 대상은 Java 17입니다.", + "question": "이 파일은 무엇을 빌드하고 무엇을 실행하며, 기본 수량은 어디서 정할까요?", + "terms": [ + { + "term": "JAR", + "meaning": "컴파일한 Java 클래스 등을 묶은 파일입니다. 이 Dockerfile은 Maven이 미리 만든 JAR를 복사합니다." + }, + { + "term": "컴파일 대상 / 실행 JVM", + "meaning": "컴파일 대상은 코드를 만들 기준 버전이고 실행 JVM은 실제 실행 프로그램입니다. 여기서는 각각 17과 21입니다." + }, + { + "term": "ENTRYPOINT / CMD", + "meaning": "이 배열 형식의 ENTRYPOINT는 실행할 명령을, CMD는 그 명령에 붙일 기본 인수를 정합니다." + }, + { + "term": "CI", + "meaning": "소스 변경에 맞춰 빌드·검사를 자동 실행하는 절차입니다. 제공 ci.yml은 아직 실행 검증하지 않은 비교 자료입니다." + } + ], + "code": { + "file": "Dockerfile", + "source": "# 검토용 학습 예시. 먼저 Maven package로 JAR를 만들고 이미지·플랫폼·digest를 별도 검증하세요.\nFROM eclipse-temurin:21-jre\nWORKDIR /app\nCOPY target/build-gradle-ci-0.1.0.jar /app/app.jar\nUSER 10001:10001\nENTRYPOINT [\"java\",\"-cp\",\"/app/app.jar\",\"edu.lab.BuildMain\"]\nCMD [\"2\"]\n" + }, + "walkthrough": [ + "첫 주석은 Maven package로 JAR를 먼저 만들라고 안내합니다. 아래 Dockerfile에는 Java 소스를 컴파일하는 명령이 없습니다.", + "FROM eclipse-temurin:21-jre는 실행용 JVM 21 환경을 고릅니다. 이는 Java 17 컴파일 대상과 별개입니다. WORKDIR은 작업 디렉터리를 /app으로 정합니다.", + "COPY는 target/build-gradle-ci-0.1.0.jar를 /app/app.jar로 복사합니다. 그 입력 파일이 준비되어야 이미지 빌드가 이 단계를 진행할 수 있습니다.", + "USER 10001:10001은 실행할 사용자·그룹 ID를 지정합니다. ENTRYPOINT는 java에 클래스 경로 /app/app.jar와 실행 클래스 edu.lab.BuildMain을 전달합니다.", + "CMD [\"2\"]는 기본 인수 2입니다. 기존 BuildMain.main은 이를 정수로 바꿔 total에 넘기므로 정상 실행 조건에서는 19,900 × 2를 출력합니다." + ], + "prediction": { + "question": "필요한 JAR와 이미지가 준비되어 정상 실행된다고 가정하고 기본 CMD를 쓰면, 기대 출력과 실행 JVM 버전은 무엇일까요?", + "answer": "기대 출력은 39800이고 Dockerfile이 지정한 실행 JVM은 21입니다.", + "explanation": "CMD의 2가 BuildMain.total의 quantity가 됩니다. 컴파일 대상 17은 실행 JVM을 17로 바꾸지 않습니다. 이는 제공 코드를 읽은 예측이며 실제 Docker 실행 통과를 주장하는 결과가 아닙니다." + }, + "takeaway": "어디서 만든 어떤 JAR를 어떤 JVM으로 실행하는지 파일 경로와 버전 값을 따라 확인합니다." + }, "theory": [ { "heading": "빌드 도구보다 고정된 계약", - "text": "Maven과 Gradle은 의존성·컴파일·테스트 과정을 표현하는 도구입니다. 같은 Java 17 대상과 BOM, 같은 ContractTest를 사용하도록 설정해 도구 차이와 업무 차이를 분리합니다. 설치형 자동 실행은 승인된 Maven 경로만 사용하며 Gradle 비교 설정은 별도 실행 검증 대상입니다." + "text": "빌드 도구가 바뀌어도 수량 2의 합계가 39,800이어야 한다는 업무 조건은 같습니다. BuildMain.total은 양수 quantity만 받아 Math.multiplyExact(19900,quantity)로 int 범위를 넘는 계산도 거절합니다. Maven과 Gradle은 필요한 라이브러리를 가져오고 컴파일·테스트하는 절차를 표현합니다. 두 설정은 같은 grader의 ContractTest와 Java 17 대상을 사용하도록 작성되어 있습니다. BOM은 함께 사용할 의존성 버전 목록입니다. 설정을 나란히 읽는 것과 둘 다 실행해 같은 결과를 확인하는 것은 다르며, 설치형 자동 검사는 Maven 경로를 사용합니다." }, { "heading": "CI와 산출물 연결", - "text": "테스트한 커밋에서 만든 산출물을 배포해야 검증 근거가 연결됩니다. CI의 JDK 버전, 테스트 검색, 실패 전파, 아티팩트 보존을 확인합니다. 예시 workflow는 다운로드 프로젝트의 설명 파일이며 저장소 자동 배포 작업으로 설치하지 않습니다." + "text": "CI는 저장소 변경 때 빌드와 검사를 자동 실행하는 절차입니다. 제공한 ci.yml은 JDK 21을 준비하고 Maven verify를 실행하는 학습용 파일입니다. 실제 .github/workflows에 설치된 자동 작업은 아닙니다. 테스트한 소스와 나중에 실행할 JAR가 다르면 테스트 성공을 그 JAR의 근거로 삼을 수 없습니다. 따라서 커밋, JDK·의존성, 실제 테스트 실행 여부, 실패 전달, 생성한 파일의 식별값과 보존 위치를 이어서 기록해야 합니다." }, { "heading": "컨테이너의 범위", - "text": "Java release 대상과 컨테이너의 실제 JVM을 구분합니다. 예시 Dockerfile은 비특권 사용자로 이미 만든 JAR를 실행하는 작은 CLI 이미지입니다. 이미지 digest·취약점·호스트 플랫폼·실행 자원·비밀 관리까지 검증한 운영 배포 설정은 아닙니다." + "text": "컴파일 대상은 사용할 문법·API와 만들어질 클래스 파일의 기준이고, 실행 JVM은 그 파일을 실제로 읽는 프로세스입니다. build.gradle의 options.release=17과 Dockerfile의 eclipse-temurin:21-jre는 다른 정보를 말합니다. Dockerfile의 COPY는 Maven package로 미리 만든 target의 JAR를 /app/app.jar에 넣습니다. USER 10001:10001 아래에서 BuildMain을 실행하고 CMD의 2를 기본 수량으로 전달합니다. 이것은 값을 출력하고 끝나는 CLI 예시이며, 이미지 digest·취약점·플랫폼·자원·비밀 관리까지 검증한 운영 배포 설정은 아닙니다." } ], "example": { "title": "BuildMain 기준 구현", "code": "package edu.lab;\npublic final class BuildMain {\n public static int total(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0) throw new IllegalArgumentException(\"quantity\");\n return Math.multiplyExact(19900,quantity);\n // END EXERCISE\n }\n public static void main(String[] args) {\n if (args.length != 1) throw new IllegalArgumentException(\"one quantity argument required\");\n System.out.println(total(Integer.parseInt(args[0])));\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 동일 업무 합계 계약 · 잘못된 수량·오버플로 거절 · 두 빌드의 테스트 경로 설명.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "BuildMain.main은 인수 하나를 정수로 읽어 total을 호출합니다. total은 0 이하를 거절하고 multiplyExact로 합계를 계산하므로 수량 2의 계산 결과는 39,800입니다. build.gradle의 sourceSets는 ../grader/src/test/java를 테스트 경로로 지정하고 useJUnitPlatform을 사용합니다. Dockerfile은 Gradle 파일을 실행하는 대신 이미 만든 Maven target JAR를 복사해 BuildMain에 기본 인수 2를 줍니다. 이 Docker 실행 흐름은 코드를 읽은 예상이며 실제 Gradle·Docker·CI 실행 검증은 아직 하지 않았습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", "files": [ { "path": "src/main/java/edu/lab/BuildMain.java", @@ -83,7 +123,7 @@ "JAR 파일만 있으면 JVM 정보는 필요 없다." ], "correctIndex": 1, - "explanation": "컴파일 대상은 허용 문법·API·클래스 파일, 실행 JVM은 실제 프로세스 환경입니다. 한 버전 값으로 합치면 검증 범위를 잘못 설명합니다." + "explanation": "options.release=17은 Java 17 기준으로 컴파일하도록 정하고 FROM eclipse-temurin:21-jre는 컨테이너가 사용할 JVM을 지정합니다. 따라서 대상 17과 실행 JVM 21을 따로 기록해야 합니다. 이 설정을 읽었다는 것만으로 실제 컨테이너 실행까지 확인한 것은 아닙니다." }, { "id": "build-gradle-ci-q2", @@ -95,20 +135,20 @@ "컨테이너를 쓰면 소스 식별은 생략한다." ], "correctIndex": 1, - "explanation": "검증 대상과 실행 산출물의 연결이 있어야 실패 분석과 재현이 가능합니다." + "explanation": "검사한 커밋과 환경, 그 빌드가 만든 JAR의 식별이 이어져야 나중에 어떤 코드를 실행했는지 알 수 있습니다. 배포 직전 다른 환경에서 임의로 다시 만들거나 최신이라는 파일 이름만 보면 검사 대상과 실행 파일이 같다는 근거가 사라집니다." } ], "practice": { "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "같은 업무 테스트를 Maven과 Gradle 설정에 연결하고 빌드 결과와 컨테이너 실행 조건을 비교합니다.", + "scenario": "같은 Java 코드를 Maven과 Gradle로 비교하려 합니다. Dockerfile이 어떤 경로의 JAR를 요구하는지 모르고 파일만 섞으면, 업무 테스트와 실제 실행 파일의 연결이 끊길 수 있습니다.", "tasks": [ - "BuildMain.total은 19,900원 상품의 양수 수량 합계를 계산하며 오버플로를 거절합니다.", - "Maven과 Gradle 설정이 같은 grader 테스트를 읽는 경로를 설명하세요.", - "설치형 자동 채점은 Maven으로 수행합니다. Gradle·Docker·CI는 제공한 설정의 비교·검토 과제이며 아직 실행 검증 전입니다.", - "Docker 예시는 JAR 생성 후 실행하는 CLI이며 웹 서버·배포 자동화가 아닙니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "BuildMain.total에서 수량 2, 0, int 합계 범위를 넘는 수량의 경로를 나누어 설명하세요.", + "Maven 설정과 build.gradle의 sourceSets가 같은 grader를 가리키는지 읽고, Gradle의 JDK 17 toolchain 준비가 별도임을 적으세요.", + "COPY의 target/build-gradle-ci-0.1.0.jar가 먼저 필요한 이유와 ENTRYPOINT·CMD가 BuildMain에 수량 2를 전달하는 방식을 설명하세요.", + "설치형 채점은 Maven입니다. Gradle·Docker·CI 설정은 비교 자료이며 실행 미검증입니다. Docker 예시는 웹 서버나 배포 자동화가 아닌 CLI임을 기록하세요.", + "실제 도입 시 테스트한 커밋·빌드 환경·산출물 식별을 어떻게 연결할지 적고, 아직 검사하지 않은 이미지·플랫폼·action 고정 조건을 구분하세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "같은 소스, 같은 검사, 만든 파일, 그 파일을 실행할 JVM까지 연결해야 빌드 성공의 의미를 설명할 수 있습니다." }, "sources": [ { diff --git a/curriculum/professional/lessons/build-maven/lesson.json b/curriculum/professional/lessons/build-maven/lesson.json index 0ed8977..3251a43 100644 --- a/curriculum/professional/lessons/build-maven/lesson.json +++ b/curriculum/professional/lessons/build-maven/lesson.json @@ -1,7 +1,7 @@ { "id": "build-maven", "title": "Maven으로 재현 가능한 주문 계산 빌드", - "description": "컴파일 대상, 의존성, 테스트 실행이 빌드에 어떤 계약으로 남는지 확인합니다.", + "description": "1,500원 상품 두 개는 3,000원이어야 하고, 계산 범위를 넘는 금액은 실패해야 합니다. 이 규칙을 검사하는 코드와 Maven의 의존성·컴파일 대상·실행 JDK를 함께 읽습니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,27 +10,27 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "동일 소스를 다른 환경에서 검증하려면 무엇을 고정해야 하는가?", - "정상 곱셈", - "0원 상품", - "잘못된 수량 거절", - "정수 오버플로 거절" + "하위 pom.xml이 공통 부모에서 가져오는 버전 설정과 직접 선언하는 테스트 라이브러리를 구분한다.", + "lineTotal(1500, 2)가 3000을 반환하는 순서를 설명한다.", + "무료 상품의 가격 0과 잘못된 수량 0을 구분한다.", + "잘못된 입력은 곱셈 전에 IllegalArgumentException으로 거절한다.", + "int 범위를 넘는 곱셈과 실제 테스트 JVM의 버전을 별도로 확인한다." ], "theory": [ { "heading": "빌드는 실행 조건의 문서", - "text": "JDK 실행 버전과 javac release 대상은 다릅니다. 이 과정은 Java 17 대상 소스와 고정된 Maven 의존성 관리 정보를 제공하며, 실제 실행 JDK는 설치형에서 따로 표시합니다." + "text": "같은 OrderAmounts.java를 가져와도 팀원의 JDK와 라이브러리 버전이 다르면 빌드 결과가 달라질 수 있습니다. pom.xml의 parent는 여러 실습이 공유하는 빌드 설정을 가져옵니다. 하위 pom.xml에는 junit-jupiter와 assertj-core를 test 범위로 선언하고, 공통 부모는 BOM에서 의존성 버전을 관리합니다. BOM은 여러 라이브러리의 버전표이며, import만으로 그 라이브러리를 전부 추가하지 않습니다.\n\n컴파일 대상 17은 Java 17의 언어·공개 API·클래스 파일 범위로 컴파일한다는 뜻입니다. 검사를 실행하는 JVM이 21이면 테스트는 여전히 JVM 21에서 돕니다. 이 수업의 기존 기록도 JDK 21 실행과 Java 17 대상을 따로 표시합니다. 실제 Java 17에서의 동작은 Java 17 JVM으로 실행해야 확인할 수 있습니다." }, { "heading": "금액 규칙과 빌드 재현성", - "text": "의존성 이름만 적는 것으로 재현성이 완성되지 않습니다. 플러그인·BOM·문자 인코딩·테스트 검색 규칙을 고정하고 네트워크 의존성 준비와 실제 테스트 실행을 구분합니다. 금액 계산에는 음수·0·오버플로 계약이 필요합니다." + "text": "먼저 계산이 지켜야 할 규칙을 정합니다. 이것이 여기서 말하는 업무 계약입니다. lineTotal은 단가 unitPrice가 0 이상이고 수량 quantity가 1 이상일 때만 계산합니다. 따라서 (0, 1)은 무료 상품이라 0을 반환하지만 (100, 0)은 잘못된 주문이라 예외를 던집니다. Math.multiplyExact는 결과가 int 범위를 벗어나면 ArithmeticException을 던져 잘못된 금액이 정상값처럼 이어지는 것을 막습니다.\n\n이 규칙을 다른 PC에서도 검사하려고 공통 부모에 컴파일·테스트 플러그인 버전, UTF-8, 테스트 소스 경로를 둡니다. 의존성 다운로드가 끝났다는 사실만으로 ContractTest가 실행됐다고 볼 수는 없습니다. 실제 실행 결과와 테스트 수를 확인해야 하며, 버전 고정만으로 모든 OS·외부 의존성까지 동일해지는 것도 아닙니다." } ], "example": { "title": "OrderAmounts 기준 구현", "code": "package edu.lab;\npublic final class OrderAmounts {\n private OrderAmounts() {}\n public static int lineTotal(int unitPrice, int quantity) {\n // BEGIN EXERCISE\n if (unitPrice < 0 || quantity <= 0) throw new IllegalArgumentException(\"invalid amount\");\n return Math.multiplyExact(unitPrice, quantity);\n // END EXERCISE\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 정상 곱셈 · 0원 상품 · 잘못된 수량 거절 · 정수 오버플로 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "lineTotal은 if에서 단가와 수량을 먼저 확인한 다음 Math.multiplyExact를 호출합니다. 기존 테스트의 (1500, 2)는 3000, (0, 1)은 0이고, (-1, 1)과 (100, 0)은 입력 검사에서 멈춥니다. (Integer.MAX_VALUE, 2)는 입력 조건에는 맞지만 곱셈 결과가 너무 커서 ArithmeticException이 납니다. 예제의 pom.xml은 JUnit·AssertJ를 선언하고 실제 버전과 플러그인 설정은 공통 부모에서 받습니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", "files": [ { "path": "src/main/java/edu/lab/OrderAmounts.java", @@ -55,7 +55,7 @@ "컴파일 플러그인의 release 값에 라이브러리 이름을 넣는다." ], "correctIndex": 1, - "explanation": "BOM의 dependencyManagement는 의존성을 추가하는 것이 아니라 선언될 의존성의 버전과 기본 설정을 관리합니다." + "explanation": "공통 부모의 BOM은 junit-jupiter 등에 사용할 버전을 알려 줍니다. 하위 pom.xml의 dependencies에 junit-jupiter를 적어야 이 프로젝트의 테스트 의존성으로 선택됩니다. BOM을 가져오는 일과 실제로 사용할 라이브러리를 추가하는 일은 두 단계입니다." }, { "id": "build-maven-q2", @@ -67,19 +67,19 @@ "네이티브 라이브러리의 모든 호환성이 검증된다." ], "correctIndex": 1, - "explanation": "컴파일 대상과 실제 테스트 JVM은 다릅니다. release는 대상 언어·API·클래스 파일을 제한하지만 실제 Java 17 실행 검사를 대신하지 않습니다." + "explanation": "JDK 21의 javac에 --release 17을 주면 Java 17에서 지원하는 언어·공개 API와 클래스 파일을 대상으로 컴파일합니다. 이어서 JDK 21의 java로 ContractTest를 실행하면 실행 JVM은 21입니다. 이 설정만으로 Java 17 JVM 테스트나 네이티브 라이브러리 호환성까지 확인한 것은 아닙니다." } ], "practice": { - "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "컴파일 대상, 의존성, 테스트 실행이 빌드에 어떤 계약으로 남는지 확인합니다.", + "title": "주문 금액 규칙을 빌드에서 반복 확인하기", + "scenario": "주문 계산을 CI에서도 검사하려고 합니다. 무료 상품은 허용하되 수량 0과 너무 큰 금액은 성공 응답으로 흘려보내면 안 됩니다. 결과값뿐 아니라 어떤 설정과 JDK로 테스트했는지 남겨야 팀원이 같은 조건을 확인할 수 있습니다.", "tasks": [ - "OrderAmounts.lineTotal(int unitPrice, int quantity)를 구현하세요.", - "unitPrice는 0 이상, quantity는 1 이상입니다. 위반하면 IllegalArgumentException을 던집니다.", - "int 범위 초과는 Math.multiplyExact를 통해 ArithmeticException으로 알립니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "OrderAmounts.lineTotal(int unitPrice, int quantity)를 구현하고 (1500, 2)와 (0, 1)의 반환값을 확인하세요.", + "unitPrice는 0 이상, quantity는 1 이상이어야 합니다. (-1, 1)과 (100, 0)을 IllegalArgumentException으로 거절하는 위치를 설명하세요.", + "Math.multiplyExact로 int 범위 초과를 ArithmeticException으로 알리세요. (Integer.MAX_VALUE, 2)는 왜 입력 검사 뒤에 실패하는지 설명하세요.", + "예제 pom.xml의 dependencies와 공통 부모의 dependencyManagement가 맡는 일을 구분하고, 기존 기록의 컴파일 대상 17·실행 JVM 21·테스트 수를 각각 읽어 보세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "금액 규칙은 구체적인 입력과 결과로, 빌드 조건은 의존성·플러그인·컴파일 대상·실행 JVM으로 남깁니다. 두 기록이 있어야 같은 계산을 같은 조건에서 다시 확인할 수 있습니다." }, "sources": [ { @@ -162,5 +162,44 @@ "starterSatisfied": false, "sourceFingerprint": "a9d4d663d97d617409faa0998490cce985e0199084dc3fa55e42351f452d2405", "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "무료 상품 주문을 허용했더니 수량 0도 통과하고, 큰 금액은 이상한 값으로 계산되는 결함을 막으려 합니다. 기존 lineTotal의 두 줄이 서로 다른 실패를 맡습니다.", + "question": "왜 입력을 확인한 뒤에도 단순 곱셈 대신 Math.multiplyExact를 호출할까요?", + "terms": [ + { + "term": "업무 계약", + "meaning": "이 메서드가 받아들일 입력과 돌려줄 결과에 대한 약속입니다. lineTotal에서는 단가 0 이상·수량 1 이상·범위를 넘는 계산 거절이 해당합니다." + }, + { + "term": "오버플로", + "meaning": "계산 결과가 자료형이 담을 수 있는 범위를 넘는 일입니다. Integer.MAX_VALUE에 2를 곱한 금액은 int에 담을 수 없습니다." + }, + { + "term": "BOM", + "meaning": "여러 라이브러리에 사용할 버전을 모아 둔 목록입니다. 이 수업의 pom.xml은 공통 부모를 통해 그 버전 정보를 받고 JUnit·AssertJ 사용은 직접 선언합니다." + }, + { + "term": "컴파일 대상과 실행 JVM", + "meaning": "컴파일 대상은 만들 코드의 호환 범위이고 실행 JVM은 그 코드를 실제로 돌리는 프로그램입니다. 이 수업의 기존 검사는 대상 17, 실행 21입니다." + } + ], + "walkthrough": [ + "lineTotal(1500, 2)를 호출하면 unitPrice는 1500, quantity는 2입니다. 둘 다 if의 거절 조건에 걸리지 않습니다.", + "Math.multiplyExact(1500, 2)가 3000을 계산하고 바로 반환합니다. 계산 결과를 별도 필드에 저장하지 않습니다.", + "lineTotal(0, 1)의 단가 0은 허용하지만 lineTotal(100, 0)의 수량 0은 if에서 IllegalArgumentException을 던집니다.", + "lineTotal(Integer.MAX_VALUE, 2)는 양수 검사에는 통과합니다. 이어지는 multiplyExact가 int 범위 초과를 발견해 ArithmeticException을 던집니다.", + "기존 ContractTest는 정상값·0원·잘못된 입력·범위 초과를 확인합니다. pom.xml의 테스트 의존성 선언은 이 검사를 빌드에서 실행할 준비이고, 실제 실행 여부는 테스트 결과로 확인합니다." + ], + "prediction": { + "question": "lineTotal(0, 1)과 lineTotal(Integer.MAX_VALUE, 2)를 각각 호출하면 어떤 결과가 나오나요?", + "answer": "첫 호출은 0을 반환하고, 두 번째 호출은 ArithmeticException을 던집니다.", + "explanation": "단가가 0인 것은 허용됩니다. 두 번째 호출도 입력 조건에는 맞지만 곱셈 결과가 int 범위를 넘어 multiplyExact에서 실패합니다. 두 실패 지점을 구분해 보는 코드 읽기 질문입니다." + }, + "takeaway": "입력이 허용되는지와 계산 결과를 담을 수 있는지는 다른 검사입니다. 이 차이를 테스트로 남기고, 그 테스트를 돌린 빌드 조건도 함께 기록합니다.", + "code": { + "file": "src/main/java/edu/lab/OrderAmounts.java", + "source": " public static int lineTotal(int unitPrice, int quantity) {\n // BEGIN EXERCISE\n if (unitPrice < 0 || quantity <= 0) throw new IllegalArgumentException(\"invalid amount\");\n return Math.multiplyExact(unitPrice, quantity);\n // END EXERCISE\n }\n" + } } } diff --git a/curriculum/professional/lessons/flyway-migrations/lesson.json b/curriculum/professional/lessons/flyway-migrations/lesson.json index 6eb9ae5..26c530c 100644 --- a/curriculum/professional/lessons/flyway-migrations/lesson.json +++ b/curriculum/professional/lessons/flyway-migrations/lesson.json @@ -1,7 +1,7 @@ { "id": "flyway-migrations", "title": "Flyway로 스키마 변경을 기록", - "description": "테이블 초기화와 운영 스키마 변경 이력을 구분하고 요청 키의 유일성을 DB 계약으로 남깁니다.", + "description": "주문 A와 B에 같은 요청 키가 저장되는 일을 막아 봅니다. 테이블을 만든 V1 다음에 V2를 적용하고, DB가 중복 키를 거절하는 이유를 읽습니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,30 +10,63 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "애플리케이션 배포와 스키마 변경의 순서를 어떻게 재현하는가?", - "두 버전 적용", - "재실행 시 0개 적용", - "중복 요청 키 거절" + "이미 만든 orders 테이블에 request_key를 추가하는 순서를 설명한다.", + "처음 migrate할 때 V1과 V2가 적용되고 두 번째에는 0개가 적용되는 이유를 설명한다.", + "주문 ID가 달라도 같은 요청 키 req-1을 저장하면 왜 실패하는지 찾는다.", + "운영 테이블에 제약을 추가하기 전 중복 데이터와 배포 순서를 확인한다." ], + "learningGuide": { + "scenario": "주문 A를 req-1로 저장했습니다. 응답을 못 받은 클라이언트가 다시 요청하면서 주문 ID만 B로 바뀌었습니다. 요청 키가 같다는 사실을 DB가 알아볼 수 있게 만듭니다.", + "question": "서로 다른 주문 ID인데도 두 번째 저장을 막으려면 어느 열에 규칙을 걸어야 할까요?", + "terms": [ + { + "term": "마이그레이션", + "meaning": "DB 구조 변경을 순서가 있는 파일로 관리하는 작업입니다. 여기서는 V1로 orders를 만들고 V2로 request_key를 추가합니다." + }, + { + "term": "UNIQUE 제약", + "meaning": "같은 값을 중복 저장하지 못하게 하는 DB 규칙입니다. uq_order_request는 request_key 값의 중복을 검사합니다." + }, + { + "term": "요청 키", + "meaning": "재시도된 요청이 원래 어떤 요청이었는지 식별할 값입니다. 주문 ID와 별도로 req-1 같은 값을 저장합니다." + } + ], + "code": { + "file": "src/main/resources/db/migration/V2__request_key.sql", + "source": "ALTER TABLE orders ADD COLUMN request_key VARCHAR(80);\nALTER TABLE orders ADD CONSTRAINT uq_order_request UNIQUE (request_key);\n" + }, + "walkthrough": [ + "첫 줄 ALTER TABLE orders ADD COLUMN은 V1에서 이미 만든 orders에 request_key 열을 추가합니다. VARCHAR(80)은 최대 길이를 정하며, 이 줄에는 NOT NULL이 없습니다.", + "둘째 줄 ADD CONSTRAINT는 uq_order_request라는 이름의 규칙을 만듭니다. UNIQUE (request_key)이므로 비교 대상은 주문 id가 아니라 요청 키입니다.", + "이 두 줄이 적용된 뒤 A와 B에 같은 req-1을 저장하면 두 번째 INSERT가 제약에 걸립니다. Flyway의 적용 이력과 주문 데이터의 중복 검사는 서로 다른 역할입니다." + ], + "prediction": { + "question": "V1·V2를 적용한 빈 DB에서 기존 테스트처럼 SQL마다 자동 커밋(변경 확정)되는 상태로 A/req-1을 저장한 뒤 B/req-1을 저장하면 주문은 몇 행 남을까요?", + "answer": "두 번째 저장은 거절되고, A 한 행만 남습니다.", + "explanation": "기존 테스트의 JDBC 연결은 기본 자동 커밋 상태이므로 A의 INSERT가 먼저 확정됩니다. B는 같은 request_key=req-1 때문에 V2의 UNIQUE 규칙에 거절되지만, 이미 확정한 A는 남습니다. 두 INSERT를 하나의 트랜잭션으로 묶어 전체 변경을 되돌리면 A도 남지 않으므로, 최종 행 수는 중복 제약과 커밋 조건을 함께 보고 판단해야 합니다." + }, + "takeaway": "무엇이 같은 요청인지 정했다면, 그 값을 저장하는 열에 DB 제약까지 연결해야 합니다." + }, "theory": [ { "heading": "버전 있는 변경 이력", - "text": "Flyway는 적용된 버전과 체크섬을 기록합니다. 이미 배포된 버전 파일을 수정하기보다 새 마이그레이션으로 다음 상태를 표현합니다. 재실행 시 이미 적용된 버전은 다시 적용되지 않습니다." + "text": "개발 PC에는 request_key 열이 있는데 다른 환경에는 없다면 같은 앱도 다르게 실패합니다. 마이그레이션은 DB 구조를 바꾸는 작업을 파일로 남겨 순서대로 적용하는 방법입니다. 이 예제의 V1__create_orders.sql은 orders를 만들고 V2__request_key.sql은 열과 제약을 추가합니다. MigrationLab.configured는 이 파일들이 있는 classpath:db/migration을 지정합니다. Flyway는 적용한 버전과 파일 내용의 확인값인 체크섬을 기록하므로, 같은 DB에서 migrate를 다시 호출해도 완료한 V1·V2를 다시 실행하지 않습니다. 이미 적용한 파일을 고치지 않고 다음 버전을 추가하는 이유입니다." }, { "heading": "제약 추가의 데이터 전제", - "text": "UNIQUE를 추가하기 전에 기존 데이터의 중복과 NULL 의미를 확인해야 합니다. 이 과제는 새 학습 DB에 nullable request_key와 UNIQUE를 추가합니다. 실제 서비스의 데이터 정리·대형 테이블 잠금·롤링 배포 전략은 추가 검증이 필요합니다." + "text": "V2의 첫 ALTER TABLE은 request_key라는 열을 추가합니다. 두 번째 줄의 UNIQUE는 같은 키 값을 가진 주문을 두 번 저장하지 못하게 하는 DB 규칙입니다. A와 B의 id가 달라도 request_key가 모두 req-1이면 두 번째 저장이 거절됩니다. 이 열에는 NOT NULL이 없으므로 값이 없는 경우의 의미까지 정한 것은 아닙니다. 이번 검사는 새 학습용 H2 DB의 비어 있지 않은 요청 키 중복을 다룹니다. 기존 운영 데이터의 중복·NULL 정책과 제약 추가 중 잠금은 별도로 확인해야 합니다." }, { "heading": "확장 후 전환", - "text": "앱과 DB를 동시에 원자적으로 교체할 수 없는 배포에서는 추가 가능한 스키마를 먼저 배포하고 코드 전환 뒤 제거하는 방식이 유용할 수 있습니다. 모든 DB의 DDL 트랜잭션 동작이 같다는 가정은 피합니다." + "text": "앱 여러 대를 차례로 교체하는 동안에는 구버전과 신버전이 같은 DB를 쓸 수 있습니다. 새 열을 먼저 추가하고, 그 열을 쓰는 코드로 전환한 뒤, 더 이상 쓰지 않는 열을 제거하는 순서를 검토하는 이유입니다. V2처럼 열을 추가하는 변경도 실제 DB 종류와 데이터 양에 따라 배포 중 영향이 달라집니다. DDL은 CREATE·ALTER 같은 구조 변경 SQL을 말하며, 실패했을 때 전부 되돌릴 수 있는지는 DB별로 확인합니다." } ], "example": { "title": "MigrationLab 기준 구현", "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport org.flywaydb.core.Flyway;\npublic final class MigrationLab {\n private MigrationLab() {}\n public static Flyway configured(DataSource source) {\n return Flyway.configure().dataSource(source).locations(\"classpath:db/migration\").load();\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 1개 JUnit 테스트 통과.\n검증 항목: 두 버전 적용 · 재실행 시 0개 적용 · 중복 요청 키 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "MigrationLab.configured는 SQL을 직접 실행하는 메서드가 아니라 Flyway가 읽을 위치와 DataSource를 연결합니다. 테스트가 migrate를 호출하면 V1의 orders 생성 뒤 V2의 request_key 추가와 UNIQUE 설정이 진행됩니다. 재호출은 새로 적용할 파일이 없어 0개입니다. 이어 A의 req-1 저장은 성공하고 B의 같은 키 저장은 실패하므로 orders에는 A 한 행만 남습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", "files": [ { "path": "src/main/java/edu/lab/MigrationLab.java", @@ -68,7 +101,7 @@ "파일 이름만 V1으로 유지하면 체크섬은 무관하다." ], "correctIndex": 0, - "explanation": "이미 적용된 변경 이력을 유지하고 다음 버전을 추가해야 다른 환경에서도 같은 순서를 재현할 수 있습니다." + "explanation": "이미 적용된 V1을 바꾸면 새 환경이 읽는 내용과 기존 DB가 적용했던 내용이 달라집니다. 다음 V2·V3 파일에 변경을 추가해야 이력을 따라 같은 상태에 도달할 수 있습니다. 이 예제도 테이블 생성 V1을 고치지 않고 요청 키를 V2에서 추가합니다." }, { "id": "flyway-migrations-q2", @@ -80,20 +113,20 @@ "서비스 포트 번호만" ], "correctIndex": 0, - "explanation": "제약 추가는 기존 데이터와 DB의 DDL 특성, 동시 배포 코드에 영향을 받습니다." + "explanation": "기존 orders에 req-1이 두 번 있다면 UNIQUE를 붙이는 단계부터 실패할 수 있습니다. NULL을 허용할지, ALTER TABLE이 얼마나 잠그는지, 구버전 앱이 새 스키마에서도 동작할지를 함께 확인해야 합니다. 새 H2 DB의 성공만으로 운영 테이블을 판단할 수 없습니다." } ], "practice": { "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "테이블 초기화와 운영 스키마 변경 이력을 구분하고 요청 키의 유일성을 DB 계약으로 남깁니다.", + "scenario": "주문 버튼을 두 번 누르거나 응답을 못 받아 재시도하면 서로 다른 주문 ID에 같은 요청 키가 들어올 수 있습니다. 애플리케이션의 사전 조회만 믿지 않고 DB에도 중복 금지 규칙을 둡니다.", "tasks": [ - "V1은 orders 기본 테이블, V2는 request_key 열과 UNIQUE 제약을 추가합니다.", - "서로 다른 주문에 같은 non-null request_key를 저장하면 DB가 거절해야 합니다.", - "같은 DB에 migrate를 다시 실행하면 이미 적용된 마이그레이션 수는 0입니다.", - "학습용 DB만 대상으로 하며 clean 호출을 구현하지 않습니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "V1에서 만든 orders에 V2의 두 ALTER TABLE이 각각 무엇을 추가하는지 말로 설명하세요.", + "A에 req-1을 저장한 뒤 B에도 req-1을 저장할 때, id와 request_key 중 어떤 규칙 때문에 실패하는지 짚으세요.", + "같은 학습 DB에서 migrate를 두 번 호출할 때 적용 수가 2, 0인 이유를 변경 이력과 연결하세요.", + "운영 테이블로 옮긴다고 가정하고 기존 중복·NULL·DDL 잠금·구버전 앱 호환성을 확인 목록에 적으세요. 이 실습에서는 학습용 DB만 사용하며 clean을 호출하지 않습니다.", + "학습 H2에서 확인한 제약 동작과 실제 운영 DB에서 아직 확인하지 않은 사항을 구분하세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "마이그레이션 파일은 DB가 다음 상태로 가는 순서이고, UNIQUE는 그 상태에서 중복 요청 키를 막는 규칙입니다. 두 역할을 함께 확인합니다." }, "sources": [ { diff --git a/curriculum/professional/lessons/jpa-state/lesson.json b/curriculum/professional/lessons/jpa-state/lesson.json index 296ee4b..c4d1065 100644 --- a/curriculum/professional/lessons/jpa-state/lesson.json +++ b/curriculum/professional/lessons/jpa-state/lesson.json @@ -1,7 +1,7 @@ { "id": "jpa-state", "title": "JPA·Hibernate의 상태와 변경 감지", - "description": "영속 상태와 분리 상태를 구분하고 공개 setter 대신 업무 메서드로 주문 불변식을 지킵니다.", + "description": "같은 changeQuantity를 호출해도 어떤 객체는 DB가 바뀌고 어떤 객체는 메모리 값만 바뀝니다. EntityManager가 관리하는 동안과 닫힌 뒤를 기존 테스트 값으로 비교합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,30 +10,30 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "객체의 필드를 바꾼 결과가 언제 DB에 반영되는가?", - "관리 엔티티 변경 감지", - "분리 객체 변경 비반영", - "실패 시 객체 수량 보존" + "OrderEntity의 메서드 호출과 DB에 SQL이 반영되는 시점을 구분한다.", + "관리 중인 주문 수량 2를 4로 바꾸고 commit하면 새 조회에서 4가 보이는 흐름을 설명한다.", + "EntityManager를 닫은 뒤 객체 수량을 7로 바꿔도 DB에는 2가 남는 이유를 설명한다.", + "changeQuantity(0)이 실패할 때 객체의 이전 수량이 보존되도록 검사 순서를 지킨다." ], "theory": [ { "heading": "영속성 컨텍스트와 변경 감지", - "text": "EntityManager가 관리하는 엔티티의 변경은 트랜잭션 flush·commit 시 SQL로 동기화될 수 있습니다. 메서드를 호출한 순간과 SQL 실행 순간은 같지 않습니다. 관리 범위를 벗어난 detached 객체를 바꿔도 자동으로 DB에 반영되지 않습니다." + "text": "영속성 컨텍스트는 EntityManager가 현재 추적하는 엔티티들을 관리하는 범위입니다. 그 안에 있는 객체를 관리 상태라고 합니다. 기존 managedChangesAreFlushed 테스트는 트랜잭션을 시작하고 em.find로 A 주문을 가져온 뒤 changeQuantity(4)를 호출합니다. 이 메서드 자체에는 UPDATE가 없지만 Hibernate가 관리 객체의 변경을 DB와 맞추면서 commit 뒤 새 EntityManager 조회에서 4를 읽게 됩니다.\n\n메모리 변경을 SQL로 동기화하는 flush와 트랜잭션을 확정하는 commit은 다른 일입니다. flush가 됐어도 커밋 전 변경은 롤백될 수 있고, SQL이 메서드 호출 바로 그 순간에 실행된다고 단정할 수 없습니다. 반대로 EntityManager를 닫아 관리에서 벗어난 분리(detached) 객체는 changeQuantity(7)를 불러도 그 변경이 자동으로 DB에 전달되지 않습니다." }, { "heading": "엔티티와 값 객체의 차이", - "text": "엔티티는 식별자·수명·영속 상태를 갖습니다. 모든 필드를 포함하는 Lombok @Data를 기계적으로 붙이면 변경되는 hashCode, 연관관계 순환, 지연 로딩 접근을 유발할 수 있습니다. 이 예제는 동등성 전략을 무리하게 일반화하지 않고 업무 변경 메서드에 집중합니다." + "text": "OrderEntity는 @Id의 id로 DB 주문 한 건을 식별합니다. 수량이 2에서 4로 바뀌어도 A 주문이라는 식별은 유지됩니다. 같은 필드값 전체를 비교하는 값과 달리 엔티티는 어느 DB 행을 나타내며 누가 관리 중인지도 중요합니다.\n\n수량처럼 바뀌는 필드까지 equals·hashCode에 넣으면 컬렉션에서 찾는 기준이 바뀔 수 있습니다. 연관관계가 있는 엔티티의 자동 생성 메서드는 연결된 객체를 따라 읽거나 지연 로딩을 일으킬 수도 있습니다. 이 예제는 그런 일반 전략을 자동 결정하려고 @Data를 붙이지 않고 changeQuantity의 변경 규칙에 집중합니다. 실제 엔티티의 동등성은 ID가 정해지는 시점·프록시·관계를 함께 검토해야 합니다." }, { "heading": "캡슐화와 ORM 제약", - "text": "보호된 무인자 생성자는 ORM의 생성 경로이며 일반 호출자의 올바른 생성 경로와 다릅니다. 임의 setter 대신 changeQuantity가 양수 불변식을 지킵니다. DB 제약과 다른 쓰기 경로까지 함께 설계해야 전체 시스템의 불변식을 보장할 수 있습니다." + "text": "캡슐화는 이 코드에서 “quantity를 아무 값으로나 대입하게 하지 않고 changeQuantity를 통해 바꾸게 한다”는 뜻입니다. changeQuantity는 quantity <= 0이면 this.quantity에 대입하기 전에 예외를 던집니다. 그래서 수량 3인 주문에 0을 요청해도 실패 뒤 수량은 3입니다. 공개 생성자도 changeQuantity를 거쳐 초기 수량에 같은 규칙을 적용합니다.\n\nprotected 무인자 생성자는 ORM이 객체를 복원할 때 쓸 생성 경로입니다. 이 코드의 @Id가 필드에 있으므로 JPA는 필드를 통해 값을 읽고 채울 수 있으며 모든 필드에 공개 setter가 필요한 것은 아닙니다. 자바 메서드의 검사만으로 DB를 직접 갱신하는 다른 경로까지 막지는 못하므로 실제 시스템에서는 DB 제약도 함께 설계해야 합니다. create-drop은 이 학습용 H2 DB를 만들고 폐기하기 위한 설정입니다." } ], "example": { "title": "JpaLab 기준 구현", "code": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class JpaLab {\n private JpaLab() {}\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"orders\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 3개 JUnit 테스트 통과.\n검증 항목: 관리 엔티티 변경 감지 · 분리 객체 변경 비반영 · 실패 시 객체 수량 보존.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "JpaLab.open은 테스트마다 별도 이름의 H2 DB를 사용하는 EntityManagerFactory를 만듭니다. 실제 수량 변경은 함께 제공된 OrderEntity.java의 changeQuantity가 맡습니다. 이 메서드는 양수인지 확인한 뒤 필드에 대입할 뿐 SQL을 직접 실행하지 않습니다. 관리 중인 A의 수량 2를 4로 바꾸고 commit하면 새 조회는 4를 읽지만, EntityManager를 닫은 뒤 A 객체를 7로 바꾸면 DB는 2 그대로입니다. 수량 3인 B를 0으로 바꾸려는 시도는 대입 전에 예외가 나서 객체 값도 3으로 남습니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", "files": [ { "path": "src/main/java/edu/lab/JpaLab.java", @@ -68,7 +68,7 @@ "다시 영속화하는 별도 과정 없이는 DB에 자동 반영되지 않는다." ], "correctIndex": 3, - "explanation": "변경 감지는 영속성 컨텍스트의 관리 상태에서 이루어집니다. 분리 객체에 대한 일반 자바 변경은 DB에 자동 전달되지 않습니다." + "explanation": "EntityManager를 닫으면 그 객체는 더 이상 해당 관리 범위에서 변경 추적되지 않습니다. 기존 테스트의 detached.changeQuantity(7)는 자바 필드를 7로 바꾸지만 storedQuantity()가 새 EntityManager로 읽는 DB 값은 2입니다. @Entity 표시가 있다는 이유만으로 모든 객체 변경이 자동 저장되지는 않습니다." }, { "id": "jpa-state-q2", @@ -80,20 +80,20 @@ "record로 바꾸면 모든 JPA 제약이 없어진다." ], "correctIndex": 1, - "explanation": "엔티티의 동등성과 해시 안정성은 저장 전후의 식별자와 객체 수명, 연관관계에 따라 달라집니다." + "explanation": "OrderEntity의 quantity는 정상적으로 바뀔 수 있는 값입니다. 이를 해시 계산에 넣었을 때 컬렉션 조회가 안정적인지, 저장 전후 ID가 같게 유지되는지, 연관관계나 프록시를 읽는 영향이 있는지 따져야 합니다. 코드 줄 수나 DB 열 순서만으로 equals·hashCode 전략을 결정할 수는 없습니다." } ], "practice": { - "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "영속 상태와 분리 상태를 구분하고 공개 setter 대신 업무 메서드로 주문 불변식을 지킵니다.", + "title": "객체 수량과 DB 수량을 따로 확인하기", + "scenario": "주문 수량 변경 메서드를 실행했는데 새로 조회하면 옛 수량이 보입니다. 메서드 본문뿐 아니라 그 객체를 읽은 EntityManager가 아직 관리 중인지, 트랜잭션을 확정했는지 확인하려 합니다.", "tasks": [ - "OrderEntity.changeQuantity는 양수만 허용하고 실패하면 기존 수량을 유지합니다.", - "관리 상태에서 수량을 변경하고 commit한 뒤 새 EntityManager로 읽으면 변경이 보입니다.", - "EntityManager를 닫은 뒤 detached 엔티티를 변경해도 DB 값은 바뀌지 않습니다.", - "create-drop 설정은 폐기 가능한 학습 DB용이며 운영 마이그레이션 정책이 아닙니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "OrderEntity.changeQuantity에서 양수 검사를 대입보다 먼저 하세요. 수량 3에서 0 변경이 실패하면 객체 값도 3이어야 합니다.", + "기존 managedChangesAreFlushed의 begin → find → changeQuantity(4) → commit → 새 EntityManager 조회를 따라 DB 수량이 4인 이유를 설명하세요.", + "detachedChangesAreNotAutomaticallySaved의 find → close → changeQuantity(7) 순서를 비교하고, 메모리 객체와 DB 수량 2를 구분하세요.", + "persistence.xml의 create-drop은 폐기 가능한 학습 DB용이며 운영 마이그레이션 정책이 아닙니다.", + "flush가 SQL 동기화이고 commit은 트랜잭션 확정이라는 차이를 설명하세요. 이 테스트에는 여러 요청의 동시 수정 충돌이나 운영 DB의 잠금 검증이 없습니다." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "필드를 바꾸는 메서드와 DB에 반영하는 관리 상태를 함께 읽습니다. 값이 바뀌었다는 사실만으로 저장을 판단하지 말고 새 EntityManager의 조회 결과와 트랜잭션 종료를 확인합니다." }, "sources": [ { @@ -195,5 +195,44 @@ "starterSatisfied": false, "sourceFingerprint": "c721dc8a92215ad6b837e104656f0ba8defb5781844f845447f5194a1737332c", "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "수량 2인 A 주문을 읽고 changeQuantity를 호출합니다. 관리 중에 4로 바꾼 경우와 EntityManager를 닫은 뒤 7로 바꾼 경우는 같은 메서드를 써도 DB 결과가 다릅니다.", + "question": "changeQuantity에 SQL이 없는데 언제 DB 수량이 바뀌고, 언제 객체 값만 바뀔까요?", + "terms": [ + { + "term": "영속성 컨텍스트", + "meaning": "EntityManager가 현재 추적하는 엔티티들을 관리하는 범위입니다. em.find로 읽은 OrderEntity가 이 범위 안에 있는지에 따라 자동 변경 반영 여부가 달라집니다." + }, + { + "term": "관리 상태와 분리 상태", + "meaning": "관리 상태는 EntityManager가 추적 중인 상태이고, 분리 상태는 그 관리에서 벗어난 상태입니다. 기존 테스트는 em.close()로 두 상황을 나눕니다." + }, + { + "term": "변경 감지", + "meaning": "관리 객체의 값이 바뀐 것을 확인해 DB에 맞추는 동작입니다. changeQuantity(4)의 필드 대입이 관리 상태에서는 SQL 반영으로 이어집니다." + }, + { + "term": "flush와 commit", + "meaning": "flush는 변경을 SQL로 동기화하는 일이고 commit은 트랜잭션을 확정하는 일입니다. SQL이 나갔다는 것만으로 확정된 것은 아닙니다." + } + ], + "walkthrough": [ + "공개 OrderEntity 생성자는 id를 확인하고 changeQuantity(quantity)를 호출합니다. 처음 만들 때도 수량 규칙을 같은 메서드로 검사합니다.", + "changeQuantity는 0 이하인지 먼저 확인하고, 통과한 경우에만 this.quantity에 새 값을 넣습니다. 이 본문 자체에는 SQL 호출이 없습니다.", + "기존 관리 상태 테스트는 begin 후 em.find로 A를 읽고 changeQuantity(4)를 부릅니다. commit 때 필요한 변경이 DB에 동기화·확정되어 새 조회가 4를 읽습니다.", + "분리 상태 테스트는 A를 읽은 em을 먼저 닫습니다. 이후 detached.changeQuantity(7)은 객체의 필드만 바꾸고, 새 EntityManager가 읽는 DB 수량은 2입니다.", + "기존 실패 테스트의 B는 수량 3입니다. changeQuantity(0)은 if에서 예외를 던져 대입을 건너뛰므로 order.quantity()는 계속 3입니다." + ], + "prediction": { + "question": "DB 수량 2인 A를 em.find로 읽고 em.close()한 뒤 그 객체에서 changeQuantity(7)를 호출합니다. 새 EntityManager로 A를 읽으면 수량은 얼마인가요?", + "answer": "2입니다. 바꾼 분리 객체의 수량만 7입니다.", + "explanation": "changeQuantity의 자바 대입은 실행됐지만 원래 EntityManager는 닫혔습니다. 그 뒤 변경을 DB에 반영하는 별도 과정이 없으므로 자동 UPDATE가 일어나지 않습니다." + }, + "takeaway": "“메서드가 실행됐다”와 “DB에 저장됐다”는 확인 지점이 다릅니다. 엔티티를 누가 관리하는지, 어느 트랜잭션에서 확정하는지를 함께 추적합니다.", + "code": { + "file": "src/main/java/edu/lab/OrderEntity.java", + "source": " public OrderEntity(String id, int quantity) {\n if (id == null || id.isBlank()) throw new IllegalArgumentException(\"id\");\n this.id = id; changeQuantity(quantity);\n }\n public String id() { return id; }\n public int quantity() { return quantity; }\n public void changeQuantity(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0) throw new IllegalArgumentException(\"quantity\");\n this.quantity = quantity;\n // END EXERCISE\n }\n}\n" + } } } diff --git a/curriculum/professional/lessons/json-validation/lesson.json b/curriculum/professional/lessons/json-validation/lesson.json index 50c3840..2e061e4 100644 --- a/curriculum/professional/lessons/json-validation/lesson.json +++ b/curriculum/professional/lessons/json-validation/lesson.json @@ -1,7 +1,7 @@ { "id": "json-validation", "title": "Jackson·Validation의 입력 경계", - "description": "JSON 역직렬화 실패, Bean Validation 실패, 업무 불변식 위반을 구분하고 입력 정책을 고정합니다.", + "description": "깨진 JSON, 수량 0, 알 수 없는 필드는 서로 다른 단계에서 거절됩니다. Request를 만드는 단계와 @Valid 검사, create 본문에 도달하는 순서를 따라갑니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,30 +10,30 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "자료형에 들어온 값은 언제 업무에 사용할 수 있는가?", - "정상 생성 201", - "0 수량·공백 sku 400", - "깨진 JSON·미지 필드 400" + "JSON을 Request로 바꾸는 과정과 Request 값의 제약 검사를 구분한다.", + "정상 sku·quantity가 create를 거쳐 201 응답으로 반환되는 순서를 설명한다.", + "0 수량·공백 sku가 create 본문 전에 400으로 거절되는 이유를 설명한다.", + "깨진 JSON·미지 필드 거절이 Jackson 설정과 연결된다는 점을 확인한다." ], "theory": [ { "heading": "세 가지 입력 경계", - "text": "유효한 JSON이라고 해서 유효한 주문은 아닙니다. 파싱·자료형 변환·필드 제약·업무 규칙을 단계별로 구분하면 400 응답과 내부 오류를 혼동하지 않습니다. @Valid는 요청 객체 검증을 MVC 경계에 연결합니다." + "text": "여기서 입력 경계란 외부에서 받은 값을 프로그램이 사용하기 전에 확인하는 지점입니다. 먼저 Jackson이 JSON 문장을 읽어 Request 객체로 바꿉니다. 깨진 JSON이면 이 단계에서 실패합니다. 객체가 만들어지면 MVC가 @Valid에 따라 sku의 @NotBlank와 quantity의 @Min(1)을 검사합니다. 수량 0은 int로 만들 수 있어도 주문 수량 조건에 맞지 않아 이 단계에서 거절됩니다.\n\n필드 검사를 통과해야 create 본문이 실행되어 Created를 반환합니다. 다만 @Min(1)은 재고가 있는지까지 확인하지 않습니다. 실제 주문에서 재고 부족·판매 중단 같은 업무 규칙은 서비스가 현재 데이터를 보고 검사해야 합니다. 이 예제의 create는 받은 값을 응답으로 옮길 뿐 실제 주문을 DB에 저장하지 않습니다." }, { "heading": "누락과 기본값", - "text": "원시 int의 누락이 0으로 변환되는 설정에서는 @Min(1)이 거절합니다. nullable Integer를 쓰면 @NotNull이 별도로 필요할 수 있습니다. 의도한 부재 의미를 타입과 검증 규칙에 함께 기록합니다." + "text": "기존 테스트 설정에서 {\"sku\":\"BOOK\"}처럼 quantity를 생략하면 원시 int 값은 0이 되고 @Min(1)에 걸립니다. “값이 없었다”와 “명시적으로 0을 보냈다”가 같은 0으로 보이므로, 두 경우를 API에서 구분해야 한다면 타입과 입력 정책을 함께 정해야 합니다.\n\nInteger로 바꾸면 누락을 null로 표현할 수 있지만 @Min만으로 null을 거절하지는 않습니다. 필수값이어야 한다면 @NotNull 같은 별도 제약도 필요합니다. 이 설명은 타입을 무조건 바꾸라는 처방이 아니라, 현재 Request의 int와 @Min(1)이 누락을 어떻게 처리하는지 읽기 위한 비교입니다." }, { "heading": "호환성과 엄격성", - "text": "알 수 없는 필드를 거절할지 무시할지는 API 정책입니다. 이 예제는 오타를 발견하도록 FAIL_ON_UNKNOWN_PROPERTIES를 명시적으로 켭니다. 이를 모든 API의 보편적 정답으로 적용하면 점진 배포 호환성을 해칠 수 있습니다." + "text": "알 수 없는 필드 이름을 거절하는 일은 sku·quantity 값의 제약 검사와 다른 역할입니다. 이 수업의 ContractTest.setup은 ObjectMapper에 FAIL_ON_UNKNOWN_PROPERTIES를 켜서 \"quanity\" 같은 필드를 보내면 Jackson 변환 단계에서 실패하게 합니다. 컨트롤러의 @Valid만으로 이 엄격한 필드 정책이 생기는 것은 아닙니다.\n\n이 선택은 오타를 빨리 드러내지만, 새 클라이언트가 추가 필드를 보내는 동안 구 서버가 이를 거절할 수 있다는 비용도 있습니다. 실제 API에서는 서버·클라이언트가 서로 다른 버전으로 배포되는 기간을 고려해 정책을 정합니다. 이 수업의 400 결과는 지정된 MockMvc 변환기·검증기 설정에 대한 결과로 읽습니다." } ], "example": { "title": "CreateOrderController 기준 구현", "code": "package edu.lab;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotBlank;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class CreateOrderController {\n // BEGIN MODEL\n public record Request(@NotBlank String sku, @Min(1) int quantity) {}\n // END MODEL\n public record Created(String sku, int quantity) {}\n @PostMapping\n @ResponseStatus(HttpStatus.CREATED)\n public Created create(@Valid @RequestBody Request request) {\n // BEGIN EXERCISE\n return new Created(request.sku(), request.quantity());\n // END EXERCISE\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 3개 JUnit 테스트 통과.\n검증 항목: 정상 생성 201 · 0 수량·공백 sku 400 · 깨진 JSON·미지 필드 400.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "Request는 sku에 @NotBlank, quantity에 @Min(1)을 선언합니다. 테스트의 MVC 경로로 {\"sku\":\"BOOK\",\"quantity\":2}를 보내면 Jackson이 Request를 만들고 @Valid 검사를 통과한 뒤 create가 Created(\"BOOK\", 2)를 반환합니다. @ResponseStatus가 응답을 201로 정합니다. quantity 0과 공백 sku는 본문 전에 거절됩니다. 알 수 없는 필드 거절은 별도 ContractTest.setup의 ObjectMapper 설정에서 오며, 이 컨트롤러에는 저장소나 재고 검사 구현이 없습니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", "files": [ { "path": "src/main/java/edu/lab/CreateOrderController.java", @@ -58,7 +58,7 @@ "int는 항상 요청에 존재하므로 누락할 수 없다." ], "correctIndex": 0, - "explanation": "요청 객체 생성 후 @Valid가 제약을 검사합니다. int의 0 기본값이 양수 조건에 맞지 않으므로 MVC는 요청을 거절합니다." + "explanation": "현재 테스트의 Jackson 설정은 빠진 원시 int quantity를 0으로 만들고, MVC가 @Valid로 @Min(1)을 검사합니다. 0은 조건에 맞지 않아 create 본문에 들어가기 전에 400으로 끝납니다. 수량 1로 자동 보정하는 코드는 없습니다." }, { "id": "json-validation-q2", @@ -70,20 +70,20 @@ "HTTP 상태 코드와 무관하므로 문서화할 필요가 없다." ], "correctIndex": 1, - "explanation": "JSON 변환 정책과 값 검증은 별개이며 엄격한 필드 정책은 오타 발견과 점진 배포 사이의 선택을 요구합니다." + "explanation": "FAIL_ON_UNKNOWN_PROPERTIES는 JSON 이름을 Request의 필드에 맞추는 Jackson 설정입니다. @Valid가 검사하는 것은 이미 만들어진 Request의 값입니다. 알 수 없는 필드를 거절하면 오타를 찾기 쉽지만 구 서버가 새 클라이언트의 추가 필드도 거절할 수 있으므로 배포 호환성과 함께 결정해야 합니다." } ], "practice": { - "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "JSON 역직렬화 실패, Bean Validation 실패, 업무 불변식 위반을 구분하고 입력 정책을 고정합니다.", + "title": "요청이 어느 단계에서 거절되는지 찾기", + "scenario": "클라이언트가 수량을 빠뜨리거나 quantity를 quanity로 잘못 적어 보냅니다. 서버는 실패 원인을 구분해 입력을 거절하고 정상 요청만 응답 객체로 옮겨야 합니다.", "tasks": [ - "sku는 공백이 아니고 quantity는 1 이상이어야 합니다.", - "POST /orders의 유효한 JSON은 201과 sku·quantity를 반환합니다.", - "잘못된 JSON, 잘못된 필드 값, 알 수 없는 필드는 400입니다.", - "메서드 안의 업무 규칙과 @Valid 요청 검증이 같은 검사라고 가정하지 마세요.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "sku는 공백이 아니고 quantity는 1 이상이어야 합니다. 각각 Request의 어떤 제약이 담당하는지 설명하세요.", + "POST /orders의 {\"sku\":\"BOOK\",\"quantity\":2}가 검증 후 201과 sku·quantity를 반환하도록 create를 완성하세요.", + "깨진 JSON, quantity 0, 알 수 없는 quanity를 각각 Jackson 변환 단계와 @Valid 검사 단계 중 어디서 거절하는지 구분하세요.", + "ContractTest.setup의 FAIL_ON_UNKNOWN_PROPERTIES 설정을 찾아 @Valid만으로 미지 필드를 거절한다고 오해하지 않도록 설명하세요.", + "현재 Created 반환은 실제 주문 저장이 아닙니다. 실무의 재고 검사·저장 규칙은 어느 서비스에서 확인할지, 컨트롤러를 자바로 직접 호출하면 MVC 검증을 거치지 않는다는 점과 함께 적으세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "JSON 문장을 읽을 수 있는지, 객체의 값이 조건에 맞는지, 실제로 주문할 수 있는지는 서로 다른 질문입니다. 실패한 단계를 알면 입력 오류를 내부 장애나 저장 성공과 혼동하지 않습니다." }, "sources": [ { @@ -173,5 +173,44 @@ "starterSatisfied": false, "sourceFingerprint": "cb2dd529c781109c07cf6272989e94ab712c38e29484390922d7e38fb4f046b9", "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "같은 POST /orders라도 깨진 JSON과 수량 0은 다른 이유로 실패합니다. {\"sku\":\"BOOK\",\"quantity\":2}가 실제로 create 본문에 도착하기까지 거치는 단계를 봅니다.", + "question": "int로 변환할 수 있는 quantity 0은 왜 정상 요청이 아니며, 누가 거절할까요?", + "terms": [ + { + "term": "역직렬화", + "meaning": "전송된 JSON을 자바 객체로 바꾸는 일입니다. Jackson은 요청 본문을 이 코드의 Request로 만듭니다." + }, + { + "term": "Bean Validation", + "meaning": "객체 값이 애너테이션의 조건에 맞는지 검사하는 기능입니다. Request에서는 공백이 아닌 sku와 1 이상 quantity를 확인합니다." + }, + { + "term": "@Valid", + "meaning": "이 MVC 요청 처리에서 만들어진 Request에 검증을 적용하도록 연결하는 표시입니다. 필드별 조건 자체는 @NotBlank와 @Min에 있습니다." + }, + { + "term": "업무 규칙", + "meaning": "형식이 맞는 요청을 실제로 처리해도 되는지 정한 조건입니다. 주문 수량이 1 이상이어도 재고가 충분한지는 별도 데이터로 판단해야 합니다." + } + ], + "walkthrough": [ + "@RequestBody는 요청 JSON을 Request로 변환해 받도록 합니다. 기존 테스트는 Jackson 변환기와 검증기를 MockMvc에 등록합니다.", + "Request의 quantity가 2이고 sku가 BOOK이면 @Min(1)과 @NotBlank를 모두 만족합니다.", + "MVC가 @Valid 검사를 마친 뒤 create 본문을 실행합니다. request.sku()와 request.quantity()를 읽어 Created에 그대로 옮깁니다.", + "@ResponseStatus(HttpStatus.CREATED)가 이 정상 반환의 상태 코드를 201로 정하고 Created가 JSON 응답이 됩니다.", + "quantity가 0이면 객체로는 만들 수 있지만 @Min(1)을 통과하지 못해 본문 전에 400으로 끝납니다. 깨진 JSON이나 엄격한 설정의 미지 필드는 그보다 앞선 Jackson 변환 단계에서 실패합니다." + ], + "prediction": { + "question": "기존 MockMvc 설정에 {\"sku\":\"BOOK\"}를 POST하면 create 본문이 실행되고 201이 나올까요?", + "answer": "아닙니다. quantity가 0이 되어 @Min(1)에 걸리고 400이 됩니다.", + "explanation": "JSON 문법은 맞아 Request를 만들 수 있지만, 이 설정에서 누락된 원시 int의 값 0이 수량 조건에 맞지 않습니다. Created를 만드는 본문에 들어가기 전에 거절됩니다." + }, + "takeaway": "“값이 자바 타입에 들어왔다”는 것은 검사 완료가 아닙니다. Request 생성 → 값 검증 → 업무 처리의 순서를 구분해 읽습니다.", + "code": { + "file": "src/main/java/edu/lab/CreateOrderController.java", + "source": " // BEGIN MODEL\n public record Request(@NotBlank String sku, @Min(1) int quantity) {}\n // END MODEL\n public record Created(String sku, int quantity) {}\n @PostMapping\n @ResponseStatus(HttpStatus.CREATED)\n public Created create(@Valid @RequestBody Request request) {\n // BEGIN EXERCISE\n return new Created(request.sku(), request.quantity());\n // END EXERCISE\n }\n" + } } } diff --git a/curriculum/professional/lessons/jwt-validation/lesson.json b/curriculum/professional/lessons/jwt-validation/lesson.json index 7c8c1df..f1a14e4 100644 --- a/curriculum/professional/lessons/jwt-validation/lesson.json +++ b/curriculum/professional/lessons/jwt-validation/lesson.json @@ -1,7 +1,7 @@ { "id": "jwt-validation", "title": "JWT 서명·발급자·대상·만료 검증", - "description": "서명 성공과 토큰 수용을 분리하고 발급자·대상·시간·필수 claim을 독립적으로 거절합니다.", + "description": "서명은 맞지만 다른 API에 쓰라고 발급된 토큰을 주문 API가 받으면 안 됩니다. JwtLab의 공개키 검사와 발급자·대상·만료·사용자 ID 조건을 한 줄씩 연결합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,32 +10,71 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "서명이 맞는 토큰이라도 우리 API가 받아들이면 안 되는 경우는?", - "정상 토큰 수용", - "issuer·audience 불일치 거절", - "만료·미래 nbf 거절", - "다른 키 서명 거절", - "필수 claim 누락 거절" + "서명 검증에 성공해도 JWT를 거절해야 하는 조건을 코드에서 찾는다.", + "정상 토큰이 통과하려면 필요한 issuer·audience·subject·exp를 설명한다.", + "다른 발급자·다른 API 대상·다른 키 서명에 대한 거절을 구분한다.", + "만료와 아직 사용 가능한 시각이 오지 않은 nbf를 고정 Clock으로 비교한다.", + "필수 값 누락과 현재 시각에 정확히 만료된 토큰을 거절하는 줄을 찾는다.", + "로컬 키 검사가 운영 키 배포·교체 검증과 다른 범위임을 설명한다." ], + "learningGuide": { + "scenario": "주문 API가 신뢰하는 키로 서명된 토큰이 왔지만, audience에는 another-api만 들어 있습니다. subject와 만료 시각은 정상입니다.", + "question": "키가 맞는다는 이유만으로 이 토큰을 받아도 될까요?", + "terms": [ + { + "term": "claim", + "meaning": "토큰이 담고 있는 이름·값 정보입니다. issuer, audience, subject, exp 등이 각각 다른 조건을 나타냅니다." + }, + { + "term": "issuer / audience", + "meaning": "issuer는 발급자, audience는 토큰을 받을 대상입니다. 이 예제는 지정 발급자와 orders-api라는 대상을 요구합니다." + }, + { + "term": "exp / nbf", + "meaning": "exp는 만료 시각, nbf는 사용 가능 시작 시각입니다. 서명이 맞아도 현재 시각이 허용 범위 밖이면 거절합니다." + }, + { + "term": "Clock", + "meaning": "현재 시각을 제공하는 Java 객체입니다. 테스트에서는 시간을 고정해 만료 경계를 반복해서 확인할 수 있습니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/JwtLab.java", + "source": " NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();\n JwtTimestampValidator time = new JwtTimestampValidator(Duration.ZERO); time.setClock(clock);\n OAuth2TokenValidator required = jwt -> {\n boolean valid = jwt.getAudience() != null && jwt.getAudience().contains(\"orders-api\")\n && jwt.getExpiresAt() != null && jwt.getExpiresAt().isAfter(clock.instant())\n && jwt.getSubject() != null && !jwt.getSubject().isBlank();\n return valid ? OAuth2TokenValidatorResult.success()\n : OAuth2TokenValidatorResult.failure(new OAuth2Error(\"invalid_token\",\"required claims missing or wrong\",null));\n };\n decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(time, new JwtIssuerValidator(ISSUER), required));\n return decoder;" + }, + "walkthrough": [ + "NimbusJwtDecoder.withPublicKey(publicKey).build()는 주어진 RSA 공개키로 서명 검사할 decoder를 만듭니다. 아래 claim 조건은 이 검사에 추가됩니다.", + "JwtTimestampValidator(Duration.ZERO)와 time.setClock(clock)은 시간 오차 허용을 0으로 두고 인수로 받은 시각을 사용합니다.", + "required의 boolean valid는 audience에 orders-api 포함, exp 존재 및 현재보다 뒤, 비어 있지 않은 subject를 &&로 묶습니다. 모두 참이어야 합니다.", + "return valid ? ...는 조건을 만족하면 성공을, 아니면 invalid_token 오류를 돌려줍니다. 토큰 내용 읽기에 성공해도 이 결과는 실패일 수 있습니다.", + "setJwtValidator의 DelegatingOAuth2TokenValidator는 time, JwtIssuerValidator(ISSUER), required를 함께 적용합니다. return decoder로 돌려준 객체가 토큰을 읽을 때 이 정책을 검사합니다." + ], + "prediction": { + "question": "서명·issuer·subject·시간이 모두 맞고 audience가 another-api뿐인 토큰은 통과할까요?", + "answer": "거절됩니다. required의 orders-api 포함 조건이 false입니다.", + "explanation": "&&로 묶인 조건 중 하나라도 거짓이면 required가 invalid_token을 반환합니다. 서명 성공은 이 토큰이 현재 주문 API를 대상으로 발급됐다는 뜻까지 보장하지 않습니다." + }, + "takeaway": "서명 검사와 토큰 수용 조건을 모두 읽어야, 어떤 토큰을 왜 거절하는지 설명할 수 있습니다." + }, "theory": [ { "heading": "서명과 수용 정책", - "text": "JWT 서명 검증은 허용된 키와 알고리즘으로 무결성을 확인하는 절차입니다. 같은 키로 발급된 토큰이라도 다른 API 대상이거나 다른 issuer, 만료된 토큰이면 수용하면 안 됩니다. claim을 디코딩하는 것만으로 검증이 완료되지 않습니다." + "text": "JWT의 claim은 토큰 안에 담긴 이름·값 정보입니다. 내용을 읽을 수 있다는 것만으로 발급자를 믿을 수는 없습니다. decoder의 NimbusJwtDecoder.withPublicKey(publicKey)는 주어진 RSA 공개키로 서명을 확인하며 이 설정의 기본 허용 알고리즘은 RS256입니다. 서명이 맞아도 required의 audience에 orders-api가 없거나 JwtIssuerValidator가 보는 issuer가 다르면 거절합니다. 서명은 내용의 무결성을, 추가 조건은 이 주문 API가 받아들일 토큰인지를 확인합니다." }, { "heading": "시간과 테스트 재현성", - "text": "시간 기반 검사는 Clock을 주입해 경계를 재현합니다. 실제 운영에서는 clock skew 정책과 시스템 시간 동기화를 고려해야 하지만 이 과제는 정확한 비교를 위해 허용 오차를 0으로 고정합니다. exp가 없으면 시간 검증이 충분하다고 가정하지 않고 필수 claim을 별도로 검사합니다." + "text": "exp는 만료 시각, nbf는 사용을 시작할 수 있는 시각입니다. JwtTimestampValidator는 시간 조건을 확인하고 setClock(clock)은 비교할 현재 시각을 외부에서 받게 합니다. required의 getExpiresAt().isAfter(clock.instant())는 exp가 반드시 존재하고 현재보다 뒤여야 한다는 이 실습의 정책입니다. 그래서 만료 시각과 현재가 정확히 같아도 거절합니다. Duration.ZERO로 시간 오차 허용을 0으로 두어 경계를 재현하며, 실제 서비스의 서버 시간 차이 허용과 시간 동기화 정책은 따로 정합니다." }, { "heading": "키 수명과 로그", - "text": "테스트는 실행 중 생성한 RSA 키를 사용하며 외부 키 서버를 호출하지 않습니다. 운영 키 배포·회전·폐기·토큰 재사용 대응을 보장하지 않습니다. 토큰 원문과 개인 claim을 로그에 남기는 대신 거절 유형을 최소한으로 관측합니다." + "text": "테스트는 실행 중 만든 RSA 키 쌍으로 서명하고 그 공개키를 decoder에 줍니다. 따라서 다른 키로 서명한 토큰을 거절하는 검사는 가능하지만 외부 키 서버에서 키를 내려받거나 운영 키를 교체·폐기하는 과정은 포함하지 않습니다. 토큰 재사용 대응도 별도 문제입니다. 운영 관측이 필요할 때는 토큰 원문이나 개인정보 claim을 그대로 기록하기보다 서명 오류·만료·대상 불일치 같은 필요한 거절 유형을 남기는 정책을 검토합니다." } ], "example": { "title": "JwtLab 기준 구현", "code": "package edu.lab;\nimport java.security.interfaces.RSAPublicKey;\nimport java.time.Clock;\nimport java.time.Duration;\nimport org.springframework.security.oauth2.core.*;\nimport org.springframework.security.oauth2.jwt.*;\npublic final class JwtLab {\n public static final String ISSUER = \"https://training-issuer.invalid\";\n public static JwtDecoder decoder(RSAPublicKey publicKey, Clock clock) {\n // BEGIN EXERCISE\n NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();\n JwtTimestampValidator time = new JwtTimestampValidator(Duration.ZERO); time.setClock(clock);\n OAuth2TokenValidator required = jwt -> {\n boolean valid = jwt.getAudience() != null && jwt.getAudience().contains(\"orders-api\")\n && jwt.getExpiresAt() != null && jwt.getExpiresAt().isAfter(clock.instant())\n && jwt.getSubject() != null && !jwt.getSubject().isBlank();\n return valid ? OAuth2TokenValidatorResult.success()\n : OAuth2TokenValidatorResult.failure(new OAuth2Error(\"invalid_token\",\"required claims missing or wrong\",null));\n };\n decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(time, new JwtIssuerValidator(ISSUER), required));\n return decoder;\n // END EXERCISE\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 5개 JUnit 테스트 통과.\n검증 항목: 정상 토큰 수용 · issuer·audience 불일치 거절 · 만료·미래 nbf 거절 · 다른 키 서명 거절 · 필수 claim 누락 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "decoder는 공개키로 서명을 확인할 NimbusJwtDecoder를 만들고, time에 고정 가능한 Clock을 넣습니다. required는 audience에 orders-api가 있는지, exp가 현재보다 뒤인지, subject가 비어 있지 않은지 검사합니다. 마지막 setJwtValidator는 time·issuer·required를 모두 연결하므로 하나라도 실패하면 수용하지 않습니다. 테스트는 정상 토큰 외에 다른 키·발급자·대상, 만료·미래 nbf, 누락 claim과 정확한 만료 경계를 검사합니다. 외부 키 서버나 운영 키 교체는 실행하지 않습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", "files": [ { "path": "src/main/java/edu/lab/JwtLab.java", @@ -60,7 +99,7 @@ "Base64 디코딩에 성공하면 수용한다." ], "correctIndex": 1, - "explanation": "토큰 수용은 무결성 외에 현재 API의 발급자·대상·시간 정책을 모두 충족해야 합니다." + "explanation": "required의 getAudience().contains(\"orders-api\")는 이 토큰을 주문 API가 받아도 되는지 묻는 조건입니다. 서명이 유효해도 audience가 another-api이면 valid가 false가 됩니다. subject 존재나 Base64 내용 읽기는 이 조건을 대신하지 않습니다." }, { "id": "jwt-validation-q2", @@ -72,20 +111,20 @@ "만료를 충분히 미래로 두고 실패 검사를 생략한다." ], "correctIndex": 1, - "explanation": "Clock 주입은 만료·not-before 경계를 실제 시간 경과와 분리해 재현합니다." + "explanation": "JwtLab.decoder는 Clock을 인수로 받고 time.setClock(clock)과 exp 비교에서 그 시각을 씁니다. 테스트가 Clock.fixed를 주면 오늘 날짜가 바뀌어도 같은 경계를 검사할 수 있습니다. 미래 만료값만 테스트하면 만료·미래 nbf·정확한 만료 경계의 실패를 놓칩니다." } ], "practice": { "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "서명 성공과 토큰 수용을 분리하고 발급자·대상·시간·필수 claim을 독립적으로 거절합니다.", + "scenario": "같은 발급자가 만든 토큰인데 다른 API용 토큰이 주문 API에 들어왔습니다. 서명 오류와 대상 불일치를 구분해서 거절하고, 만료 시각의 경계도 재현 가능한 검사로 남깁니다.", "tasks": [ - "RS256과 주어진 RSA 공개키로 서명을 검증합니다.", - "issuer=https://training-issuer.invalid, audience=orders-api, nonblank subject, exp가 모두 필요합니다.", - "만료·미래 nbf·잘못된 issuer·audience·서명은 거절합니다.", - "Clock을 외부에서 받아 고정 시간으로 테스트하며 실제 개인키를 파일에 저장하지 않습니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "withPublicKey가 담당하는 서명 검사와 setJwtValidator의 세 검증기가 담당하는 내용 검사를 나누어 설명하세요. 이 설정은 RS256을 사용합니다.", + "issuer=https://training-issuer.invalid, audience에 orders-api 포함, 비어 있지 않은 subject, 현재보다 뒤인 exp가 필요한 줄을 찾으세요.", + "다른 키·issuer·audience, 만료·미래 nbf, 필수 값 누락을 한 조건씩 바꿔 보는 이유를 적으세요.", + "Clock을 고정했을 때 exp가 현재와 같거나 1초 뒤인 경우의 차이를 설명하세요. 실제 개인키를 파일에 저장하지 않습니다.", + "이 과제의 실행 중 생성한 로컬 키 검사와 실제 키 서버·키 교체·폐기·토큰 재사용 대응에서 남은 검증을 구분하세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "토큰의 서명을 확인한 뒤에도 누구에게, 언제까지, 어떤 주체에 대해 발급된 것인지 우리 API의 조건으로 검사합니다." }, "sources": [ { diff --git a/curriculum/professional/lessons/kafka-idempotency/lesson.json b/curriculum/professional/lessons/kafka-idempotency/lesson.json index 09de21b..a5b1531 100644 --- a/curriculum/professional/lessons/kafka-idempotency/lesson.json +++ b/curriculum/professional/lessons/kafka-idempotency/lesson.json @@ -1,7 +1,7 @@ { "id": "kafka-idempotency", "title": "Kafka 재전달과 DB 멱등 처리", - "description": "Kafka 전송과 DB 반영을 분리하고 같은 이벤트의 중복 처리 및 커밋 사이의 장애 구간을 설명합니다.", + "description": "같은 이벤트가 두 번 오면 주문 처리 횟수도 두 번 늘어날까요? 처리한 eventId 기록과 주문 변경을 하나의 DB 작업으로 묶어 중복 적용을 막는 코드를 읽습니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,31 +10,70 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "메시지를 다시 받더라도 DB 효과를 한 번만 적용하려면?", - "중복 이벤트 효과 한 번", - "DB 실패 시 이벤트 기록 롤백", - "다른 payload 중복 ID 거절", - "Kafka 전송 후 수동 오프셋 커밋" + "같은 이벤트를 다시 받아도 DB 효과를 반복하지 않는 멱등 처리를 설명한다.", + "첫 handle은 true, 같은 eventId·orderId의 재호출은 false가 되는 경로를 따라간다.", + "없는 주문 처리 실패가 processed_events 기록까지 되돌려야 하는 이유를 설명한다.", + "같은 eventId에 다른 orderId가 오면 왜 거절하는지 설명한다.", + "DB 확정과 Kafka 읽은 위치 확정 사이의 장애 가능성 및 실제 연동 미검증 범위를 구분한다." ], + "learningGuide": { + "scenario": "주문 A의 handled_count가 0일 때 evt-1이 도착합니다. 처리 후 같은 evt-1과 A가 다시 전달됩니다. 두 번째 전달을 새 작업으로 보면 처리 횟수가 2가 됩니다.", + "question": "이 메서드는 두 번째 전달을 어디서 알아보고, 어떤 값을 반환할까요?", + "terms": [ + { + "term": "멱등 처리", + "meaning": "같은 의미의 요청을 다시 받아도 업무 효과를 더 만들지 않는 처리입니다. 여기서는 handled_count를 한 번만 올립니다." + }, + { + "term": "eventId / payload", + "meaning": "eventId는 이벤트 식별자이고 payload는 그 이벤트의 내용입니다. 이 예제에서 비교할 내용은 orderId입니다." + }, + { + "term": "커밋 / 롤백", + "meaning": "commit은 연결의 DB 변경을 확정하고 rollback은 아직 확정하지 않은 변경을 되돌립니다. 처리 기록과 주문 변경에 함께 적용합니다." + }, + { + "term": "오프셋", + "meaning": "Kafka에서 소비자가 읽은 위치입니다. 이 메서드의 DB 커밋과는 별도로 소비자가 확정합니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/EventHandler.java", + "source": " c.setAutoCommit(false);\n try {\n try (PreparedStatement s = c.prepareStatement(\"insert into processed_events(event_id,order_id) values(?,?)\")) {\n s.setString(1,eventId); s.setString(2,orderId); s.executeUpdate();\n }\n try (PreparedStatement s = c.prepareStatement(\"update orders set handled_count=handled_count+1 where id=?\")) {\n s.setString(1,orderId); if (s.executeUpdate() != 1) throw new SQLException(\"unknown order\",\"02000\");\n }\n c.commit(); return true;\n } catch (SQLException failure) {\n try { c.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); throw failure; }\n if (!\"23505\".equals(failure.getSQLState())) throw failure;\n try (PreparedStatement s = c.prepareStatement(\"select order_id from processed_events where event_id=?\")) {\n s.setString(1,eventId);\n try (ResultSet result = s.executeQuery()) {\n if (result.next() && orderId.equals(result.getString(1))) return false;\n throw new IllegalArgumentException(\"same eventId with different payload\",failure);" + }, + "walkthrough": [ + "c.setAutoCommit(false)는 아래 SQL을 매번 따로 확정하지 않게 합니다. 첫 INSERT가 processed_events에 eventId와 orderId를 기록합니다.", + "다음 UPDATE가 해당 주문의 handled_count를 1 올립니다. 바뀐 행 수가 1이 아니면 unknown order 예외를 던져 확정 단계로 가지 못하게 합니다.", + "두 SQL이 성공하면 c.commit() 뒤 true를 반환합니다. 예외가 나면 catch에서 c.rollback()으로 이번 변경을 되돌립니다.", + "학습 DB의 중복 키 SQLState인 23505만 중복 처리 분기로 보냅니다. 다른 DB 오류는 그대로 호출자에게 전달합니다.", + "SELECT로 기존 eventId의 order_id를 읽고 전달받은 orderId와 같으면 false를 반환합니다. 다르면 같은 ID의 다른 내용이므로 예외를 던집니다. 중복 분기에는 handled_count를 다시 올리는 SQL이 없습니다." + ], + "prediction": { + "question": "초기 처리 기록이 없고 주문 A의 handled_count가 0일 때, 정상 DB에서 handle(\"evt-1\",\"A\")를 순서대로 두 번 호출하면 어떻게 될까요?", + "answer": "첫 호출은 true, 두 번째는 false이며 handled_count는 1입니다.", + "explanation": "첫 호출이 기록과 증가를 함께 확정합니다. 두 번째 INSERT는 eventId 중복으로 실패하지만 기존 order_id가 A와 같으므로 이미 처리한 내용으로 판단합니다. 이 DB 동작 예측이 실제 Kafka 장애 복구 검증을 뜻하지는 않습니다." + }, + "takeaway": "같은 ID라는 사실과 같은 내용이라는 사실을 함께 확인해야 중복을 안전하게 건너뛸 수 있습니다." + }, "theory": [ { "heading": "전달 횟수와 업무 효과", - "text": "메시지가 한 번 보였다는 사실과 업무 상태가 한 번 변경됐다는 사실은 다릅니다. 이 과제는 같은 eventId를 두 메시지로 전송하여 재전달 가능성을 모델링합니다. 브로커 장애나 프로세스 강제 종료 복구를 직접 재현한 검사는 아닙니다." + "text": "소비자가 이벤트를 처리하고 응답을 남기기 전에 멈추면 같은 내용을 다시 받을 수 있습니다. 전달받은 횟수와 주문 상태를 바꾼 횟수를 분리해야 하는 이유입니다. 멱등 처리는 같은 의미의 이벤트를 반복해도 업무 효과를 추가로 만들지 않는 처리입니다. 이 실습은 eventId가 같은 메시지 두 건을 보내 재전달 가능성을 모델링하도록 작성되어 있습니다. 브로커 장애나 프로세스 강제 종료 후 복구를 직접 재현한 검사는 아니며, 실제 Kafka 연동도 별도 환경에서 확인해야 합니다." }, { "heading": "DB 멱등 처리", - "text": "처리한 eventId의 UNIQUE 기록과 업무 UPDATE를 같은 DB 트랜잭션에 둡니다. 중복 이벤트는 이전 payload와 일치하는지 확인한 뒤 효과를 반복하지 않습니다. 같은 ID의 다른 payload는 조용히 무시하지 않고 계약 위반으로 거절합니다." + "text": "EventHandler.handle은 c.setAutoCommit(false)로 SQL마다 자동 확정하는 동작을 끄고, processed_events에 eventId·orderId를 넣은 뒤 orders.handled_count를 1 늘립니다. 둘 다 성공해야 c.commit()으로 함께 확정합니다. 실패하면 c.rollback()으로 이번 변경을 되돌립니다. 학습 H2에서 eventId 중복을 나타내는 SQLState 23505라면 기존 order_id를 조회합니다. 같은 주문이면 이미 반영된 이벤트라 false를 반환하고, 다른 주문이면 같은 ID의 의미가 바뀐 것이므로 거절합니다." }, { "heading": "오프셋과 DB 사이", - "text": "DB 커밋 뒤 오프셋을 커밋하면 두 커밋 사이 장애에서 중복 전달이 가능하므로 DB 멱등 처리가 필요합니다. Kafka 트랜잭션만으로 임의의 외부 DB까지 자동으로 exactly-once가 되는 것은 아닙니다. outbox·재시도·독성 메시지 처리 정책은 후속 설계 비교 대상입니다." + "text": "오프셋은 Kafka 파티션에서 어디까지 읽었는지 나타내는 위치입니다. 테스트 소스는 자동 오프셋 커밋을 끄고 DB 처리 뒤 consumer.commitSync()로 읽은 위치를 확정합니다. DB의 c.commit() 후 이 호출 전에 멈추면 DB 반영은 끝났어도 같은 메시지를 다시 받을 수 있습니다. 그래서 DB 쪽 eventId 검사가 필요합니다. Kafka 트랜잭션만 켠다고 임의의 외부 DB까지 자동으로 한 번만 반영되는 것은 아닙니다. 이벤트 발행 기록을 DB와 함께 남기는 outbox, 제한된 재시도, 반복 실패 메시지의 격리 정책은 후속 설계 대상입니다." } ], "example": { "title": "EventHandler 기준 구현", "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\npublic final class EventHandler {\n private final DataSource source;\n public EventHandler(DataSource source) { this.source = java.util.Objects.requireNonNull(source); }\n public boolean handle(String eventId, String orderId) throws SQLException {\n // BEGIN EXERCISE\n if (eventId == null || eventId.isBlank() || orderId == null || orderId.isBlank()) throw new IllegalArgumentException(\"event identity\");\n try (Connection c = source.getConnection()) {\n c.setAutoCommit(false);\n try {\n try (PreparedStatement s = c.prepareStatement(\"insert into processed_events(event_id,order_id) values(?,?)\")) {\n s.setString(1,eventId); s.setString(2,orderId); s.executeUpdate();\n }\n try (PreparedStatement s = c.prepareStatement(\"update orders set handled_count=handled_count+1 where id=?\")) {\n s.setString(1,orderId); if (s.executeUpdate() != 1) throw new SQLException(\"unknown order\",\"02000\");\n }\n c.commit(); return true;\n } catch (SQLException failure) {\n try { c.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); throw failure; }\n if (!\"23505\".equals(failure.getSQLState())) throw failure;\n try (PreparedStatement s = c.prepareStatement(\"select order_id from processed_events where event_id=?\")) {\n s.setString(1,eventId);\n try (ResultSet result = s.executeQuery()) {\n if (result.next() && orderId.equals(result.getString(1))) return false;\n throw new IllegalArgumentException(\"same eventId with different payload\",failure);\n }\n }\n }\n }\n // END EXERCISE\n }\n}\n", "output": "예상 검증 항목: 중복 이벤트 효과 한 번 · DB 실패 시 이벤트 기록 롤백 · 다른 payload 중복 ID 거절 · Kafka 전송 후 수동 오프셋 커밋. 아직 실행하지 않았습니다.", - "explanation": "기준 구현과 테스트 소스는 공개 학습 자료입니다. 실제 실행·의존성 해석·설치형 통합 검증 결과는 별도 증거로 표시합니다.", + "explanation": "handle은 eventId와 orderId를 확인한 뒤 같은 JDBC 연결에서 처리 기록 INSERT와 주문 횟수 UPDATE를 수행합니다. 주문이 없으면 UPDATE 수가 1이 아니므로 예외를 던지고 INSERT까지 롤백합니다. 이미 처리한 eventId라면 중복 INSERT 실패 뒤 기존 order_id를 읽어 같은 주문일 때만 false를 반환합니다. 따라서 true는 이번에 새로 반영했다는 뜻이고 false는 같은 내용을 앞서 반영했다는 뜻입니다. Kafka 오프셋 커밋은 이 메서드 밖의 테스트 소비자가 담당합니다. 실제 Kafka 연동은 현재 미검증이며 기준 구현과 테스트 소스는 공개 학습 자료입니다. 실제 서비스 연결·설치형 통합 검증은 별도 증거가 필요합니다.", "files": [ { "path": "src/main/java/edu/lab/EventHandler.java", @@ -64,7 +103,7 @@ "같은 메시지가 다시 전달될 수 있어 DB 멱등 처리가 필요하다." ], "correctIndex": 3, - "explanation": "DB와 오프셋은 별도의 커밋입니다. 중간 장애로 다시 받은 메시지가 DB 효과를 반복하지 않도록 설계해야 합니다." + "explanation": "c.commit()과 consumer.commitSync()는 서로 다른 시스템에 남기는 확정 기록입니다. DB만 확정한 채 멈추면 소비자는 마지막으로 확정된 오프셋부터 다시 읽을 수 있습니다. 같은 이벤트의 processed_events 기록을 확인해 handled_count를 또 올리지 않아야 합니다." }, { "id": "kafka-idempotency-q2", @@ -76,20 +115,20 @@ "eventId를 새로 만들어 조용히 재처리한다." ], "correctIndex": 2, - "explanation": "멱등 키가 같다는 이유만으로 의미가 다른 요청을 동일 요청으로 수용해서는 안 됩니다." + "explanation": "eventId가 같아도 orderId가 A에서 B로 바뀌면 같은 업무 요청이 아닙니다. handle의 중복 분기는 기존 order_id와 전달받은 orderId를 비교하고 다르면 IllegalArgumentException을 던집니다. 중복 ID라는 이유로 다른 주문까지 처리하거나 성공으로 숨기지 않습니다." } ], "practice": { "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "Kafka 전송과 DB 반영을 분리하고 같은 이벤트의 중복 처리 및 커밋 사이의 장애 구간을 설명합니다.", + "scenario": "evt-1을 처리해 주문 A의 처리 횟수를 늘렸지만, 소비자가 Kafka의 읽은 위치를 남기기 전에 종료됐다고 가정합니다. 재시작 뒤 evt-1을 다시 받아도 DB 효과를 반복하지 않아야 합니다.", "tasks": [ - "handle(eventId, orderId)는 첫 정상 적용에 true, 동일 payload의 중복 적용에는 false를 반환합니다.", - "processed_events 기록과 orders.handled_count 증가는 같은 DB 트랜잭션입니다.", - "없는 주문이나 같은 eventId의 다른 orderId는 거절하고 부분 변경을 남기지 않습니다.", - "실제 전송 검사는 전용 loopback Kafka와 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS가 필요하며 자원 미준비 시 실행되지 않습니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "handle(\"evt-1\",\"A\")의 첫 true와 두 번째 false가 각각 어떤 분기에서 반환되는지 설명하세요.", + "processed_events INSERT와 handled_count UPDATE 중 하나만 확정되면 어떤 문제가 생기는지, setAutoCommit(false)·commit·rollback과 연결하세요.", + "없는 주문이면 방금 추가한 처리 기록도 사라져야 하는 이유와, 같은 eventId에 다른 orderId가 오면 거절해야 하는 이유를 구분하세요.", + "실제 전송 검사에는 전용 loopback Kafka와 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS가 필요합니다. 현재 실제 연동은 미검증이며 자원 미준비를 검사 통과로 보지 않습니다.", + "DB 커밋 뒤 오프셋 커밋 전 장애와 반복 실패 메시지의 처리 정책을 적으세요. 같은 메시지 두 건 전송은 브로커 장애 복구 검증과 다릅니다." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "중복을 알아보는 기록과 실제 업무 변경을 같은 DB 트랜잭션에 둡니다. Kafka에 읽은 위치를 남기는 단계까지 포함해 장애 구간을 설명해야 합니다." }, "sources": [ { diff --git a/curriculum/professional/lessons/lombok-values/lesson.json b/curriculum/professional/lessons/lombok-values/lesson.json index 9f4b166..0272fbe 100644 --- a/curriculum/professional/lessons/lombok-values/lesson.json +++ b/curriculum/professional/lessons/lombok-values/lesson.json @@ -1,7 +1,7 @@ { "id": "lombok-values", "title": "Lombok 생성 코드와 동등성의 범위", - "description": "생성 코드가 만드는 동등성·생성자 계약을 해석합니다.", + "description": "HashSet에 넣은 주문 키의 방문 횟수를 바꿔도 다시 찾을 수 있어야 합니다. Lombok이 생성하는 equals·hashCode가 어떤 필드를 사용하는지 읽습니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,26 +10,26 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "어떤 필드가 해시 기반 동등성에 참여해야 하는가?", - "같은 ID 동등성", - "가변 표시 값 제외", - "HashSet 조회 유지" + "Lombok이 생성할 생성자·getter·equals·hashCode를 애너테이션과 연결해 설명한다.", + "서로 다른 OrderKey라도 orderId가 같으면 같은 키로 판단하는 이유를 설명한다.", + "visit()로 바뀌는 displayVisits를 동등성에서 제외한다.", + "방문 횟수가 변한 뒤에도 HashSet 조회가 유지되는지 확인한다." ], "theory": [ { "heading": "애너테이션 처리 결과 읽기", - "text": "Lombok은 컴파일 시 생성 코드를 제공하는 도구입니다. 생성자·접근자·동등성 메서드를 직접 작성한 것과 비교하고 원하는 계약을 먼저 정합니다." + "text": "OrderKey에는 생성자와 getOrderId가 직접 적혀 있지 않습니다. @RequiredArgsConstructor가 final orderId를 받는 생성자를 만들고 @Getter가 필드를 읽는 메서드를 만듭니다. orderId의 @NonNull은 생성자에 null이 들어오는 것을 거절하게 합니다. 다만 null 검사이므로 빈 문자열까지 주문 ID로 적합한지는 이 애너테이션이 결정하지 않습니다.\n\nequals는 두 객체를 같은 값으로 볼지, hashCode는 해시 기반 컬렉션이 찾을 위치를 정할 때 사용할 값을 제공합니다. @EqualsAndHashCode(onlyExplicitlyIncluded = true)는 표시한 필드만 이 두 메서드에 참여시키라는 설정이고, 여기서는 orderId에만 Include가 붙어 있습니다. 코드를 줄이기 전에 “방문 횟수가 달라도 ID가 같으면 같은 키”라는 규칙을 먼저 정한 것입니다." }, { "heading": "가변 필드와 엔티티의 함정", - "text": "방문 횟수 같은 가변 표시 값까지 equals/hashCode에 포함하면 HashSet에 넣은 뒤 조회가 깨질 수 있습니다. 이 수업은 안정적인 식별자를 가진 값 객체이며 JPA 엔티티 전체에 @Data를 붙이라는 예제가 아닙니다. 엔티티 ID 할당·프록시·연관관계는 JPA 단원에서 별도로 검토합니다." + "text": "HashSet은 객체를 넣을 때의 해시값을 이용해 저장하고, 찾을 때도 해시값과 equals를 사용합니다. set.add(key) 뒤 key.visit()를 했을 때 displayVisits가 hashCode 계산에 들어 있다면 찾을 기준이 달라져 같은 객체도 못 찾는 문제가 생길 수 있습니다. 이 코드의 hashCode는 final String orderId에만 의존하므로 visit() 뒤에도 기준이 같습니다.\n\n이 예제는 주문을 식별하는 작은 키 객체이며 객체 전체가 불변인 것은 아닙니다. displayVisits는 계속 바뀝니다. 이를 JPA 엔티티에 모든 필드를 포함하는 @Data를 붙여도 된다는 규칙으로 옮기지 않습니다. DB 저장 때 ID가 생기거나 연관 객체를 따라가야 하는 엔티티는 동등성·출력·접근자 생성의 영향을 별도로 검토해야 합니다." } ], "example": { "title": "OrderKey 기준 구현", "code": "package edu.lab;\nimport lombok.Getter;\nimport lombok.NonNull;\nimport lombok.RequiredArgsConstructor;\nimport lombok.EqualsAndHashCode;\n@Getter\n@RequiredArgsConstructor\n@EqualsAndHashCode(onlyExplicitlyIncluded = true)\npublic final class OrderKey {\n @NonNull @EqualsAndHashCode.Include private final String orderId;\n private int displayVisits;\n public void visit() { displayVisits++; }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 같은 ID 동등성 · 가변 표시 값 제외 · HashSet 조회 유지.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "OrderKey(\"o-1\")을 만들면 orderId가 고정되고 displayVisits는 0에서 시작합니다. visit()는 displayVisits만 1 늘립니다. Include는 orderId에만 붙어 있으므로 방문 횟수가 다른 두 OrderKey(\"o-1\")도 equals가 같다고 판단하고 hashCode도 같습니다. 그래서 기존 테스트는 visit() 뒤 원래 key와 새 OrderKey(\"o-1\")을 모두 HashSet에서 찾는지 확인합니다. @NonNull이 생성자의 null 입력을 거절하는지도 별도 테스트합니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", "files": [ { "path": "src/main/java/edu/lab/OrderKey.java", @@ -54,7 +54,7 @@ "HashSet은 항상 객체 참조만 비교하기 때문이다." ], "correctIndex": 0, - "explanation": "HashSet은 저장된 해시와 equals를 사용합니다. 가변 방문 횟수가 해시에 포함되면 저장 뒤 같은 객체를 찾지 못할 수 있습니다." + "explanation": "visit()는 사용자가 주문 화면을 몇 번 봤는지를 바꿉니다. 같은 주문인지 판단하는 orderId는 그대로입니다. displayVisits까지 hashCode에 넣으면 Set에 저장할 때와 찾을 때의 해시 기준이 달라질 수 있으므로 이 필드는 제외합니다." }, { "id": "lombok-values-q2", @@ -66,19 +66,19 @@ "필드가 private인지 여부만" ], "correctIndex": 0, - "explanation": "생성된 메서드도 실제 객체 모델의 일부입니다. 엔티티의 동등성·toString·setter가 업무와 ORM 제약을 지키는지 확인해야 합니다." + "explanation": "엔티티는 저장 전후 ID가 달라질 수 있고 다른 엔티티와 연결될 수 있습니다. 자동 생성한 equals·hashCode·toString이 그 필드를 읽거나 바꾸는 동작도 실제 코드입니다. 이 수업의 고정 orderId 키에서 안전한 선택이 모든 엔티티에서도 맞는지 식별자 수명과 연관관계를 살펴야 합니다." } ], "practice": { - "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "생성 코드가 만드는 동등성·생성자 계약을 해석합니다.", + "title": "방문 횟수가 바뀌어도 찾을 수 있는 주문 키", + "scenario": "최근 본 주문을 HashSet으로 관리합니다. 사용자가 다시 보았다고 visit()를 호출한 뒤에도 같은 주문 키를 찾고 중복으로 넣지 않으려 합니다.", "tasks": [ - "OrderKey의 동등성과 hashCode는 불변 orderId만 사용하게 하세요.", - "visit()로 displayVisits가 바뀌어도 HashSet에서 키를 찾을 수 있어야 합니다.", - "Lombok @EqualsAndHashCode(onlyExplicitlyIncluded=true)와 Include의 생성 의미를 설명하세요.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "OrderKey의 equals와 hashCode에 불변 orderId만 참여하게 하세요.", + "HashSet에 넣은 key에 visit()를 호출한 뒤 key와 새 OrderKey(\"o-1\")을 찾을 수 있는지 기존 테스트에서 확인하세요.", + "onlyExplicitlyIncluded=true와 orderId의 Include를 보고 displayVisits가 왜 제외되는지 설명하세요.", + "OrderKey(null)은 거절되지만 빈 ID의 유효성은 별도 규칙이 필요하다는 점, JPA 엔티티의 ID 수명까지 이 테스트로 검증한 것은 아니라는 점을 적으세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "자동 생성된 메서드도 어떤 값을 읽는지 직접 작성한 코드처럼 검토합니다. 컬렉션에 들어간 키는 조회 기준이 흔들리지 않아야 하므로 표시용 가변 값과 식별 값을 구분합니다." }, "sources": [ { @@ -160,5 +160,44 @@ "starterSatisfied": false, "sourceFingerprint": "65bcae5900047877a822e89821af9b5ac4a511c1b6a634d002757f4cee5efa9a", "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "주문 키를 HashSet에 넣고 화면을 한 번 열어 visit()를 호출했습니다. 방문 횟수가 변했다는 이유로 이미 넣은 주문을 다시 못 찾으면 중복 확인이 깨집니다.", + "question": "OrderKey에서 어떤 필드가 바뀌어도 같은 주문 키라는 판단은 유지되어야 할까요?", + "terms": [ + { + "term": "동등성", + "meaning": "서로 다른 객체를 같은 값으로 판단하는 규칙입니다. 여기서는 orderId가 같으면 같은 OrderKey입니다." + }, + { + "term": "hashCode", + "meaning": "HashSet이 찾을 후보를 좁힐 때 사용하는 정수값입니다. 같은 키로 판단되는 두 객체는 같은 hashCode를 내야 합니다." + }, + { + "term": "생성 코드", + "meaning": "컴파일할 때 Lombok이 애너테이션을 보고 추가하는 메서드입니다. OrderKey의 생성자·getter·equals·hashCode가 해당합니다." + }, + { + "term": "불변 식별 값", + "meaning": "객체가 만들어진 뒤 바뀌지 않는 구별 기준입니다. final String orderId는 고정되어 있고 displayVisits는 visit()마다 바뀝니다." + } + ], + "walkthrough": [ + "@RequiredArgsConstructor는 final orderId를 받는 생성자를 만듭니다. @NonNull 때문에 null ID는 생성할 때 거절됩니다.", + "@EqualsAndHashCode의 onlyExplicitlyIncluded=true가 비교 대상을 명시한 필드로 제한합니다. Include가 있는 필드는 orderId 하나입니다.", + "HashSet에 OrderKey(\"o-1\")을 넣으면 그 키의 hashCode가 저장 위치를 찾는 데 쓰입니다.", + "visit()는 displayVisits만 증가시킵니다. equals와 hashCode의 입력인 orderId에는 손대지 않습니다.", + "그래서 방문 뒤에도 원래 key와 새 OrderKey(\"o-1\")을 같은 키로 찾습니다. 기존 stableIdentity 테스트는 OrderKey(\"o-2\")와는 다른 값인지도 확인합니다." + ], + "prediction": { + "question": "OrderKey(\"o-1\")을 HashSet에 넣고 visit()를 한 번 호출한 뒤 새 OrderKey(\"o-1\")을 조회하면 찾을 수 있나요?", + "answer": "찾을 수 있습니다. 기존 key의 displayVisits는 1이지만 새 키와 orderId가 같습니다.", + "explanation": "displayVisits에는 Include가 없어서 equals와 hashCode 계산에 참여하지 않습니다. 방문 횟수 차이가 있어도 조회 기준인 orderId는 같습니다." + }, + "takeaway": "“모든 필드를 비교한다”보다 “무엇이 같아야 같은 키인가”를 먼저 정합니다. 자동 생성 도구의 설정은 그 결정을 코드로 드러내는 방법입니다.", + "code": { + "file": "src/main/java/edu/lab/OrderKey.java", + "source": "@Getter\n@RequiredArgsConstructor\n@EqualsAndHashCode(onlyExplicitlyIncluded = true)\npublic final class OrderKey {\n @NonNull @EqualsAndHashCode.Include private final String orderId;\n private int displayVisits;\n public void visit() { displayVisits++; }\n}\n" + } } } diff --git a/curriculum/professional/lessons/mvc-rest/lesson.json b/curriculum/professional/lessons/mvc-rest/lesson.json index d7ce2b4..b53e223 100644 --- a/curriculum/professional/lessons/mvc-rest/lesson.json +++ b/curriculum/professional/lessons/mvc-rest/lesson.json @@ -1,7 +1,7 @@ { "id": "mvc-rest", "title": "MVC·REST의 상태 코드와 자원 계약", - "description": "주문 조회를 HTTP 자원으로 표현하고 성공·부재·읽기 요청의 상태 불변성을 분리합니다.", + "description": "GET /orders/A-1이 어떤 메서드로 들어가 200과 JSON이 되는지 따라갑니다. 없는 주문의 404 응답과 반복 조회에서 주문 수량이 그대로인 이유도 확인합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,30 +10,30 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "컨트롤러의 반환값과 HTTP 계약은 어디에서 만나는가?", - "기존 주문 조회 200", - "부재 주문 404", - "반복 조회 상태 보존" + "URL의 A-1이 find의 id로 전달되고 Summary가 JSON 응답이 되는 순서를 설명한다.", + "존재하는 A-1 주문은 200과 id·quantity를 반환하게 한다.", + "없는 주문은 404로 표현하고 서버 오류와 구분한다.", + "조회 세 번 뒤에도 수량 2를 유지하는지 확인한다." ], "theory": [ { "heading": "전송 계약과 업무 계약", - "text": "HTTP 메서드·상태 코드·표현 형식은 클라이언트가 관찰하는 계약입니다. 조회 성공은 200과 JSON, 주문 부재는 404로 표현합니다. GET은 업무 상태를 변경하지 않도록 설계합니다." + "text": "클라이언트는 자바 반환 객체를 직접 보지 못하고 HTTP 상태 코드와 응답 본문을 봅니다. 이것이 전송 계약, 즉 요청을 보낸 쪽과 주고받기로 한 형식입니다. GET /orders/A-1은 클래스의 /orders와 메서드의 /{id}에 연결되고 @PathVariable이 A-1을 id 인수로 전달합니다. find는 orders.get(id)의 결과를 보고 있으면 ResponseEntity.ok(order), 없으면 notFound().build()를 반환합니다.\n\n업무 계약은 주문을 읽을 때 기존 수량을 바꾸지 않는다는 규칙입니다. 이 코드에는 Map을 읽는 get만 있으며 수량을 줄이는 동작이 없습니다. @GetMapping을 붙였다고 이런 규칙이 자동으로 지켜지는 것은 아닙니다. 메서드 본문과 반복 호출 결과를 함께 확인해야 합니다." }, { "heading": "컨트롤러의 책임", - "text": "라우팅과 응답 변환은 어댑터의 책임입니다. 가격 계산·재고 예약 같은 업무 규칙까지 컨트롤러에 쌓으면 HTTP 없이 검증하기 어렵습니다. 이 예제의 고정 Map은 조회 계약을 좁혀 보여 주는 학습용 저장소입니다." + "text": "컨트롤러는 HTTP 요청을 자바 호출로 연결하고 결과를 응답으로 표현하는 역할을 합니다. 이 예제에서 @RestController와 응답 변환기가 Summary의 id·quantity를 JSON으로 전달합니다. 가격 계산이나 재고 예약까지 이곳에 섞으면 HTTP 처리와 주문 규칙을 각각 테스트하기 어려워집니다.\n\norders는 A-1 한 건을 담은 고정 Map입니다. 데이터베이스 조회나 저장을 구현한 것이 아니라 “존재하면 200, 없으면 404”라는 응답 선택을 보여 줍니다. DB 실패도 null로 바꿔 404를 주는 구현을 이 코드에서 도출하면 안 됩니다. 주문 부재와 저장소 장애는 클라이언트가 다르게 처리해야 할 상황입니다." }, { "heading": "MockMvc의 경계", - "text": "MockMvc는 실제 서버 소켓 없이 Spring MVC 요청 처리를 검증합니다. JSON 직렬화와 매핑 테스트를 작성할 수 있지만 실제 네트워크·TLS·프록시·서블릿 컨테이너 설정의 종단 검증을 대신하지 않습니다." + "text": "기존 ContractTest는 standaloneSetup(new OrdersController())로 MockMvc를 준비합니다. MockMvc는 실제 서버 포트를 열지 않고 요청·응답 객체를 통해 Spring MVC 처리를 실행하는 검사 도구입니다. 단순히 find(\"A-1\")을 직접 호출하는 것과 달리 URL 연결, 상태 코드, JSON의 id·quantity까지 확인할 수 있습니다.\n\n반복 GET 검사는 세 번 모두 quantity가 2인지 봅니다. 그러나 실제 TLS 인증서·역방향 프록시·서블릿 서버 설정은 이 경로에 없습니다. 이 검사가 통과했다는 말과 배포된 URL에 실제로 접속해 확인했다는 말을 구분합니다." } ], "example": { "title": "OrdersController 기준 구현", "code": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class OrdersController {\n public record Summary(String id, int quantity) {}\n private final Map orders = Map.of(\"A-1\", new Summary(\"A-1\", 2));\n @GetMapping(\"/{id}\")\n public ResponseEntity find(@PathVariable(\"id\") String id) {\n // BEGIN EXERCISE\n Summary order = orders.get(id);\n return order == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(order);\n // END EXERCISE\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 3개 JUnit 테스트 통과.\n검증 항목: 기존 주문 조회 200 · 부재 주문 404 · 반복 조회 상태 보존.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "orders에는 \"A-1\"을 키로 Summary(\"A-1\", 2)가 들어 있습니다. GET /orders/A-1의 id가 find로 전달되면 orders.get이 그 객체를 찾고 ResponseEntity.ok가 200 응답을 선택합니다. MVC의 JSON 변환을 거쳐 id는 A-1, quantity는 2로 보입니다. /orders/missing은 조회 결과가 null이므로 404를 선택합니다. find는 Map을 바꾸지 않아 같은 조회를 반복해도 수량 2가 유지됩니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", "files": [ { "path": "src/main/java/edu/lab/OrdersController.java", @@ -58,7 +58,7 @@ "자원 부재를 404로 표현한다." ], "correctIndex": 3, - "explanation": "이 수업의 계약은 자원이 존재하는 성공과 존재하지 않는 상태를 구분합니다. 500은 서버 내부 실패의 의미와 혼동하게 합니다." + "explanation": "orders.get(\"missing\")이 null이라는 것은 이 저장소에 해당 주문이 없다는 뜻입니다. 이 수업은 그 경우를 404로 약속했습니다. 200의 빈 객체는 정상 주문과 부재를 구분하기 어렵게 하고, 500은 정상적인 부재를 서버 장애처럼 보이게 합니다." }, { "id": "mvc-rest-q2", @@ -70,19 +70,19 @@ "부재 주문의 상태 코드" ], "correctIndex": 2, - "explanation": "MockMvc는 모의 요청·응답으로 MVC 처리를 검사하며 실제 네트워크 구간을 거치지 않습니다." + "explanation": "MockMvc는 실제 OrdersController를 MVC 처리 과정으로 호출해 경로·상태 코드·JSON 필드를 검사합니다. 실제 서버 소켓을 열지 않으므로 TLS 연결과 외부 네트워크 프록시 설정은 그 검사에 참여하지 않습니다." } ], "practice": { - "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "주문 조회를 HTTP 자원으로 표현하고 성공·부재·읽기 요청의 상태 불변성을 분리합니다.", + "title": "주문 조회의 성공·부재·반복을 구분하기", + "scenario": "주문 상세 화면은 A-1이면 수량 2를 보여 주고, 없는 주문이면 부재 안내를 보여 줘야 합니다. 새로고침을 여러 번 해도 재고나 주문 수량을 소비하면 안 됩니다.", "tasks": [ - "GET /orders/A-1은 id A-1, quantity 2인 JSON과 200을 반환합니다.", - "존재하지 않는 ID는 빈 주문이나 null의 200 대신 404를 반환합니다.", - "반복 GET은 데이터를 변경하지 않습니다. 새 주문 생성은 이 수업 범위에 없습니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "GET /orders/A-1에서 id A-1, quantity 2인 JSON과 200을 반환하는 분기를 완성하세요.", + "orders에 없는 ID는 404를 반환하세요. 저장소 장애를 404로 취급하는 것과 이 코드의 단순 부재를 구분하세요.", + "반복 GET 세 번의 quantity가 모두 2인 기존 테스트를 읽고, find 안에서 상태를 바꾸는 코드가 없는지 확인하세요. 새 주문 생성은 이 수업 범위에 없습니다.", + "find를 직접 호출하는 검사, MockMvc 검사, 배포된 URL 호출이 각각 무엇을 거치는지 설명하세요. 여기서 실행된 기준 검사는 MockMvc 범위입니다." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "조회 API를 읽을 때 URL → 인수 → 조회값 → 상태 코드 → JSON 순서로 따라갑니다. 성공 응답뿐 아니라 부재의 표현과 반복 호출 뒤 상태도 함께 확인합니다." }, "sources": [ { @@ -164,5 +164,44 @@ "starterSatisfied": false, "sourceFingerprint": "cf818104cfb16da2cfc56a8a887429f7354bfbaf23b2edb5db949abe45065bdf", "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "주문 상세 화면을 새로고침할 때 같은 주문이 계속 보이고, 존재하지 않는 ID에는 분명한 부재 응답이 필요합니다. 고정된 A-1 주문으로 이 흐름을 좁혀 살펴봅니다.", + "question": "orders.get(id)의 값 하나가 어떻게 200 또는 404 응답으로 이어질까요?", + "terms": [ + { + "term": "라우팅", + "meaning": "요청 경로와 처리 메서드를 연결하는 일입니다. /orders와 /{id}가 합쳐져 GET /orders/A-1을 find로 보냅니다." + }, + { + "term": "경로 변수", + "meaning": "URL의 일부를 메서드 인수로 받는 값입니다. @PathVariable(\"id\")는 A-1을 String id에 전달합니다." + }, + { + "term": "ResponseEntity", + "meaning": "응답 본문과 상태 코드 등을 정하는 객체입니다. 여기서는 ok(order)가 200과 주문을, notFound().build()가 본문 없는 404를 만듭니다." + }, + { + "term": "직렬화", + "meaning": "자바 객체를 전송할 표현으로 바꾸는 일입니다. 이 MVC 설정은 Summary의 id·quantity를 JSON 필드로 보냅니다." + } + ], + "walkthrough": [ + "@RequestMapping(\"/orders\")와 @GetMapping(\"/{id}\")가 GET /orders/A-1을 find 메서드에 연결합니다.", + "@PathVariable(\"id\")가 URL의 A-1을 id에 전달합니다. orders.get(id)는 저장된 Summary(\"A-1\", 2)를 찾습니다.", + "order가 null이 아니므로 ResponseEntity.ok(order)를 반환합니다. MVC가 이 본문을 JSON으로 바꿔 200과 함께 전달합니다.", + "id가 missing이면 orders.get의 결과가 null입니다. 삼항식의 다른 분기가 본문 없는 404를 선택합니다.", + "어느 분기에도 orders나 quantity를 바꾸는 코드가 없습니다. 기존 반복 조회 테스트는 A-1을 세 번 읽어도 quantity가 2인지 확인합니다." + ], + "prediction": { + "question": "같은 컨트롤러에 GET /orders/A-1을 세 번 보내고 GET /orders/missing을 한 번 보내면 어떤 결과인가요?", + "answer": "A-1 세 번은 모두 200과 quantity 2이며, missing은 404입니다.", + "explanation": "find는 저장된 값을 조회해 응답을 고를 뿐 값을 변경하지 않습니다. 고정 Map에 missing 키가 없으므로 마지막 요청은 부재 분기로 갑니다." + }, + "takeaway": "HTTP 응답을 이해하려면 애너테이션부터 반환 객체까지 연결해 읽습니다. 메서드 호출 결과와 클라이언트가 보는 상태 코드·JSON은 MVC가 이어 줍니다.", + "code": { + "file": "src/main/java/edu/lab/OrdersController.java", + "source": "@RestController\n@RequestMapping(\"/orders\")\npublic class OrdersController {\n public record Summary(String id, int quantity) {}\n private final Map orders = Map.of(\"A-1\", new Summary(\"A-1\", 2));\n @GetMapping(\"/{id}\")\n public ResponseEntity find(@PathVariable(\"id\") String id) {\n // BEGIN EXERCISE\n Summary order = orders.get(id);\n return order == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(order);\n // END EXERCISE\n }\n}\n" + } } } diff --git a/curriculum/professional/lessons/mybatis-mapping/lesson.json b/curriculum/professional/lessons/mybatis-mapping/lesson.json index 6814906..df2d247 100644 --- a/curriculum/professional/lessons/mybatis-mapping/lesson.json +++ b/curriculum/professional/lessons/mybatis-mapping/lesson.json @@ -1,7 +1,7 @@ { "id": "mybatis-mapping", "title": "MyBatis 파라미터 바인딩과 XML 매핑", - "description": "같은 SQL을 명시적으로 유지하면서 XML 매핑·값 바인딩·세션 수명을 연결합니다.", + "description": "findBySku(\"BOOK\")이 XML의 #{sku}로 전달되고 조회 열이 OrderRow에 채워지는 과정을 봅니다. SQL처럼 생긴 입력도 쿼리 조건을 바꾸지 않고 하나의 값으로 처리해야 합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,30 +10,30 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "SQL을 직접 소유할 때 프레임워크가 맡는 책임은 무엇인가?", - "명시적 열·속성 매핑", - "정확한 SKU 조회", - "SQL 조각 입력을 값으로 취급" + "Mapper 메서드·XML namespace·select id가 연결되는 위치를 찾는다.", + "order_id라는 조회 열이 OrderRow.id에 들어가는 명시적 매핑을 설명한다.", + "BOOK 입력이 SKU가 BOOK인 행만 조회하는 순서를 설명한다.", + "SQL 조각처럼 보이는 입력을 문자열 값으로 바인딩하고 테스트 데이터에서 빈 결과가 되는 이유를 설명한다." ], "theory": [ { "heading": "SQL 소유권과 매핑", - "text": "MyBatis는 SQL과 결과 객체 사이의 연결을 명시적으로 구성합니다. 쿼리 계획·인덱스·행 수·트랜잭션 책임은 사라지지 않습니다. resultMap은 열 이름과 Java 속성의 관계를 드러냅니다." + "text": "매핑은 이름이 다른 두 대상을 연결하는 규칙입니다. Mapper.findBySku의 호출은 XML의 namespace=\"edu.lab.MyBatisLab$Mapper\"와 select id=\"findBySku\"에 연결됩니다. SELECT는 id를 order_id라는 이름으로 읽고, resultMap은 그 order_id를 자바 OrderRow의 id 속성에 넣습니다. SQL에 별칭을 쓴 뒤 자바 필드 이름까지 자동으로 같아질 것이라 기대하지 않고 이 연결을 명시합니다.\n\nMyBatis는 값 전달과 결과 객체 채우기를 도와주지만 어떤 SQL을 실행할지는 개발자가 작성합니다. 반환 행이 지나치게 많거나 WHERE 조건에 맞는 인덱스가 없다면 매핑을 사용했다는 사실로 해결되지 않습니다. 쿼리와 실제 DB 데이터 규모·통계·실행계획을 확인하는 책임은 남습니다." }, { "heading": "#{}와 ${}의 차이", - "text": "이 과제의 #{sku}는 PreparedStatement 값 바인딩을 사용합니다. 문자열 치환을 사용하는 ${}에 요청 값을 전달하면 SQL 구문이 바뀔 수 있습니다. 동적 정렬 필드가 필요하면 서버의 허용 목록으로 별도 선택합니다." + "text": "Mapper의 @Param(\"sku\")가 인수 이름을 정하고 XML의 #{sku}가 그 값을 PreparedStatement의 파라미터로 전달합니다. findBySku(\"BOOK\")의 SQL 구조는 그대로이고 비교할 값만 BOOK이 됩니다. 기존 테스트의 \"' OR '1'='1\"도 하나의 sku 값입니다. 준비된 BOOK·PEN 행 중 그 문자열과 같은 SKU가 없어서 빈 목록이 나옵니다. SQL 같은 문자열을 무조건 금지하는 기능이 아니라, 값이 SQL 문법으로 해석되지 않게 하는 방식입니다.\n\n문자열 치환 방식으로 요청값을 SQL 안에 직접 넣으면 따옴표나 조건식이 쿼리의 일부가 될 수 있습니다. 정렬 열처럼 SQL 구조를 고르는 요구는 값 바인딩과 다르므로 서버가 허용한 열·방향 중에서 선택해야 합니다." }, { "heading": "대안 학습 경로", - "text": "JDBC를 공통 선수로 삼고 MyBatis와 JPA를 비교합니다. SQL 중심 조회·복잡한 집계와 객체 상태 변경 중심 흐름은 서로 다른 비용이 있습니다. 두 기술을 연속 필수 단계로 취급하거나 한 도구가 다른 도구의 상위 버전이라고 설명하지 않습니다." + "text": "이 예제의 factory는 XML을 읽어 SqlSessionFactory를 만들고, 테스트의 호출자가 openSession으로 작업할 세션을 엽니다. SqlSession은 SQL 실행과 매퍼 호출을 담당하는 작업 단위이며 호출자는 try-with-resources로 닫습니다. 연결을 계속 점유한 채 남기지 않으려는 수명 관리입니다.\n\nJDBC는 SQL 실행·값 바인딩·행 읽기를 직접 작성하는 공통 출발점입니다. MyBatis는 직접 작성한 SQL과 객체의 연결을 도와주고, JPA는 관리 중인 객체의 상태 변화와 DB 반영을 연결합니다. 복잡한 조회 SQL을 세밀하게 제어할지, 객체의 변경 흐름을 중심에 둘지에 따라 살필 비용이 다릅니다. 어느 쪽을 쓰든 트랜잭션을 어디서 시작하고 끝낼지는 정해야 합니다." } ], "example": { "title": "MyBatisLab 기준 구현", "code": "package edu.lab;\nimport java.io.InputStream;\nimport java.util.List;\nimport javax.sql.DataSource;\nimport org.apache.ibatis.annotations.Param;\nimport org.apache.ibatis.builder.xml.XMLMapperBuilder;\nimport org.apache.ibatis.mapping.Environment;\nimport org.apache.ibatis.session.*;\nimport org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;\npublic final class MyBatisLab {\n public static class OrderRow {\n private String id; private String sku; private int quantity;\n public String getId() { return id; } public void setId(String id) { this.id = id; }\n public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; }\n public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity = quantity; }\n }\n public interface Mapper { List findBySku(@Param(\"sku\") String sku); }\n public static SqlSessionFactory factory(DataSource source) {\n Configuration config = new Configuration(new Environment(\"training\", new JdbcTransactionFactory(), source));\n try (InputStream xml = MyBatisLab.class.getResourceAsStream(\"/edu/lab/OrderMapper.xml\")) {\n if (xml == null) throw new IllegalStateException(\"mapper resource missing\");\n new XMLMapperBuilder(xml, config, \"edu/lab/OrderMapper.xml\", config.getSqlFragments()).parse();\n return new SqlSessionFactoryBuilder().build(config);\n } catch (java.io.IOException failure) { throw new IllegalStateException(failure); }\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 명시적 열·속성 매핑 · 정확한 SKU 조회 · SQL 조각 입력을 값으로 취급.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "MyBatisLab.factory는 /edu/lab/OrderMapper.xml을 읽고 세션 팩터리를 만듭니다. 테스트에서 얻은 Mapper의 findBySku(\"BOOK\") 호출은 XML의 같은 select id로 연결됩니다. #{sku}는 BOOK 값을 바인딩하고, SELECT의 order_id 별칭은 resultMap을 통해 OrderRow.id에 들어갑니다. 현재 테스트 데이터는 A·BOOK·2와 B·PEN·3이라 BOOK 조회는 A 한 행을 돌려줍니다. SQL 조각처럼 보이는 입력도 값으로 전달되며, 현재 데이터에 같은 SKU가 없어 빈 목록입니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", "files": [ { "path": "src/main/java/edu/lab/MyBatisLab.java", @@ -63,7 +63,7 @@ "입력 길이를 줄이면 어떤 치환도 안전하다." ], "correctIndex": 2, - "explanation": "#{}는 값을 SQL 구조와 분리해 바인딩합니다. 문자열 치환은 SQL 문법 자체를 바꿀 수 있어 요청 값을 직접 넣으면 안 됩니다." + "explanation": "#{sku}는 @Param(\"sku\")의 값을 PreparedStatement에 전달합니다. 예를 들어 \"' OR '1'='1\"을 넣어도 WHERE의 비교값 하나일 뿐 조건식을 추가하지 않습니다. 문자열을 직접 SQL에 치환하면 값과 문법이 섞이므로 요청값을 그대로 넣는 방법은 피해야 합니다." }, { "id": "mybatis-mapping-q2", @@ -75,20 +75,20 @@ "위 세 책임 중 어느 것도 자동으로 사라지지 않는다." ], "correctIndex": 3, - "explanation": "SQL 매핑 도구는 성능·정합성·트랜잭션 설계를 대신 결정하지 않습니다." + "explanation": "resultMap이 order_id를 id에 채워 줘도 WHERE가 읽는 행 수나 ORDER BY의 정렬 비용은 그대로 존재합니다. 또 여러 변경을 함께 확정할 트랜잭션 구간도 정해야 합니다. MyBatis 사용만으로 인덱스·트랜잭션·행 수 검토가 자동 완료되는 것은 아닙니다." } ], "practice": { - "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "같은 SQL을 명시적으로 유지하면서 XML 매핑·값 바인딩·세션 수명을 연결합니다.", + "title": "SKU 입력에서 결과 객체까지 연결하기", + "scenario": "주문 목록을 SKU로 검색합니다. BOOK에는 A 주문 한 건만 보여야 하고, SQL 조건처럼 보이는 문자열이 입력돼도 전체 주문을 돌려줘서는 안 됩니다.", "tasks": [ - "XML namespace와 Mapper 인터페이스의 메서드를 연결하세요.", - "findBySku는 sku가 정확히 일치하는 행만 반환하며 #{sku}로 바인딩합니다.", - "알 수 없는 sku와 SQL 조각처럼 생긴 입력은 빈 목록을 반환합니다.", - "SqlSession은 호출자가 try-with-resources로 닫습니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "XML namespace와 Mapper 인터페이스를 연결하고 select id를 findBySku와 맞추세요.", + "findBySku의 @Param(\"sku\")가 #{sku}에 전달되게 하고, order_id 열을 OrderRow.id에 연결하세요.", + "테스트 데이터 A·BOOK·2와 B·PEN·3에서 BOOK은 A 한 행, MISSING과 SQL 조각 모양의 입력은 빈 목록인 이유를 설명하세요.", + "호출자가 SqlSession을 try-with-resources로 닫는 위치를 확인하세요. 값을 조회한 뒤 세션을 계속 열어 둘 필요가 있는지 설명하세요.", + "검색 데이터가 커지면 어떤 WHERE·ORDER BY와 인덱스·행 수를 확인할지 적으세요. 이 H2 소량 데이터 검사는 운영 DB 성능이나 다른 쿼리의 바인딩 안전성까지 증명하지 않습니다." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "매퍼를 읽을 때 자바 인수 → 바인딩 이름 → SQL 조건 → 결과 열 → 객체 속성을 한 번 연결해 봅니다. SQL과 입력값을 구분하고 쿼리 설계·세션 수명은 계속 직접 검토합니다." }, "sources": [ { @@ -177,5 +177,44 @@ "starterSatisfied": false, "sourceFingerprint": "127b6b7a7bdcf465e0b3b55b32791f14bef3accc1f246aa163497b27102bb36c", "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "A 주문은 BOOK 2개, B 주문은 PEN 3개입니다. BOOK을 검색하면 A만 나와야 하고, 입력이 SQL처럼 생겼다고 두 주문이 모두 나오면 안 됩니다.", + "question": "findBySku의 String 인수는 어디서 SQL 값이 되고 결과의 order_id는 어떻게 자바 id가 될까요?", + "terms": [ + { + "term": "매퍼(mapper)", + "meaning": "자바 메서드와 SQL을 연결하는 구성입니다. Mapper.findBySku와 XML의 findBySku가 같은 작업을 가리킵니다." + }, + { + "term": "값 바인딩", + "meaning": "SQL의 형태를 유지한 채 값만 따로 전달하는 일입니다. #{sku}는 @Param(\"sku\")로 받은 문자열을 비교값으로 넣습니다." + }, + { + "term": "resultMap", + "meaning": "조회 결과의 열을 객체의 어느 속성에 넣을지 적은 규칙입니다. order_id 열이 OrderRow.id로 연결됩니다." + }, + { + "term": "SqlSession", + "meaning": "매퍼를 얻고 SQL 작업을 수행하는 세션입니다. 이 예제에서는 호출자가 열고 작업 후 닫습니다." + } + ], + "walkthrough": [ + "MyBatisLab.factory가 이 XML을 읽어 등록합니다. namespace의 MyBatisLab$Mapper는 자바 중첩 Mapper 인터페이스를 가리킵니다.", + "호출자가 세션에서 Mapper를 얻고 findBySku(\"BOOK\")을 호출하면 select id=\"findBySku\"의 SQL이 선택됩니다.", + "자바 @Param(\"sku\")로 이름 붙인 BOOK이 #{sku}에 바인딩됩니다. WHERE sku = ?라는 비교 구조를 입력 문자열로 다시 만들지 않습니다.", + "SELECT의 id AS order_id가 만든 열을 resultMap의 id property=\"id\"가 OrderRow.id에 연결합니다. sku와 quantity도 각각 같은 이름의 속성으로 옮깁니다.", + "테스트 데이터에서는 BOOK인 A 한 행만 반환되고 quantity는 2입니다. 호출자는 결과를 확인한 뒤 try-with-resources로 SqlSession을 닫습니다." + ], + "prediction": { + "question": "현재 테스트 데이터에서 findBySku(\"' OR '1'='1\")를 부르면 모든 주문이 나올까요?", + "answer": "아닙니다. 빈 목록이 나옵니다.", + "explanation": "#{sku}는 문자열 전체를 하나의 비교값으로 바인딩합니다. BOOK·PEN 중 그 문자열과 같은 SKU가 없어 결과가 없습니다. SQL 조각을 지워서가 아니라 SQL 구조를 바꾸지 않기 때문입니다." + }, + "takeaway": "MyBatis가 줄여 주는 코드는 값 전달과 결과 매핑입니다. 입력 문자열이 SQL 문법이 되지 않는 이유와 각 열이 어느 필드로 가는지는 XML과 인터페이스를 함께 읽어 확인합니다.", + "code": { + "file": "src/main/resources/edu/lab/OrderMapper.xml", + "source": "\n \n \n \n \n\n" + } } } diff --git a/curriculum/professional/lessons/observability/lesson.json b/curriculum/professional/lessons/observability/lesson.json index e39dab8..b51a46a 100644 --- a/curriculum/professional/lessons/observability/lesson.json +++ b/curriculum/professional/lessons/observability/lesson.json @@ -1,7 +1,7 @@ { "id": "observability", "title": "Micrometer 지표와 관측 실패 신호", - "description": "성공과 실패의 수·시간을 관측하고 지표 태그에 주문별 값을 넣는 비용을 확인합니다.", + "description": "실패한 주문이 로그에만 남고 처리 시간 집계에서 빠지면 장애를 놓칠 수 있습니다. work.run의 성공·실패를 세고 시간을 남기면서 원래 예외도 전달하는 코드를 읽습니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,31 +10,70 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "장애를 설명할 수 있는 신호를 어떤 비용으로 남길 것인가?", - "성공·실패 계수", - "성공·실패 타이머 기록", - "예외 보존과 Observation 종료", - "주문 ID 태그 제외" + "work.run이 성공하거나 실패할 때 어떤 관측 기록을 남기는지 설명한다.", + "outcome이 success에서 failure로 바뀌는 지점을 찾는다.", + "finally에서 호출 수·처리 시간을 기록하고 Observation을 끝내는 이유를 설명한다.", + "관측을 추가해도 원래 업무 예외를 호출자에게 전달해야 하는 이유를 설명한다.", + "주문별 ID 대신 값이 제한된 outcome 태그를 쓰는 이유를 설명한다." ], + "learningGuide": { + "scenario": "주문 A 처리는 성공하고 B 처리는 IllegalStateException을 던졌습니다. 둘 다 얼마나 걸렸는지 남기면서 B의 호출자에게는 실패를 그대로 알려야 합니다.", + "question": "catch에서 예외를 다시 던지는데도 실패한 요청의 시간을 기록할 수 있을까요?", + "terms": [ + { + "term": "Counter / Timer", + "meaning": "Counter는 횟수를 누적하고 Timer는 실행 시간과 기록 수를 모읍니다. 이 예제는 calls와 duration에 각각 사용합니다." + }, + { + "term": "태그", + "meaning": "지표를 나누는 이름·값입니다. outcome=success 또는 outcome=failure로 결과를 구분합니다." + }, + { + "term": "Observation", + "meaning": "관측할 구간의 시작·오류·종료를 관련 처리기에 알리는 객체입니다. 실제 원격 수집기까지 자동 설치하는 것은 아닙니다." + }, + { + "term": "finally", + "meaning": "try가 정상 종료되거나 catch가 예외를 다시 던져도 실행되는 정리 구간입니다. 여기서 시간 기록과 관측 종료를 합니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/OrderTelemetry.java", + "source": " Timer.Sample sample = Timer.start(meters); String outcome=\"success\";\n Observation observation = Observation.start(\"orders.reserve\", observations);\n try (Observation.Scope ignored = observation.openScope()) { work.run(); }\n catch (RuntimeException | Error failure) { outcome=\"failure\"; observation.error(failure); throw failure; }\n finally {\n meters.counter(\"orders.reserve.calls\",\"outcome\",outcome).increment();\n sample.stop(Timer.builder(\"orders.reserve.duration\").tag(\"outcome\",outcome).register(meters));\n observation.stop();" + }, + "walkthrough": [ + "Timer.start(meters)로 시간을 재기 시작하고 outcome을 success로 둡니다. Observation.start는 orders.reserve라는 관측 구간을 시작합니다.", + "try의 observation.openScope() 안에서 work.run()을 실행합니다. 업무가 정상이면 outcome은 success로 남습니다.", + "업무가 RuntimeException이나 Error를 던지면 catch가 outcome을 failure로 바꾸고 observation.error로 알립니다. throw failure는 원래 예외를 호출자에게 보내기 위한 줄입니다.", + "그 뒤 finally의 counter(...).increment()가 최종 outcome의 호출 수를 늘리고 sample.stop(...)이 같은 outcome으로 경과 시간을 기록합니다.", + "observation.stop()으로 관측을 끝냅니다. 태그를 만드는 두 줄 모두 orderId를 넣지 않고 outcome만 사용합니다." + ], + "prediction": { + "question": "새 메모리 레지스트리에서 성공 한 번과 업무 예외 한 번을 실행하고 관측 도구는 정상 동작했다면, 실패 Counter와 Timer의 기록 수는 얼마일까요?", + "answer": "failure Counter는 1이고 failure Timer의 기록 수도 1입니다. 업무 예외도 호출자에게 전달됩니다.", + "explanation": "catch가 failure로 표시한 뒤 예외를 던져도 finally가 실행됩니다. 그래서 실패 횟수와 시간 기록이 남습니다. 기록 수 1은 특정 밀리초가 측정됐다는 고정 결과나 성능 개선 수치를 뜻하지 않습니다." + }, + "takeaway": "업무 실패를 보이게 만드는 관측 코드가 그 실패를 숨기거나 바꾸지 않는지 확인합니다." + }, "theory": [ { "heading": "로그·지표·추적의 질문", - "text": "지표는 변화와 비율, 로그는 사건의 구체적 문맥, 추적은 요청의 경로를 설명하는 데 사용합니다. 이 예제는 Micrometer의 메모리 레지스트리와 Observation 콜백을 검사하며 원격 수집기·대시보드·분산 추적 배포를 구성하지 않습니다." + "text": "주문 처리가 느려졌다는 문의에는 시간이 필요하고, 얼마나 자주 실패했는지에는 횟수가 필요합니다. 지표는 이렇게 수와 변화를 모아 보는 데이터이며, 로그는 개별 사건의 문맥, 추적은 요청이 거친 경로를 보는 데 쓰입니다. OrderTelemetry는 Counter로 호출 수, Timer로 처리 시간을 기록합니다. Observation은 관측 구간의 시작·오류·종료를 알리는 객체입니다. 이번 검사는 메모리 안의 저장소와 콜백을 사용하므로 원격 수집기·대시보드·분산 추적 배포를 구성한 것은 아닙니다." }, { "heading": "태그의 개수 비용", - "text": "orderId·이메일처럼 값의 수가 늘어나는 정보를 지표 태그에 넣으면 시계열 수가 계속 증가합니다. outcome처럼 제한된 집합을 태그로 사용하고 상세 식별자는 별도 접근 통제와 보존 정책이 있는 로그·추적에서 검토합니다." + "text": "태그는 같은 지표를 조건별로 나누는 이름·값입니다. 이 코드의 outcome 태그는 success 또는 failure여서 결과별 호출 수를 볼 수 있습니다. 매 주문의 orderId를 붙이면 주문마다 별도 시계열, 즉 시간에 따른 별도 값의 묶음이 생겨 수집·저장·조회할 대상이 계속 늘어납니다. run은 orderId를 인수로 받지만 지표 태그에 넣지 않습니다. 이메일 같은 개인 식별값도 이런 태그로 쓰지 않습니다. 상세 식별자가 필요한 로그·추적에는 접근 권한과 보존 정책을 따로 정합니다." }, { "heading": "실패도 끝까지 기록", - "text": "업무 예외를 삼키면 호출자의 재시도·트랜잭션 계약을 바꿉니다. finally에서 시간을 기록하고 예외를 원래대로 전파합니다. 관측 자체의 실패가 업무에 어떤 영향을 줄지도 운영 설계에서 따로 판단합니다." + "text": "try 안의 work.run이 예외를 던지면 catch가 outcome을 failure로 바꾸고 observation.error에 원인을 알린 뒤 같은 failure를 다시 던집니다. finally는 성공·실패 두 경로에서 호출 수와 시간을 기록하고 observation.stop을 호출합니다. 예외를 삼키고 정상 반환하면 상위 호출자가 성공으로 오해해 재시도나 DB 변경 취소인 롤백을 하지 않을 수 있습니다. 다만 관측 도구 자체가 예외를 던질 때 원래 업무에 미칠 영향은 별도 설계 대상이며, 이번 메모리 검사만으로 그 장애 대응까지 보장하지 않습니다." } ], "example": { "title": "OrderTelemetry 기준 구현", "code": "package edu.lab;\nimport java.util.Objects;\nimport io.micrometer.core.instrument.*;\nimport io.micrometer.observation.*;\npublic final class OrderTelemetry {\n private final MeterRegistry meters; private final ObservationRegistry observations;\n public OrderTelemetry(MeterRegistry meters, ObservationRegistry observations) { this.meters=Objects.requireNonNull(meters); this.observations=Objects.requireNonNull(observations); }\n public void run(String orderId, Runnable work) {\n // BEGIN EXERCISE\n Objects.requireNonNull(orderId); Objects.requireNonNull(work);\n Timer.Sample sample = Timer.start(meters); String outcome=\"success\";\n Observation observation = Observation.start(\"orders.reserve\", observations);\n try (Observation.Scope ignored = observation.openScope()) { work.run(); }\n catch (RuntimeException | Error failure) { outcome=\"failure\"; observation.error(failure); throw failure; }\n finally {\n meters.counter(\"orders.reserve.calls\",\"outcome\",outcome).increment();\n sample.stop(Timer.builder(\"orders.reserve.duration\").tag(\"outcome\",outcome).register(meters));\n observation.stop();\n }\n // END EXERCISE\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 1개 JUnit 테스트 통과.\n검증 항목: 성공·실패 계수 · 성공·실패 타이머 기록 · 예외 보존과 Observation 종료 · 주문 ID 태그 제외.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "유효한 인수로 run에 들어오면 Timer.Sample이 시작되고 outcome은 success입니다. work.run에서 RuntimeException 또는 Error가 나면 catch가 failure로 바꾼 뒤 같은 예외를 다시 던집니다. finally는 그 outcome으로 calls를 1 늘리고 duration에 시간을 남긴 다음 Observation을 종료합니다. 기준 테스트는 성공 한 번·실패 한 번 후 각 Counter와 Timer의 기록 수, 종료 두 번과 오류 한 번, 원래 예외 객체 보존을 검사합니다. 실제 처리 속도의 개선이나 원격 수집 성공을 측정하는 테스트는 아닙니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", "files": [ { "path": "src/main/java/edu/lab/OrderTelemetry.java", @@ -59,7 +98,7 @@ "트랜잭션 격리 수준이 자동 변경된다." ], "correctIndex": 0, - "explanation": "지표의 태그 조합은 서로 다른 시계열이 됩니다. 주문마다 새 값을 만들면 데이터 양과 운영 비용이 증가합니다." + "explanation": "outcome=success와 outcome=failure는 제한된 두 값입니다. 여기에 주문마다 다른 orderId까지 넣으면 태그 조합이 계속 늘어 별도 지표를 수집·저장해야 합니다. 타입 오류나 트랜잭션 변경이 아니라 관측 데이터의 수와 운영 비용 문제입니다." }, { "id": "observability-q2", @@ -71,20 +110,20 @@ "Observation이 자동으로 원래 예외를 다시 던진다." ], "correctIndex": 0, - "explanation": "관측은 원래 업무 계약을 유지하도록 설계해야 합니다. 예외를 숨기면 호출자가 성공으로 오해할 수 있습니다." + "explanation": "catch에서 failure를 기록하고 return하면 호출자는 성공으로 볼 수 있습니다. 이 예제는 observation.error(failure) 뒤 throw failure를 실행하므로 관측 도구가 정상 동작하는 테스트 조건에서 원래 실패를 보존합니다. Observation이 대신 예외를 던져 줄 것이라고 가정하지 않습니다." } ], "practice": { "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "성공과 실패의 수·시간을 관측하고 지표 태그에 주문별 값을 넣는 비용을 확인합니다.", + "scenario": "주문 장애가 났는데 성공한 요청의 처리 시간만 남아 원인을 찾기 어렵습니다. 실패도 집계하되, 실패를 기록하는 코드 때문에 호출자가 성공으로 오해하지 않도록 흐름을 점검합니다.", "tasks": [ - "성공과 실패 각각 calls Counter와 duration Timer를 outcome 태그로 기록합니다.", - "업무 예외는 바꾸거나 삼키지 않고 그대로 전달합니다.", - "Observation은 성공·실패 모두 종료하며 실패 원인을 error로 알립니다.", - "orderId를 지표 태그에 넣지 않습니다. 원격 exporter나 Actuator 운영 공개는 이 과제 범위가 아닙니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "성공한 work와 예외를 던진 work에서 outcome이 각각 어떤 값으로 finally에 도착하는지 따라가세요.", + "calls Counter와 duration Timer가 성공·실패 각각 한 번 기록되는 테스트 조건을 설명하세요. Timer의 기록 수와 실제 시간 수치를 구분하세요.", + "observation.error(failure), throw failure, observation.stop()의 역할을 나누어 설명하세요.", + "orderId를 인수로 받아도 지표 태그에 넣지 않은 이유를 적으세요. 원격 exporter와 Actuator의 운영 공개는 이번 검사 범위가 아닙니다.", + "지표 저장 자체가 실패하는 경우 원래 업무 예외와 결과에 미칠 영향을 별도 미검증 사항으로 적으세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "실패도 수와 시간으로 남기되 원래 실패는 호출자에게 전달합니다. 남길 데이터의 종류와 값의 가짓수도 함께 설계합니다." }, "sources": [ { diff --git a/curriculum/professional/lessons/oidc-identity/lesson.json b/curriculum/professional/lessons/oidc-identity/lesson.json index f3655e5..d1976be 100644 --- a/curriculum/professional/lessons/oidc-identity/lesson.json +++ b/curriculum/professional/lessons/oidc-identity/lesson.json @@ -1,7 +1,7 @@ { "id": "oidc-identity", "title": "OAuth2·OIDC의 역할과 계정 식별 경계", - "description": "OAuth2 권한 위임과 OIDC 로그인 신원을 구분하고 issuer·subject로 계정의 경계를 고정합니다.", + "description": "이메일이 같은 두 외부 로그인 사용자를 한 계정으로 합치면 안 되는 이유를 살펴봅니다. 이미 인증된 사용자 정보에서 신뢰한 발급자와 사용자 ID를 골라 반환합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,31 +10,69 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "외부 로그인 사용자를 어떤 식별자로 내부 계정에 연결하는가?", - "미인증 401", - "신뢰한 issuer·subject 전달", - "비신뢰 issuer 403", - "이메일로 신원 병합하지 않음" + "외부 로그인 사용자의 이메일 대신 issuer와 subject를 함께 식별에 쓰는 이유를 설명한다.", + "GET /me의 미인증 401과 비신뢰 발급자 403을 구분한다.", + "principal의 발급자 검사 뒤 subject를 읽어 Identity를 만드는 순서를 따라간다.", + "같은 이메일이어도 subject가 다른 두 요청이 서로 다른 식별값을 갖는지 설명한다.", + "oidcLogin 테스트가 로그인 전체 과정을 실행하지 않는다는 한계를 설명한다." ], + "learningGuide": { + "scenario": "테스트에 같은 이메일을 넣은 subject-A와 subject-B가 있습니다. 서비스는 두 요청을 같은 사용자로 합치지 않고 각각의 식별값을 돌려주어야 합니다.", + "question": "이 코드에서 사용자를 구분하는 값은 이메일일까요, 다른 두 값일까요?", + "terms": [ + { + "term": "principal", + "meaning": "인증 후 애플리케이션에 전달되는 요청자의 정보입니다. 여기서는 OidcUser 타입의 principal입니다." + }, + { + "term": "issuer", + "meaning": "사용자 정보를 발급한 주체입니다. getIssuer()의 값을 이 실습이 허용한 TRUSTED_ISSUER와 비교합니다." + }, + { + "term": "subject", + "meaning": "해당 발급자 안에서 사용자를 구분하는 값입니다. getSubject()로 읽으며 발급자와 함께 사용합니다." + }, + { + "term": "fixture", + "meaning": "테스트에서 상황을 만들기 위해 넣는 가상 설정이나 데이터입니다. oidcLogin()은 실제 로그인 절차 대신 인증된 주체를 넣습니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/IdentityLab.java", + "source": " @GetMapping(\"/me\") public Identity me(@AuthenticationPrincipal OidcUser principal) {\n // BEGIN EXERCISE\n if (principal.getIssuer() == null || !TRUSTED_ISSUER.equals(principal.getIssuer().toString()))\n throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"issuer not trusted\");\n String subject = principal.getSubject();\n if (subject == null || subject.isBlank()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"subject missing\");\n return new Identity(TRUSTED_ISSUER, subject);\n // END EXERCISE" + }, + "walkthrough": [ + "@AuthenticationPrincipal OidcUser principal은 인증 뒤의 사용자 정보를 인수로 받습니다. 이 발췌는 외부 로그인 화면이나 토큰 교환을 구현하는 부분이 아닙니다.", + "첫 if는 getIssuer()가 없거나 허용한 TRUSTED_ISSUER와 다르면 FORBIDDEN, 즉 403을 던집니다. 발급자를 신뢰할 수 있어야 다음으로 갑니다.", + "String subject = principal.getSubject()로 그 발급자 안의 사용자 ID를 읽습니다. 다음 if는 없거나 빈 문자열인 ID를 거절합니다.", + "return new Identity(TRUSTED_ISSUER, subject)는 검사한 발급자와 사용자 ID를 함께 반환합니다. 이메일을 비교하거나 병합하는 단계는 없습니다." + ], + "prediction": { + "question": "허용 issuer와 같은 이메일을 가진 두 주체의 subject가 각각 subject-A, subject-B이면 /me의 결과도 같을까요?", + "answer": "다릅니다. issuer는 같고 각 결과의 subject는 subject-A, subject-B로 유지됩니다.", + "explanation": "반환값은 new Identity(TRUSTED_ISSUER, subject)로 만듭니다. 이메일을 참조하지 않으므로 같은 이메일이 두 주체를 하나로 바꾸지 않습니다. 이 결과는 이미 인증된 주체를 넣은 테스트의 범위입니다." + }, + "takeaway": "외부 신원을 내부 계정에 연결하기 전에 누가 발급한 어떤 사용자 ID인지 먼저 구분합니다." + }, "theory": [ { "heading": "OAuth2와 OIDC의 역할", - "text": "OAuth2는 클라이언트의 자원 접근 권한 위임을 다룹니다. OIDC는 그 위에 사용자 인증과 ID Token 의미를 정의합니다. access token을 사용자의 모든 신원을 증명하는 임의 증명서처럼 다루지 않습니다." + "text": "외부 서비스에 내 자원의 일부를 읽을 권한을 주는 일과, 지금 로그인한 사용자가 누구인지 받는 일은 다릅니다. OAuth2는 앞의 권한 위임을 다루고 OIDC는 그 위에 사용자 인증과 ID Token의 의미를 더합니다. ID Token은 로그인 신원 정보를 담는 토큰이고 access token은 자원 접근에 쓰이는 토큰입니다. 이 예제의 me는 인증 후 전달된 OidcUser를 읽습니다. access token 문자열을 임의로 해석해 계정 ID로 삼는 코드가 아닙니다." }, { "heading": "계정 식별의 안정성", - "text": "이 과제의 내부 식별자는 신뢰한 issuer와 subject의 조합입니다. 이메일은 변경되거나 공급자 사이에서 충돌할 수 있으므로 자동 계정 병합 키로 사용하지 않습니다. issuer 신뢰 목록과 계정 연결 정책은 서비스가 명시합니다." + "text": "issuer는 누가 신원을 발급했는지, subject는 그 발급자가 구분한 사용자가 누구인지를 나타냅니다. Me.me는 먼저 principal.getIssuer()가 TRUSTED_ISSUER와 같은지 검사하고, 비어 있지 않은 getSubject()와 묶어 Identity를 반환합니다. 같은 이메일을 가진 subject-A와 subject-B라도 결과는 다릅니다. 이메일은 바뀌거나 다시 쓰일 수 있으므로 이 코드의 계정 식별 기준이 아닙니다. 신뢰할 발급자 목록과 실제 내부 계정 연결 정책은 서비스가 별도로 정해야 합니다." }, { "heading": "테스트 프로필의 한계", - "text": "oidcLogin 테스트 도구는 이미 인증된 OIDC 주체를 요청에 제공합니다. 따라서 이 실습은 주체를 받아 계정을 구분하는 경계를 검증합니다. authorization redirect·state·nonce·PKCE·코드 교환·ID Token 서명 검증의 전체 프로토콜은 실행하지 않으며, 별도 로컬 IdP 통합 과제로 이어집니다." + "text": "fixture는 테스트 조건을 만들기 위한 가상 데이터나 설정입니다. 여기서는 oidcLogin()이 이미 인증된 OidcUser를 요청에 넣어 주므로 Me.me의 신원 처리만 집중해서 검사합니다. .invalid 주소의 실제 로그인 제공자를 방문하지 않습니다. 로그인 화면으로 이동하는 redirect, 요청·응답을 연결하는 state와 nonce, 코드 교환을 보호하는 PKCE, authorization code 교환, ID Token 서명 검증을 끝까지 실행한 테스트는 아닙니다. 이 단계들은 별도 로컬 로그인 제공자(IdP) 통합 검증으로 확인해야 합니다." } ], "example": { "title": "IdentityLab 기준 구현", "code": "package edu.lab;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.client.registration.*;\nimport org.springframework.security.oauth2.core.AuthorizationGrantType;\nimport org.springframework.security.oauth2.core.oidc.user.OidcUser;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.security.web.authentication.HttpStatusEntryPoint;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.server.ResponseStatusException;\n@SpringBootApplication\npublic class IdentityLab {\n public static final String TRUSTED_ISSUER = \"https://training-issuer.invalid\";\n public record Identity(String issuer, String subject) {}\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).oauth2Login(Customizer.withDefaults())\n .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));\n return http.build();\n }\n @Bean ClientRegistrationRepository clients() {\n ClientRegistration fixture = ClientRegistration.withRegistrationId(\"training\")\n .clientId(\"training-client\").clientSecret(\"training-only-not-real\")\n .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)\n .redirectUri(\"{baseUrl}/login/oauth2/code/{registrationId}\").scope(\"openid\")\n .authorizationUri(TRUSTED_ISSUER + \"/authorize\").tokenUri(TRUSTED_ISSUER + \"/token\")\n .jwkSetUri(TRUSTED_ISSUER + \"/jwks\").userInfoUri(TRUSTED_ISSUER + \"/userinfo\")\n .userNameAttributeName(\"sub\").issuerUri(TRUSTED_ISSUER).clientName(\"local fixture\").build();\n return new InMemoryClientRegistrationRepository(fixture);\n }\n @RestController public static class Me {\n @GetMapping(\"/me\") public Identity me(@AuthenticationPrincipal OidcUser principal) {\n // BEGIN EXERCISE\n if (principal.getIssuer() == null || !TRUSTED_ISSUER.equals(principal.getIssuer().toString()))\n throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"issuer not trusted\");\n String subject = principal.getSubject();\n if (subject == null || subject.isBlank()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"subject missing\");\n return new Identity(TRUSTED_ISSUER, subject);\n // END EXERCISE\n }\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 4개 JUnit 테스트 통과.\n검증 항목: 미인증 401 · 신뢰한 issuer·subject 전달 · 비신뢰 issuer 403 · 이메일로 신원 병합하지 않음.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "Me.me는 @AuthenticationPrincipal로 받은 OidcUser에서 발급자를 먼저 확인합니다. 발급자가 없거나 TRUSTED_ISSUER와 다르면 403을 던지고, subject가 없거나 비어 있어도 거절합니다. 통과하면 발급자와 subject 두 값만 Identity에 담아 반환합니다. 이메일을 읽는 줄은 없습니다. 테스트의 oidcLogin은 이 메서드에 전달할 인증 주체를 만들어 주며 실제 외부 로그인이나 키 서버 호출을 실행하지 않습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", "files": [ { "path": "src/main/java/edu/lab/IdentityLab.java", @@ -59,7 +97,7 @@ "access token 문자열의 앞 5글자" ], "correctIndex": 0, - "explanation": "신뢰한 발급자의 subject가 주체 식별의 기준입니다. 표시 이름과 이메일은 변경·충돌 가능성이 있으므로 자동 계정 병합 근거로 부족합니다." + "explanation": "Me.me가 반환하는 Identity의 두 필드는 issuer와 subject입니다. OIDC의 사용자 식별은 이 조합을 기준으로 해야 공급자와 그 안의 사용자를 함께 구분할 수 있습니다. 같은 이메일의 subject-A·subject-B를 반환하는 테스트가 이메일 자동 병합을 하지 않는 이유를 보여 줍니다." }, { "id": "oidc-identity-q2", @@ -71,20 +109,20 @@ "브라우저 redirect와 state·nonce 전체" ], "correctIndex": 2, - "explanation": "테스트 주체 주입은 프로토콜 실행을 생략하므로 별도 IdP 통합 검증이 필요합니다." + "explanation": "oidcLogin()은 요청에 OidcUser를 넣어 인증이 끝난 뒤 상황을 만듭니다. 따라서 이 테스트로 알 수 있는 것은 Me.me가 발급자·subject를 어떻게 처리하는지입니다. 브라우저 이동, 코드 교환, state·nonce, PKCE, 실제 서명·키 교체 검증은 별도입니다." } ], "practice": { "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "OAuth2 권한 위임과 OIDC 로그인 신원을 구분하고 issuer·subject로 계정의 경계를 고정합니다.", + "scenario": "같은 이메일 문자열을 가진 외부 사용자 두 명이 들어왔습니다. 이메일만으로 계정을 합치면 다른 사용자의 주문까지 연결될 수 있으므로, 반환할 식별값부터 발급자와 subject로 구분합니다.", "tasks": [ - "GET /me는 인증된 OIDC 주체의 issuer와 subject를 반환합니다.", - "허용 issuer는 https://training-issuer.invalid 하나이며 다른 issuer는 403입니다.", - "같은 이메일이어도 다른 subject를 같은 계정으로 합치지 않습니다.", - ".invalid 공급자 URL과 training-only 값은 호출하지 않는 테스트 설정입니다. 실제 외부 계정이나 client secret을 입력하지 않습니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "GET /me가 미인증 요청에 401을 주는 설정과, 인증 주체의 발급자를 보고 403을 주는 Me.me의 조건문을 구분하세요.", + "허용 발급자 https://training-issuer.invalid의 subject-A와 subject-B가 같은 이메일이어도 서로 다른 Identity를 만드는 이유를 설명하세요.", + "principal.getIssuer()가 허용 값이 아닌 경우 subject가 그럴듯해도 거절해야 하는 이유를 적으세요.", + ".invalid 주소와 training-only 값은 외부 호출용 자격증명이 아니라 테스트 설정입니다. 실제 계정이나 client secret을 넣지 않습니다.", + "이번 테스트가 인증 주체를 주입한다는 점과 실제 IdP 로그인 전체에서 따로 확인할 redirect·state·nonce·PKCE·코드 교환·서명을 구분해 기록하세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "받은 사용자 정보의 신뢰 범위부터 확인하고 issuer와 subject를 함께 다룹니다. 주체 처리 테스트 통과를 외부 로그인 전체 검증으로 확대하지 않습니다." }, "sources": [ { diff --git a/curriculum/professional/lessons/optimistic-cancel/lesson.json b/curriculum/professional/lessons/optimistic-cancel/lesson.json index ea3f290..fd17173 100644 --- a/curriculum/professional/lessons/optimistic-cancel/lesson.json +++ b/curriculum/professional/lessons/optimistic-cancel/lesson.json @@ -1,7 +1,7 @@ { "id": "optimistic-cancel", "title": "낙관적 잠금과 중복 취소의 의미", - "description": "취소 상태 전이와 낙관적 잠금 충돌을 분리하여 중복 복원과 덮어쓰기를 막는 근거를 확인합니다.", + "description": "취소 버튼을 두 번 누르거나 두 작업이 같은 주문을 읽으면 재고를 두 번 돌려줄 수 있습니다. 객체의 취소 표시와 DB의 버전 검사가 각각 막는 문제를 나누어 읽습니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,30 +10,65 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "같은 상태를 읽은 두 작업이 서로의 결과를 덮어쓰지 않으려면?", - "취소 상태 전이", - "반복 취소 복원량 0", - "오래된 버전 커밋 거절" + "cancelAndReturnQuantity의 첫 호출과 두 번째 호출이 다른 수량을 반환하는 이유를 설명한다.", + "cancelled가 false에서 true로 바뀌는 지점을 찾는다.", + "같은 객체의 중복 취소를 막는 if 문과 DB 저장 충돌을 잡는 @Version의 역할을 구분한다.", + "반환 수량만으로 외부 재고 복원이 완료됐다고 판단할 수 없는 이유를 설명한다." ], + "learningGuide": { + "scenario": "수량 3인 주문을 취소한 뒤 같은 객체에 취소를 다시 요청합니다. 매번 3을 돌려주면 이를 믿는 호출자가 재고를 두 번 늘릴 수 있습니다.", + "question": "이미 처리한 취소를 메서드가 어떻게 기억하고 두 번째 반환값을 바꿀까요?", + "terms": [ + { + "term": "상태 전이", + "meaning": "객체가 한 상태에서 다음 상태로 바뀌는 것입니다. 여기서는 cancelled=false가 true로 바뀝니다." + }, + { + "term": "멱등성", + "meaning": "같은 요청을 반복해도 업무 효과가 추가로 생기지 않는 성질입니다. 이미 취소된 객체는 복원 후보 수량을 0으로 돌려줍니다." + }, + { + "term": "낙관적 잠금", + "meaning": "DB에 저장할 때 version을 비교해, 읽은 뒤 다른 작업이 먼저 바꾼 상태를 감지하는 방법입니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/CancellableOrder.java", + "source": " @Version private long version;\n private int quantity;\n private boolean cancelled;\n protected CancellableOrder() {}\n public CancellableOrder(String id, int quantity) {\n if (id == null || id.isBlank() || quantity <= 0) throw new IllegalArgumentException(\"invalid order\");\n this.id = id; this.quantity = quantity;\n }\n public long version() { return version; }\n public boolean cancelled() { return cancelled; }\n public int cancelAndReturnQuantity() {\n // BEGIN EXERCISE\n if (cancelled) return 0;\n cancelled = true;\n return quantity;\n // END EXERCISE" + }, + "walkthrough": [ + "@Version private long version은 JPA가 DB 저장 충돌을 확인할 때 쓰는 필드입니다. quantity와 cancelled는 주문 수량과 취소 여부를 기억합니다.", + "생성자의 if는 비어 있는 id와 0 이하 수량을 거절합니다. this.quantity = quantity로 받은 수량을 객체에 저장합니다.", + "cancelAndReturnQuantity의 if (cancelled) return 0은 이미 취소한 객체의 실행을 여기서 끝냅니다.", + "처음에는 if를 지나 cancelled=true로 바꾸고 quantity를 반환합니다. 이 순서 때문에 같은 객체의 다음 호출은 0을 반환합니다.", + "이 메서드에는 재고를 수정하는 SQL이나 API 호출이 없습니다. 별도 객체끼리의 DB 저장 충돌은 위의 @Version이 담당하고, 실제 재고 반영은 이 발췌 밖의 설계 과제입니다." + ], + "prediction": { + "question": "수량 3인 같은 객체에 cancelAndReturnQuantity를 연속 두 번 호출하면 반환값과 마지막 cancelled는 무엇일까요?", + "answer": "반환값은 3, 0이며 마지막 cancelled는 true입니다.", + "explanation": "첫 호출이 true를 저장한 뒤 3을 반환합니다. 두 번째 호출은 그 true를 읽고 즉시 0을 반환합니다. 이는 같은 객체의 결과이며 외부 재고가 실제로 3개 복원됐다는 뜻은 아닙니다." + }, + "takeaway": "객체에서 취소를 결정한 순간과 DB·외부 시스템에서 그 취소를 확정한 순간을 구분합니다." + }, "theory": [ { "heading": "중복 호출의 업무 의미", - "text": "취소는 한 번 RESERVED에서 CANCELLED로 전이합니다. 이미 취소된 주문의 두 번째 호출은 복원할 수량 0을 반환합니다. 메서드 내부의 상태 검사만으로 여러 DB 트랜잭션의 경쟁까지 해결되지는 않습니다." + "text": "수량 3인 주문에서 cancelAndReturnQuantity를 처음 호출하면 cancelled는 false입니다. if를 지나 true로 바꾸고 3을 반환합니다. 같은 객체를 다시 호출하면 if (cancelled)에서 0을 반환해 복원 수량을 중복 계산하지 않습니다. 같은 요청을 반복해도 업무 효과를 추가로 만들지 않는 성질을 멱등성이라고 합니다. 다만 두 작업이 각각 읽은 별도 객체에는 cancelled=false가 남아 있을 수 있으므로 이 if만으로 DB의 동시 변경까지 해결하지는 못합니다." }, { "heading": "버전 비교로 충돌 발견", - "text": "@Version은 이전 버전을 조건으로 UPDATE하는 낙관적 잠금에 사용됩니다. 두 영속성 컨텍스트가 같은 버전을 읽고 서로 변경하면 먼저 저장한 결과를 뒤의 커밋이 덮어쓰는 대신 충돌을 알릴 수 있습니다. 충돌 처리와 재시도는 호출자 책임입니다." + "text": "낙관적 잠금은 저장할 때 읽었던 버전과 DB의 현재 버전을 비교해 다른 작업의 선행 변경을 발견하는 방식입니다. 이 엔티티의 @Version long version이 그 비교에 쓰입니다. 영속성 컨텍스트는 JPA가 읽어 온 객체와 변경을 관리하는 작업 공간입니다. 테스트의 first와 stale이 같은 주문을 각각 읽고 취소한 뒤 first가 먼저 커밋하면, 오래된 버전을 가진 stale의 커밋은 실패합니다. 커밋은 변경을 확정하는 단계입니다. 충돌을 잡은 호출자는 최신 상태를 다시 읽고 취소가 여전히 필요한지 판단해야 합니다." }, { "heading": "외부 효과와 트랜잭션", - "text": "복원 수량을 계산한 즉시 외부 API를 호출하면 나중의 커밋 실패와 불일치할 수 있습니다. 이 과제는 엔티티와 DB 버전 충돌만 검사합니다. 실제 재고 복원은 같은 DB 트랜잭션이나 outbox 등 별도 경계 설계가 필요합니다." + "text": "cancelAndReturnQuantity가 2를 반환한 순간에는 객체의 상태와 복원할 후보 수량만 정해졌습니다. 그 뒤 DB 커밋이 실패할 수도 있습니다. 반환 직후 외부 재고 API에 2개를 더했다면 주문은 취소되지 않았는데 재고만 늘어날 수 있습니다. 이번 검사는 객체 동작과 H2의 버전 충돌까지이며 외부 재고·결제는 호출하지 않습니다. 실제 복원은 같은 DB 트랜잭션에 묶거나, 보낼 이벤트를 DB에 함께 기록하는 outbox 등의 방법을 따로 설계합니다." } ], "example": { "title": "CancellationLab 기준 구현", "code": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class CancellationLab {\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"cancellations\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 취소 상태 전이 · 반복 취소 복원량 0 · 오래된 버전 커밋 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "CancellableOrder의 cancelled 필드는 이미 취소했는지 기억하고, cancelAndReturnQuantity는 처음에만 quantity를 반환합니다. @Version은 이 if와 별도로 DB에 저장할 때 오래된 객체의 덮어쓰기를 막습니다. 테스트 하나는 수량 3인 객체를 두 번 호출해 3, 0을 확인하고, 다른 하나는 두 EntityManager에서 같은 주문을 읽어 두 번째 커밋이 실패하는지 확인합니다. 반환값을 실제 재고 DB에 더하는 코드는 없습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", "files": [ { "path": "src/main/java/edu/lab/CancellationLab.java", @@ -68,7 +103,7 @@ "버전 필드를 직접 0으로 바꾸면 해결된다." ], "correctIndex": 2, - "explanation": "낙관적 잠금은 충돌 발견 수단입니다. 새 상태에서 명령이 여전히 유효한지 확인하고 제한된 재시도·거절 정책을 결정해야 합니다." + "explanation": "stale이 들고 있던 주문은 first가 저장한 최신 상태를 반영하지 않습니다. 충돌한 객체를 그대로 다시 저장하거나 version을 바꾸면 문제를 숨깁니다. 새 작업에서 주문을 다시 읽고 이미 취소되었는지 확인한 뒤 제한된 재시도나 거절을 결정해야 합니다." }, { "id": "optimistic-cancel-q2", @@ -80,20 +115,20 @@ "해당 메서드가 계산한 복원 후보 수량" ], "correctIndex": 2, - "explanation": "객체 상태 변화와 외부 시스템의 커밋은 서로 다른 경계입니다." + "explanation": "메서드 본문에는 cancelled=true와 return quantity만 있습니다. 외부 재고 API나 재고 UPDATE가 없고 DB 커밋 전일 수도 있습니다. 따라서 2는 복원할 후보 수량이며 외부 재고 2개 복원이 완료되었다는 증거가 아닙니다." } ], "practice": { "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "취소 상태 전이와 낙관적 잠금 충돌을 분리하여 중복 복원과 덮어쓰기를 막는 근거를 확인합니다.", + "scenario": "취소 요청의 응답이 늦어 사용자가 다시 누릅니다. 한 객체에서의 반복 호출과 서로 다른 요청이 각자 읽은 주문의 경쟁을 나누어, 재고 복원이 중복될 위치를 찾습니다.", "tasks": [ - "첫 cancelAndReturnQuantity 호출은 주문 수량을 반환하고 cancelled=true로 바꿉니다.", - "이미 취소된 객체에서는 0을 반환합니다.", - "@Version으로 stale 영속성 컨텍스트의 두 번째 커밋이 실패해야 합니다.", - "이 예제의 반환 수량은 재고 DB나 결제 API에 실제로 적용되지 않습니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "수량 3인 CancellableOrder에서 첫 호출의 cancelled 변화와 반환값, 두 번째 호출이 멈추는 if를 설명하세요.", + "first와 stale이 취소 전 주문 A를 각각 읽은 경우, 양쪽 메서드가 수량을 반환할 수 있어도 두 커밋이 모두 성공하지 않는 이유를 @Version과 연결하세요.", + "두 번째 커밋 충돌 뒤에는 최신 주문을 다시 읽어 이미 취소됐는지 판단해야 하는 이유를 적으세요.", + "반환 수량을 바로 외부 재고 API에 적용했을 때 DB 커밋 실패가 만드는 불일치를 설명하세요. 이 예제는 재고 DB나 결제 API를 실제로 호출하지 않습니다.", + "객체의 반복 호출 검사와 두 영속성 컨텍스트의 버전 충돌 검사에서 각각 확인한 상태를 구분하세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "if (cancelled)는 같은 객체의 반복을 막고 @Version은 오래된 DB 상태의 덮어쓰기를 잡습니다. 실제 재고 복원은 별도의 확정 단계까지 설계해야 합니다." }, "sources": [ { diff --git a/curriculum/professional/lessons/orders-capstone/lesson.json b/curriculum/professional/lessons/orders-capstone/lesson.json index 15eb323..5050ef7 100644 --- a/curriculum/professional/lessons/orders-capstone/lesson.json +++ b/curriculum/professional/lessons/orders-capstone/lesson.json @@ -1,7 +1,7 @@ { "id": "orders-capstone", "title": "주문·예약·취소 통합 계약", - "description": "HTTP 입력·역할·객체 소유권·재고 트랜잭션·중복 요청·취소를 하나의 주문 흐름으로 연결합니다.", + "description": "응답이 늦어 주문이나 취소를 다시 보내도 재고가 두 번 바뀌면 안 됩니다. 입력 검사부터 요청 ID 비교, 주문 소유자 확인, 재고 변경까지 한 흐름으로 읽습니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,36 +10,75 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "부분적으로 옳은 기능들을 합쳤을 때도 주문 불변식이 유지되는가?", - "입력·미인증 거절", - "동일 요청 멱등 처리", - "payload 충돌과 재고 부족 롤백", - "다른 사용자 취소 거절", - "반복 취소 재고 한 번 복원" + "주문 생성·재시도·취소를 이어서 실행해도 재고가 맞아야 한다는 조건을 설명한다.", + "잘못된 입력과 미인증 요청이 DB를 바꾸기 전에 거절되는 경로를 찾는다.", + "같은 의미의 요청을 반복해도 효과를 늘리지 않는 멱등 처리를 requestId와 연결한다.", + "같은 ID의 다른 요청 내용과 재고 부족을 거절하고 실패한 DB 변경을 되돌리는 롤백을 설명한다.", + "구매자 역할이 있어도 다른 사람 주문을 취소할 수 없는 이유를 찾는다.", + "취소 상태를 실제로 바꾼 요청만 재고를 한 번 복원하는 조건을 읽는다." ], + "learningGuide": { + "scenario": "초기 재고 5에서 alice가 BOOK 2개를 예약해 현재 재고는 3입니다. alice가 같은 주문의 취소를 두 번 보내도 재고가 7이 되면 안 됩니다.", + "question": "두 번째 취소에서 재고를 더하지 않게 만드는 판단은 어디에 있을까요?", + "terms": [ + { + "term": "인가 / 소유권 검사", + "meaning": "그 사용자가 이 행동을 해도 되는지 확인하는 일입니다. own은 인증된 사용자와 주문 buyer가 같은지 확인합니다." + }, + { + "term": "조건부 UPDATE", + "meaning": "WHERE 조건에 맞는 행만 바꾸는 SQL입니다. 여기서는 RESERVED 주문만 CANCELLED로 바꿉니다." + }, + { + "term": "영향받은 행 수", + "meaning": "UPDATE가 실제 바꾼 행의 개수입니다. changed==1일 때만 이번 요청이 취소 상태를 바꾼 것입니다." + }, + { + "term": "트랜잭션", + "meaning": "DB 변경을 함께 확정하거나 실패 시 되돌리는 단위입니다. cancel의 상태 변경과 재고 복원을 함께 묶습니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/CapstoneLab.java", + "source": " @Transactional public OrderView cancel(String buyer,String id) {\n // BEGIN EXERCISE\n Stored order=require(id); own(order,buyer);\n int changed=jdbc.update(\"update orders set status='CANCELLED' where id=? and status='RESERVED'\",id);\n if(changed==1) jdbc.update(\"update inventory set stock=stock+? where sku=?\",order.quantity(),order.sku());\n return require(id).view();" + }, + "walkthrough": [ + "@Transactional cancel은 Controller가 Spring 관리 Orders를 호출하는 경로에서 DB 트랜잭션을 적용받습니다. 첫 require(id)는 주문이 없으면 404를 던집니다.", + "own(order,buyer)는 주문에 저장된 buyer와 호출자를 비교합니다. 다른 사람이라면 403을 던져 아래 변경 SQL까지 가지 못하게 합니다.", + "첫 jdbc.update는 id가 같고 status가 RESERVED일 때만 CANCELLED로 바꿉니다. 바꾼 행의 수를 changed에 받습니다.", + "if(changed==1)일 때만 주문 수량만큼 재고를 더합니다. 두 번째 취소에서는 이미 CANCELLED여서 changed가 0이고 이 UPDATE를 건너뜁니다.", + "return require(id).view()는 변경 뒤 주문 상태를 다시 읽어 반환합니다. 메서드의 DB 변경들은 같은 트랜잭션으로 확정되며 외부 결제 취소는 이 코드에 없습니다." + ], + "prediction": { + "question": "현재 재고 3, alice 소유의 수량 2인 RESERVED 주문에 alice가 취소를 순서대로 두 번 요청하면 각 요청 뒤 재고는 얼마일까요?", + "answer": "첫 취소 뒤 5, 두 번째 취소 뒤에도 5입니다. 주문 상태는 CANCELLED입니다.", + "explanation": "첫 요청만 RESERVED 조건에 맞아 changed가 1이므로 2개를 복원합니다. 두 번째는 changed가 0이어서 복원하지 않습니다. 같은 사용자·정상 DB의 순차 호출을 읽은 결과이며 동시 부하 검증을 대신하지 않습니다." + }, + "takeaway": "조회 당시의 상태만 믿지 않고 실제 상태 변경에 성공한 요청만 후속 재고 변경을 하도록 연결합니다." + }, "theory": [ { "heading": "계약을 먼저 연결한다", - "text": "요청 어댑터는 입력과 인증 주체를 받고 서비스는 상태 전이와 DB 경계를 결정합니다. DB는 재고 음수 금지와 요청 ID 유일성을 보조합니다. 계층 수를 늘리는 것 자체가 목표가 아니라 어느 실패를 누가 처리하는지 설명하는 것이 목표입니다." + "text": "재고 5개인 BOOK을 alice가 2개 주문하면 재고는 3개가 되고 주문은 RESERVED가 되어야 합니다. Controller.create는 @Valid로 입력을 검사하고 인증된 사용자 이름을 Orders.reserve에 넘깁니다. 인증은 누구인지 확인하는 일이며, 인가는 그 사람이 해당 행동을 해도 되는지 판단하는 일입니다. 보안 설정은 BUYER 역할을, own은 특정 주문의 소유자를 검사합니다. 서비스는 재고 차감과 주문 저장을 같은 DB 트랜잭션, 즉 함께 확정하거나 취소할 작업으로 묶습니다. DB의 수량·상태 제약과 주문 ID 기본키도 잘못된 저장을 막습니다." }, { "heading": "반복 요청과 경쟁", - "text": "같은 요청 ID·사용자·SKU·수량의 순차 재시도는 기존 주문을 반환합니다. 같은 ID의 다른 내용은 충돌입니다. 동시 신규 요청의 유일 키 충돌은 409로 돌려주고 같은 요청으로 다시 조회하도록 합니다. 단일 DB의 조건부 UPDATE와 트랜잭션을 쓰지만 이 테스트만으로 모든 고부하 경쟁을 검증했다고 주장하지 않습니다." + "text": "멱등성은 같은 의미의 요청을 반복해도 업무 효과를 더 만들지 않는 성질입니다. reserve는 requestId로 주문을 먼저 찾습니다. 이미 있으면 소유자와 sku·quantity, 즉 요청 내용(payload)이 같은지 확인해 기존 주문을 반환하므로 재고를 또 빼지 않습니다. 같은 사용자가 같은 ID로 수량을 바꾸면 409입니다. 새 요청은 stock>=quantity인 경우에만 차감하고 주문을 저장합니다. 동시에 같은 ID로 신규 저장이 경쟁하면 유일키 충돌이 생길 수 있어 트랜잭션을 롤백하고 409 뒤 같은 요청으로 재시도하게 합니다. 롤백은 확정 전 DB 변경을 되돌리는 일입니다. 제공 테스트는 순차 재시도와 실패 상태를 검사하며 모든 동시 실행·고부하를 검증한 것은 아닙니다." }, { "heading": "취소와 경계의 증거", - "text": "취소 상태를 조건부로 바꾼 트랜잭션만 재고를 복원합니다. 권한 검사 실패·재고 부족·입력 실패는 부분 변경을 남기지 않아야 합니다. 실제 결제 취소와 이벤트 발행은 포함하지 않으며 추가 시 외부 시스템과의 실패 경계를 다시 설계합니다." + "text": "cancel은 주문을 읽은 뒤 own으로 소유자를 확인합니다. 이어 status가 RESERVED일 때만 CANCELLED로 바꾸는 UPDATE를 실행합니다. changed==1인 요청만 재고를 더하고, 반복 취소로 이미 CANCELLED이면 changed==0이라 더하지 않습니다. 이 두 변경은 같은 트랜잭션에 있습니다. 소유권 부족·입력 오류·재고 부족도 실패 뒤 부분 변경이 남지 않아야 합니다. 여기에는 외부 결제 취소·메일·이벤트 발행이 없으며, 추가할 때는 DB 롤백으로 외부 작업도 취소된다고 가정하지 말고 실패 처리를 다시 설계해야 합니다." }, { "heading": "SOLID와 추상화의 비용", - "text": "컨트롤러·서비스·SQL의 책임을 구분하되 모든 클래스에 인터페이스를 추가하지 않습니다. 구현 대체나 외부 협력 테스트가 필요한 지점에 추상화를 도입하고 SQL의 원자적 조건을 무의미한 객체 계층으로 숨기지 않습니다. 정답 코드는 한 가지 설계안이며 대안은 동일 행동 기준으로 비교합니다." + "text": "책임을 나누는 목적은 문제가 생겼을 때 고칠 곳을 찾기 쉽게 하는 데 있습니다. 이 예제에서는 Controller가 HTTP 입력·응답을, Orders가 주문 규칙과 트랜잭션을, SQL이 조건에 맞는 행의 변경을 담당합니다. SOLID는 이런 책임·의존 관계를 검토하는 설계 원칙 묶음이지 클래스마다 인터페이스를 붙이는 규칙이 아닙니다. 외부 결제 구현을 바꾸거나 대역으로 테스트해야 할 때처럼 필요한 경계에 추상화를 둡니다. status 조건과 changed 행 수의 의미를 여러 객체 뒤에 숨기지 않고, 다른 설계도 동일한 주문·재고 결과로 비교합니다." } ], "example": { "title": "CapstoneLab 기준 구현", "code": "package edu.lab;\nimport java.util.*;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.*;\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.dao.DuplicateKeyException;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.jdbc.core.*;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.Authentication;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class CapstoneLab {\n public static void main(String[] args) { SpringApplication.run(CapstoneLab.class,args); }\n public record Create(@NotBlank @Size(max=60) @Pattern(regexp=\"[A-Za-z0-9_-]{1,60}\") String requestId, @NotBlank @Pattern(regexp=\"BOOK\") String sku, @Min(1) int quantity) {}\n public record OrderView(String id, String sku, int quantity, String status) {}\n private record Stored(String id, String buyer, String sku, int quantity, String status) {\n OrderView view() { return new OrderView(id,sku,quantity,status); }\n }\n public static class Failure extends RuntimeException {\n final int status;\n Failure(int status,String message) { super(message); this.status=status; }\n }\n @Service public static class Orders {\n private final JdbcTemplate jdbc;\n private static final RowMapper ROW = (r,n) -> new Stored(r.getString(\"id\"),r.getString(\"buyer\"),r.getString(\"sku\"),r.getInt(\"quantity\"),r.getString(\"status\"));\n public Orders(JdbcTemplate jdbc) { this.jdbc=jdbc; }\n private List find(String id) { return jdbc.query(\"select id,buyer,sku,quantity,status from orders where id=?\",ROW,id); }\n private Stored require(String id) { var rows=find(id); if(rows.isEmpty()) throw new Failure(404,\"order not found\"); return rows.get(0); }\n private void own(Stored order,String buyer) { if(!order.buyer().equals(buyer)) throw new Failure(403,\"not order owner\"); }\n @Transactional public OrderView reserve(String buyer,Create request) {\n // BEGIN EXERCISE\n if (buyer==null || buyer.isBlank() || request.requestId()==null || !request.requestId().matches(\"[A-Za-z0-9_-]{1,60}\")\n || !\"BOOK\".equals(request.sku()) || request.quantity()<=0) throw new Failure(400,\"invalid order\");\n var existing=find(request.requestId());\n if(!existing.isEmpty()) {\n Stored order=existing.get(0); own(order,buyer);\n if(!order.sku().equals(request.sku()) || order.quantity()!=request.quantity()) throw new Failure(409,\"request payload conflicts\");\n return order.view();\n }\n int changed=jdbc.update(\"update inventory set stock=stock-? where sku=? and stock>=?\",request.quantity(),request.sku(),request.quantity());\n if(changed!=1) throw new Failure(409,\"insufficient stock\");\n jdbc.update(\"insert into orders(id,buyer,sku,quantity,status) values(?,?,?,?,?)\",request.requestId(),buyer,request.sku(),request.quantity(),\"RESERVED\");\n return new OrderView(request.requestId(),request.sku(),request.quantity(),\"RESERVED\");\n // END EXERCISE\n }\n @Transactional public OrderView cancel(String buyer,String id) {\n // BEGIN EXERCISE\n Stored order=require(id); own(order,buyer);\n int changed=jdbc.update(\"update orders set status='CANCELLED' where id=? and status='RESERVED'\",id);\n if(changed==1) jdbc.update(\"update inventory set stock=stock+? where sku=?\",order.quantity(),order.sku());\n return require(id).view();\n // END EXERCISE\n }\n }\n @RestController @RequestMapping(\"/orders\")\n public static class Controller {\n private final Orders orders;\n public Controller(Orders orders) { this.orders=orders; }\n @PostMapping public OrderView create(@Valid @RequestBody Create request,Authentication auth) { return orders.reserve(auth.getName(),request); }\n @PostMapping(\"/{id}/cancel\") public OrderView cancel(@PathVariable(\"id\") String id,Authentication auth) { return orders.cancel(auth.getName(),id); }\n @ExceptionHandler(Failure.class) public ResponseEntity> failure(Failure failure) {\n return ResponseEntity.status(failure.status).body(Map.of(\"error\",failure.getMessage()));\n }\n @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> duplicate() {\n return ResponseEntity.status(409).body(Map.of(\"error\",\"concurrent request conflict; retry the same request\"));\n }\n }\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.requestMatchers(\"/orders\",\"/orders/**\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"alice\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"bob\").password(\"{noop}training\").roles(\"BUYER\").build());\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 6개 JUnit 테스트 통과.\n검증 항목: 입력·미인증 거절 · 동일 요청 멱등 처리 · payload 충돌과 재고 부족 롤백 · 다른 사용자 취소 거절 · 반복 취소 재고 한 번 복원.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "reserve는 입력을 확인한 뒤 requestId의 기존 주문을 찾습니다. 같은 사용자·같은 내용이면 바로 반환하고, 새 주문일 때만 조건부 재고 UPDATE와 주문 INSERT를 실행합니다. cancel은 require와 own으로 존재·소유자를 확인하고 RESERVED에서 CANCELLED로 바뀐 행 수가 1일 때만 수량을 복원합니다. 예를 들어 초기 재고 5에서 alice가 2개 주문하면 3, 같은 생성 요청을 반복해도 3, 취소를 두 번 해도 최종 5입니다. 테스트는 이 순차 결과와 입력·인증·역할·CSRF·소유권 실패 뒤 DB 상태를 검사합니다. 동시 부하, 결제, 외부 이벤트까지 실행한 검사는 아닙니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", "files": [ { "path": "src/main/java/edu/lab/CapstoneLab.java", @@ -74,7 +113,7 @@ "재고가 있으면 같은 ID의 주문을 하나 더 만든다." ], "correctIndex": 2, - "explanation": "멱등성은 같은 의미의 요청을 반복할 때의 계약입니다. 같은 키의 다른 내용은 업무 충돌로 명시해야 합니다." + "explanation": "reserve의 기존 주문 분기는 sku와 quantity를 원래 값과 비교합니다. 같은 requestId에 수량만 바꾸면 같은 의미의 재시도가 아니므로 request payload conflicts로 409를 던집니다. 기존 재고 차감을 되풀이하거나 원래 주문을 조용히 덮어쓰지 않습니다." }, { "id": "orders-capstone-q2", @@ -86,22 +125,22 @@ "더 높은 역할 이름을 가진 사용자" ], "correctIndex": 2, - "explanation": "상태 전이의 실제 성공을 DB 영향 행 수로 판단하고 재고 복원을 같은 트랜잭션에 둡니다." + "explanation": "먼저 조회했다고 취소 권한을 독점한 것은 아닙니다. DB에서 status=RESERVED 조건으로 UPDATE에 성공해 changed==1을 받은 작업만 재고를 더합니다. 재고 UPDATE도 같은 트랜잭션에 두어 상태 변경과 함께 확정하거나 취소합니다. 이 조건의 설계와 실제 동시 부하 검증은 구분해야 합니다." } ], "practice": { "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "HTTP 입력·역할·객체 소유권·재고 트랜잭션·중복 요청·취소를 하나의 주문 흐름으로 연결합니다.", + "scenario": "alice의 BOOK 2개 주문 응답이 늦어 같은 요청을 다시 보냅니다. 이후 bob의 취소 요청, alice의 취소 두 번을 차례로 받았을 때 재고와 주문 상태가 어떻게 남아야 하는지 추적합니다.", "tasks": [ - "POST /orders는 requestId·sku=BOOK·양수 quantity를 받고 200과 주문 상태를 반환합니다. requestId는 영문 대소문자·숫자·_·-만 사용한 1~60자이며 취소 URL의 단일 경로 요소로 사용합니다.", - "동일 사용자·동일 payload의 같은 requestId 재시도는 재고를 다시 차감하지 않습니다. 다른 payload는 409입니다.", - "재고 부족은 409이며 주문 기록과 재고 모두 바뀌지 않습니다.", - "POST /orders/{id}/cancel은 주문 소유자만 수행할 수 있고 반복 취소는 재고를 한 번만 복원합니다.", - "인증·BUYER 역할·CSRF가 필요합니다. Flyway는 실행별 학습 H2 스키마를 준비합니다.", - "동시 생성에서 같은 requestId가 충돌하면 409 후 동일 요청 재시도 정책을 사용합니다. 결제·메일·외부 이벤트는 이 프로젝트에 연결하지 않습니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "POST /orders의 requestId·sku=BOOK·양수 quantity가 어디서 검사되는지 설명하세요. requestId는 영문 대소문자·숫자·_·-의 1~60자로 취소 URL의 한 경로 요소가 됩니다.", + "초기 재고 5에서 alice가 requestId=A, 수량 2를 두 번 요청하면 200·200과 재고 3이 되는 이유, 같은 ID의 수량 3은 409인 이유를 설명하세요.", + "재고가 5인데 수량 6을 요청하면 409 뒤 주문 0개·재고 5가 남아야 하는 조건을 changed 검사와 연결하세요.", + "bob의 취소는 403으로 거절하고 alice의 취소 두 번은 재고를 최종 5로만 돌려야 합니다. own과 status 조건부 UPDATE의 역할을 나누어 설명하세요.", + "인증·BUYER 역할·CSRF 조건이 DB 변경보다 앞에서 필요한 이유와, Flyway가 학습용 H2 구조를 준비하는 역할을 적으세요.", + "동시 신규 요청의 같은 requestId 충돌은 409 뒤 동일 요청 재시도 정책입니다. 제공된 순차 테스트가 모든 경쟁·고부하를 검증하지 않으며 결제·메일·외부 이벤트도 연결하지 않았음을 기록하세요.", + "취소 후 같은 생성 requestId를 다시 보내면 기존 CANCELLED 주문을 돌려준다는 점을 확인하고 새 주문 ID를 임의로 재사용할 때의 의미를 설명하세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "응답 코드와 최종 DB 상태를 함께 확인합니다. 동일 요청 비교, 소유자 검사, 상태 조건부 UPDATE가 각기 다른 중복·권한 문제를 막습니다." }, "sources": [ { diff --git a/curriculum/professional/lessons/redis-cache/lesson.json b/curriculum/professional/lessons/redis-cache/lesson.json index 0938f89..df492b7 100644 --- a/curriculum/professional/lessons/redis-cache/lesson.json +++ b/curriculum/professional/lessons/redis-cache/lesson.json @@ -1,7 +1,7 @@ { "id": "redis-cache", "title": "Redis TTL·무효화·키 소유권", - "description": "캐시의 TTL과 무효화 범위를 명시하고 Redis 데이터와 원본 DB의 일관성을 구분합니다.", + "description": "화면에는 재고가 5개인데 DB에는 이미 3개일 수 있습니다. StockCache가 값을 얼마 동안 보관하고 어떤 키만 지우는지 읽으며, 캐시 조회와 판매 가능 판단을 구분합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,31 +10,69 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "캐시 값이 있다는 사실과 현재 재고가 정확하다는 사실은 같은가?", - "문자열 수량 저장·조회", - "양수 TTL 설정", - "지정 키만 무효화", - "잘못된 캐시 입력 거절" + "캐시에 남은 수량과 DB의 현재 수량이 다를 수 있는 이유를 설명한다.", + "put이 수량을 문자열로 바꾸고 양수 TTL과 함께 저장하는 순서를 따라간다.", + "get의 값 없음과 수량 0을 구분한다.", + "invalidate가 지정 SKU의 키만 지워야 하는 이유를 설명한다.", + "잘못된 수량·TTL의 거절과 전용 Redis가 필요한 실제 연동 검사 범위를 구분한다." ], + "learningGuide": { + "scenario": "전용 Redis의 이번 실행 접두사 아래 BOOK=5와 PEN=2를 저장했다고 가정합니다. BOOK의 값만 오래되어 지우려 합니다.", + "question": "BOOK을 지운 뒤 get 결과는 수량 0일까요, 값 없음일까요? PEN은 어떻게 될까요?", + "terms": [ + { + "term": "캐시", + "meaning": "조회용으로 잠시 보관한 원본 데이터의 복사본입니다. 이 클래스의 수량은 주문을 확정하는 DB 재고가 아닙니다." + }, + { + "term": "TTL", + "meaning": "저장한 값이 만료되기까지의 기간입니다. put의 ttl은 양수여야 하며 DB 변경을 자동으로 전파하지는 않습니다." + }, + { + "term": "무효화", + "meaning": "더는 사용할 수 없는 캐시 값을 지우는 일입니다. invalidate는 지정 sku의 키 하나를 지웁니다." + }, + { + "term": "OptionalInt", + "meaning": "정수가 있거나 없음을 구분하는 반환 타입입니다. empty()와 값 0은 다릅니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/StockCache.java", + "source": " public void put(String sku, int quantity, Duration ttl) {\n // BEGIN EXERCISE\n if (quantity < 0 || ttl == null || ttl.isZero() || ttl.isNegative()) throw new IllegalArgumentException(\"cache value or ttl\");\n redis.opsForValue().set(key(sku), Integer.toString(quantity), ttl);\n // END EXERCISE\n }\n public OptionalInt get(String sku) {\n // BEGIN EXERCISE\n String value = redis.opsForValue().get(key(sku));\n return value == null ? OptionalInt.empty() : OptionalInt.of(Integer.parseInt(value));\n // END EXERCISE\n }\n public void invalidate(String sku) {\n // BEGIN EXERCISE\n redis.delete(key(sku));" + }, + "walkthrough": [ + "put의 if는 음수 quantity, null·0·음수 ttl을 거절합니다. 다음 set은 key(sku)에 수량 문자열과 만료 기간을 함께 저장합니다.", + "get의 String value는 같은 key(sku)를 조회합니다. 값이 없으면 null이므로 OptionalInt.empty()를 반환합니다.", + "값이 있으면 Integer.parseInt(value)로 정수로 읽습니다. 저장된 \"0\"은 값 없음이 아니라 수량 0입니다.", + "invalidate의 redis.delete(key(sku))는 그 키만 지웁니다. key는 원래 클래스에서 prefix와 sku를 합치므로 BOOK 삭제가 PEN 키까지 가리키지 않습니다." + ], + "prediction": { + "question": "Redis 명령이 정상 처리되고 TTL이 아직 남아 있을 때 BOOK만 invalidate하면 BOOK과 PEN의 조회 결과는 무엇일까요?", + "answer": "BOOK은 OptionalInt.empty(), PEN은 수량 2입니다.", + "explanation": "delete에 넘긴 것은 BOOK 키 하나입니다. BOOK의 다음 조회는 null이라 empty가 되고, 다른 키인 PEN은 유지됩니다. 이는 코드로 예측한 계약이며 실제 Redis 연동 검증은 별도로 필요합니다." + }, + "takeaway": "값 없음·재고 0·오래된 조회값을 서로 구분하고, 자신이 관리하는 키만 변경합니다." + }, "theory": [ { "heading": "원본과 복제된 조회 값", - "text": "이 단원의 Redis 값은 조회를 돕는 캐시입니다. 판매 가능 수량의 최종 판단은 트랜잭션으로 보호한 DB에 둡니다. TTL은 오래된 값이 남는 시간을 제한하는 수단이며 모든 동시 요청의 일관성을 자동 보장하지 않습니다." + "text": "캐시는 원본을 매번 읽지 않도록 잠시 보관한 조회용 복사본입니다. DB 재고가 5에서 3으로 줄어도 Redis에 넣어 둔 문자열 \"5\"가 곧바로 바뀌지는 않습니다. StockCache.put은 수량과 TTL을 받아 저장하지만 DB를 조회하거나 주문을 예약하지 않습니다. TTL은 값이 만료될 때까지의 기간입니다. 오래 남은 값을 없애는 데 쓰지만, 만료 전의 값을 항상 최신으로 만들지는 않습니다. 판매 가능 수량의 최종 판단은 DB 트랜잭션에서 해야 합니다." }, { "heading": "쓰기와 무효화의 순서", - "text": "DB 변경 뒤 캐시를 지우는 방식에도 장애·재조회 경쟁이 남을 수 있습니다. 요구하는 최신성과 장애 허용 범위를 정하고 무효화 재시도나 이벤트 전달을 설계합니다. 이 실습은 키별 put/get/invalidate 계약만 자동 검사합니다." + "text": "무효화는 더는 믿지 않을 캐시 값을 지워 다음 조회가 원본을 다시 보게 하는 방법입니다. invalidate(\"BOOK\")은 key(\"BOOK\")을 만들어 그 키를 삭제합니다. 이 클래스에는 DB 조회 후 캐시를 채우는 전체 흐름은 없습니다. 실제 서비스에서 DB 변경 뒤 삭제가 실패하거나 다른 요청이 오래된 값을 다시 넣을 수 있으므로, 허용할 지연과 삭제 재시도·이벤트 전달 정책을 따로 정해야 합니다. 이 실습의 검사 대상은 put/get/invalidate의 키별 계약입니다." }, { "heading": "키 소유권과 환경", - "text": "각 실행은 java-edu: 아래 UUID 접두사를 사용하고 자신의 키만 지웁니다. FLUSHALL·FLUSHDB를 사용하지 않습니다. StringRedisTemplate으로 문자열 형식을 고정하며 학습 전용 loopback Redis가 준비되지 않으면 통합 검사를 수행한 것으로 표시하지 않습니다." + "text": "key는 생성자에 받은 prefix와 sku를 붙입니다. 테스트는 java-edu::처럼 실행마다 다른 접두사를 만들어 BOOK·PEN 키가 다른 실행과 섞이지 않게 합니다. 생성자는 java-edu:로 시작하고 :로 끝나는 형태를 확인하며 UUID 자체는 테스트가 만듭니다. StringRedisTemplate은 키와 값을 문자열로 다루므로 get은 Integer.parseInt로 수량을 복구합니다. FLUSHALL·FLUSHDB 같은 전체 삭제 대신 소유한 키만 지웁니다. 전용 로컬 Redis가 준비되지 않은 상태의 소스 제공을 실제 연동 검사 통과로 기록하지 않습니다." } ], "example": { "title": "StockCache 기준 구현", "code": "package edu.lab;\nimport java.time.Duration;\nimport java.util.OptionalInt;\nimport org.springframework.data.redis.core.StringRedisTemplate;\npublic final class StockCache {\n private final StringRedisTemplate redis; private final String prefix;\n public StockCache(StringRedisTemplate redis, String prefix) {\n if (prefix == null || !prefix.startsWith(\"java-edu:\") || !prefix.endsWith(\":\")) throw new IllegalArgumentException(\"training prefix\");\n this.redis = java.util.Objects.requireNonNull(redis); this.prefix = prefix;\n }\n public String key(String sku) { if (sku == null || sku.isBlank()) throw new IllegalArgumentException(\"sku\"); return prefix + sku; }\n public void put(String sku, int quantity, Duration ttl) {\n // BEGIN EXERCISE\n if (quantity < 0 || ttl == null || ttl.isZero() || ttl.isNegative()) throw new IllegalArgumentException(\"cache value or ttl\");\n redis.opsForValue().set(key(sku), Integer.toString(quantity), ttl);\n // END EXERCISE\n }\n public OptionalInt get(String sku) {\n // BEGIN EXERCISE\n String value = redis.opsForValue().get(key(sku));\n return value == null ? OptionalInt.empty() : OptionalInt.of(Integer.parseInt(value));\n // END EXERCISE\n }\n public void invalidate(String sku) {\n // BEGIN EXERCISE\n redis.delete(key(sku));\n // END EXERCISE\n }\n}\n", "output": "예상 검증 항목: 문자열 수량 저장·조회 · 양수 TTL 설정 · 지정 키만 무효화 · 잘못된 캐시 입력 거절. 아직 실행하지 않았습니다.", - "explanation": "기준 구현과 테스트 소스는 공개 학습 자료입니다. 실제 실행·의존성 해석·설치형 통합 검증 결과는 별도 증거로 표시합니다.", + "explanation": "put은 음수 quantity와 0 이하·누락 TTL을 거절한 뒤 Integer.toString(quantity)와 ttl을 Redis에 함께 넘깁니다. get은 값이 없으면 OptionalInt.empty(), 있으면 문자열을 정수로 바꿉니다. invalidate는 key(sku) 하나만 삭제하므로 BOOK을 지워도 같은 실행의 PEN은 남아야 합니다. 이 코드에는 원본 DB 변경이나 자동 재조회가 없습니다. 전용 Redis에서 이 동작을 실제로 확인하는 통합 검사는 현재 미검증이며, 기준 구현과 테스트 소스는 공개 학습 자료입니다. 실제 서비스 연결·설치형 통합 검증은 별도 증거가 필요합니다.", "files": [ { "path": "src/main/java/edu/lab/StockCache.java", @@ -64,7 +102,7 @@ "캐시 조회 값은 언제나 최신 DB 값과 같다." ], "correctIndex": 2, - "explanation": "TTL은 만료 정책입니다. DB와 Redis 두 저장소 사이의 쓰기 순서와 동시성 문제는 별도 계약으로 남습니다." + "explanation": "ttl은 put에 넘긴 값이 언제 만료될지 정합니다. 그 사이 DB에서 재고가 줄어도 이 클래스가 그 변경을 자동 전달받지는 않습니다. 따라서 캐시 만료와 DB·Redis 두 저장소의 변경을 함께 확정하는 문제는 별도입니다." }, { "id": "redis-cache-q2", @@ -76,20 +114,20 @@ "정리를 하지 않으면 항상 안전하다." ], "correctIndex": 1, - "explanation": "공유 저장소 전체를 지우지 않고 실행별 소유권을 제한해야 다른 데이터에 영향을 주지 않습니다." + "explanation": "key(\"BOOK\")은 현재 실행의 prefix와 BOOK을 합칩니다. 그 키만 지우면 PEN이나 다른 실행의 데이터는 보존할 수 있습니다. FLUSHALL은 이 소유 범위를 넘어 데이터를 지우므로 이 실습의 정리 방법이 아닙니다." } ], "practice": { "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "캐시의 TTL과 무효화 범위를 명시하고 Redis 데이터와 원본 DB의 일관성을 구분합니다.", + "scenario": "BOOK의 DB 재고를 바꾼 뒤 조회 캐시를 지우려 합니다. 같은 Redis에 PEN이나 다른 실행의 키도 있으므로, 갱신 대상 BOOK만 정확히 지워야 합니다.", "tasks": [ - "put은 0 이상 수량과 양수 TTL만 허용하며 문자열로 저장합니다.", - "get은 부재이면 OptionalInt.empty, 존재하면 수량을 반환합니다.", - "invalidate는 지정 SKU만 지우고 다른 키를 보존합니다.", - "전용 로컬 Redis와 JAVA_EDU_REDIS_HOST/JAVA_EDU_REDIS_PORT가 필요합니다. 자원이 없으면 단원 통합 검사는 미실행 상태입니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "put(\"BOOK\",5,양수 TTL)에서 5가 어떤 문자열로 저장되는지, 음수 수량과 0 TTL이 어디서 거절되는지 설명하세요.", + "get이 OptionalInt.empty()를 반환하는 경우와 저장된 수량 0을 반환하는 경우를 구분하세요.", + "만료 전 BOOK을 invalidate했을 때 BOOK은 없어지고 PEN은 남아야 하는 이유를 key와 prefix에 연결하세요.", + "실제 통합 검사에는 전용 로컬 Redis와 JAVA_EDU_REDIS_HOST·JAVA_EDU_REDIS_PORT가 필요합니다. 자원이 없으면 미실행으로 기록하며 전체 저장소 삭제 명령을 쓰지 않습니다.", + "DB 갱신 뒤 캐시 삭제에 실패한 상황과, 이번 키별 검사만으로 확인할 수 없는 최신성·장애 복구 범위를 적으세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "TTL과 키별 삭제는 캐시 수명을 관리합니다. 원본 DB와 언제 같은 값을 보장할지는 별도 흐름으로 설계하고 검사해야 합니다." }, "sources": [ { diff --git a/curriculum/professional/lessons/security-csrf/lesson.json b/curriculum/professional/lessons/security-csrf/lesson.json index 319c0a4..bc6e36f 100644 --- a/curriculum/professional/lessons/security-csrf/lesson.json +++ b/curriculum/professional/lessons/security-csrf/lesson.json @@ -1,7 +1,7 @@ { "id": "security-csrf", "title": "Spring Security의 인증·권한·CSRF", - "description": "인증 여부, 역할 권한, 브라우저 요청의 CSRF 방어를 서로 다른 실패 조건으로 검사합니다.", + "description": "로그인한 viewer가 주문을 만들면 왜 403일까요? 사용자 확인, 주문을 만들 권한, CSRF 토큰을 각각 만족시켜야 하는 POST /orders를 읽습니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,31 +10,65 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "로그인했다는 사실만으로 모든 상태 변경을 허용할 수 있는가?", - "미인증 401", - "역할 부족 403", - "CSRF 누락 403", - "허용된 요청 200" + "사용자가 누구인지 확인하는 인증과 그 행동을 허용하는 인가를 구분한다.", + "유효한 CSRF 토큰만 있는 미인증 요청이 401인 이유를 설명한다.", + "VIEWER 역할이나 누락된 CSRF 토큰이 각각 403을 만드는 조건을 찾는다.", + "BUYER와 유효한 CSRF를 갖춘 요청만 200이 되는 경로를 따라간다.", + "역할 검사와 특정 주문의 소유자 검사를 구분한다." ], + "learningGuide": { + "scenario": "viewer는 올바른 아이디와 비밀번호로 요청했고 CSRF 토큰도 보냈습니다. 그런데 주문 생성 요청이 403으로 거절됩니다.", + "question": "로그인은 확인됐는데 이 요청을 막는 코드가 또 있을까요?", + "terms": [ + { + "term": "인증", + "meaning": "요청자의 신원을 확인하는 과정입니다. 여기서는 HTTP Basic으로 교육용 계정 정보를 받습니다." + }, + { + "term": "인가", + "meaning": "그 사용자가 해당 행동을 해도 되는지 판단하는 과정입니다. hasRole(\"BUYER\")가 주문 요청의 역할을 검사합니다." + }, + { + "term": "CSRF 토큰", + "meaning": "브라우저를 이용한 위조 요청을 막기 위해 서버가 확인하는 값입니다. 이 예제의 POST에는 인증과 별도로 필요합니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/SecurityLab.java", + "source": " @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n // BEGIN EXERCISE\n http.authorizeHttpRequests(auth -> auth.requestMatchers(\"/orders\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n // END EXERCISE\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"buyer\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"viewer\").password(\"{noop}training\").roles(\"VIEWER\").build());" + }, + "walkthrough": [ + "authorizeHttpRequests의 requestMatchers(\"/orders\")가 검사할 경로를 고릅니다. hasRole(\"BUYER\")는 그 경로를 요청할 사용자에게 BUYER 역할을 요구합니다.", + "anyRequest().authenticated()는 앞 규칙에 잡히지 않은 다른 요청에도 인증을 요구합니다. /orders의 BUYER 조건을 없애는 줄이 아닙니다.", + "httpBasic은 인증 방식을, csrf는 기본 CSRF 방어를 설정합니다. http.build()가 이 설정을 적용할 보안 필터 구성을 만듭니다.", + "users의 buyer에는 BUYER, viewer에는 VIEWER 역할이 붙습니다. 따라서 viewer는 신원이 확인되고 토큰이 있어도 /orders의 BUYER 조건을 통과하지 못합니다." + ], + "prediction": { + "question": "POST /orders에 viewer의 올바른 인증 정보와 유효한 CSRF 토큰을 보내면 200일까요, 403일까요?", + "answer": "403입니다. viewer에게는 BUYER 역할이 없습니다.", + "explanation": "인증과 CSRF를 만족해도 hasRole(\"BUYER\") 검사는 남아 있습니다. users에서 viewer에게 준 역할은 VIEWER이므로 요청은 create에 들어가기 전에 거절됩니다." + }, + "takeaway": "로그인 성공은 모든 행동을 허용한다는 뜻이 아닙니다. 코드에서 각 허용 조건을 따로 읽습니다." + }, "theory": [ { "heading": "인증·권한·객체 소유권", - "text": "인증은 요청 주체를 확인하고 권한은 주체가 허용된 행동을 수행할 수 있는지 판단합니다. BUYER 역할이 있어도 다른 사용자의 주문을 취소할 권한까지 생기지는 않습니다. 이 단원은 URL 역할 권한을, 통합 과제는 객체 소유권을 검증합니다." + "text": "인증은 요청한 사람이 누구인지 확인하는 일이고, 인가는 확인된 사람이 이 행동을 해도 되는지 판단하는 일입니다. SecurityLab의 httpBasic은 테스트 계정 buyer와 viewer의 인증 정보를 받습니다. requestMatchers(\"/orders\").hasRole(\"BUYER\")는 /orders 요청에 BUYER 역할을 요구하는 인가 규칙입니다. viewer는 인증에 성공해도 이 역할이 없어 주문을 만들 수 없습니다. 또한 BUYER 역할만으로 다른 사람 주문까지 취소할 수는 없습니다. 특정 주문의 소유자 검사는 통합 과제에서 따로 다룹니다." }, { "heading": "CSRF와 인증 전송 방식", - "text": "브라우저가 자동으로 보내는 인증 정보가 있는 상태 변경 요청은 CSRF 위협을 검토해야 합니다. REST라는 이름만으로 CSRF를 끄지 않습니다. 이 예제는 HTTP Basic 테스트 계정과 기본 CSRF 보호를 함께 사용하며 실제 서비스의 HTTPS·계정 저장 정책은 별도입니다." + "text": "CSRF는 공격자가 사용자의 브라우저를 통해 사용자가 의도하지 않은 상태 변경 요청을 보내게 하는 공격입니다. 브라우저가 인증 정보를 자동으로 보내는 환경에서는 로그인 여부만으로 이를 가릴 수 없습니다. 이 예제의 csrf(Customizer.withDefaults())는 기본 방어를 유지하므로 POST /orders에 유효한 CSRF 토큰도 필요합니다. REST API라는 이름만으로 이 검사를 끄지 않습니다. HTTP Basic과 {noop}training 비밀번호는 로컬 교육용 설정이며 실제 서비스의 HTTPS와 비밀번호 저장 정책을 대신하지 않습니다." }, { "heading": "실패 순서의 관찰", - "text": "보안 필터는 컨트롤러보다 먼저 요청을 거절할 수 있습니다. 인증 테스트에 유효한 CSRF 토큰을 주고, CSRF 테스트에는 인증된 사용자를 주면 두 원인을 분리할 수 있습니다. 모든 실패가 하나의 상태 코드가 되리라고 가정하지 않습니다." + "text": "보안 필터는 컨트롤러의 create에 도착하기 전에 요청을 거절할 수 있습니다. 로그인 정보와 CSRF가 모두 없으면 먼저 실패한 검사만 보게 됩니다. 그래서 인증 검사는 csrf()를 붙인 미인증 요청으로, CSRF 검사는 buyer로 인증하되 토큰을 뺀 요청으로 만듭니다. 전자는 이 설정에서 401, 후자는 403입니다. viewer에 유효한 토큰을 주면 역할 부족 403을 따로 볼 수 있습니다. 설정 코드를 쓴 줄 순서를 실제 필터 실행 순서라고 단정하지 말고 각각의 실패 조건을 분리해 확인합니다." } ], "example": { "title": "SecurityLab 기준 구현", "code": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class SecurityLab {\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n // BEGIN EXERCISE\n http.authorizeHttpRequests(auth -> auth.requestMatchers(\"/orders\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n // END EXERCISE\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"buyer\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"viewer\").password(\"{noop}training\").roles(\"VIEWER\").build());\n }\n @RestController public static class Orders {\n @PostMapping(\"/orders\") public Map create() { return Map.of(\"status\",\"accepted\"); }\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 4개 JUnit 테스트 통과.\n검증 항목: 미인증 401 · 역할 부족 403 · CSRF 누락 403 · 허용된 요청 200.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "security 메서드는 /orders에 BUYER 역할을 요구하고 HTTP Basic 인증과 기본 CSRF 보호를 켭니다. users는 BUYER인 buyer와 VIEWER인 viewer를 만듭니다. create는 보안 검사를 통과한 뒤에만 status=accepted를 반환합니다. 그래서 테스트는 미인증·역할 부족·토큰 누락을 따로 만들고, buyer와 유효한 토큰이 함께 있을 때 200을 확인합니다. 이 컨트롤러에는 실제 주문 저장이나 소유권 검사 코드가 없습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", "files": [ { "path": "src/main/java/edu/lab/SecurityLab.java", @@ -59,7 +93,7 @@ "인증 검사는 유효한 CSRF와 함께, CSRF 검사는 인증된 요청으로 수행한다." ], "correctIndex": 3, - "explanation": "여러 방어가 동시에 실패하면 먼저 동작한 필터만 관찰할 수 있습니다. 나머지 전제는 만족시켜 검사 대상을 분리합니다." + "explanation": "미인증 요청에 CSRF까지 없으면 CSRF 검사가 먼저 막아 인증 실패를 제대로 관찰하지 못할 수 있습니다. csrf()로 한 조건을 만족시킨 뒤 인증만 빼거나, buyer 인증을 준 뒤 CSRF만 빼야 무엇이 401·403을 만들었는지 알 수 있습니다." }, { "id": "security-csrf-q2", @@ -71,20 +105,20 @@ "URL에 주문 ID가 있으면 권한이 있다고 본다." ], "correctIndex": 1, - "explanation": "역할 기반 권한과 특정 자원에 대한 권한은 서로 다른 계약입니다." + "explanation": "hasRole(\"BUYER\")는 이 사용자가 구매자 역할인지 검사할 뿐 어느 주문의 주인인지는 모릅니다. 취소할 주문의 소유자와 인증된 사용자를 별도로 비교해야 합니다. CSRF 토큰은 이 소유권 판단을 대신하지 않습니다." } ], "practice": { "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "인증 여부, 역할 권한, 브라우저 요청의 CSRF 방어를 서로 다른 실패 조건으로 검사합니다.", + "scenario": "POST /orders가 403을 반환했다는 문의를 받았습니다. 계정의 역할이 부족한 경우와 CSRF 토큰이 빠진 경우가 같은 코드로 보일 수 있으므로 재현 요청을 나누어 원인을 찾습니다.", "tasks": [ - "POST /orders는 BUYER 역할만 허용하고 CSRF 토큰도 요구합니다.", - "유효한 CSRF와 미인증 요청은 401, 인증했지만 CSRF가 없으면 403입니다.", - "VIEWER는 CSRF가 유효해도 403, BUYER와 유효 CSRF는 200입니다.", - "training 비밀번호는 이 로컬 테스트의 가상 계정 값이며 실제 자격증명을 사용하지 않습니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "csrf()만 붙이고 인증하지 않은 요청의 401을 확인할 때, CSRF 조건을 먼저 만족시키는 이유를 설명하세요.", + "viewer와 유효한 CSRF로 요청하면 users의 어느 역할 설정과 hasRole의 어떤 조건이 충돌하는지 찾으세요.", + "buyer로 인증해도 CSRF를 빼면 403인 이유, 둘 다 갖추면 200인 이유를 각각 말하세요.", + "training은 가상 계정의 로컬 테스트 비밀번호입니다. 실제 자격증명으로 바꾸지 말고, 운영 계정 저장·HTTPS·토큰 전달은 별도 설계 대상으로 적으세요.", + "이번 검사가 URL의 역할 권한까지라는 점을 적고, 다른 사람의 주문 취소를 막으려면 필요한 소유권 검사를 통합 과제와 연결하세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "401·403만 보고 원인을 단정하지 않습니다. 인증, 역할, CSRF를 한 조건씩 바꾼 요청으로 확인합니다." }, "sources": [ { diff --git a/curriculum/professional/lessons/spring-aop/lesson.json b/curriculum/professional/lessons/spring-aop/lesson.json index 356cb36..9e2028e 100644 --- a/curriculum/professional/lessons/spring-aop/lesson.json +++ b/curriculum/professional/lessons/spring-aop/lesson.json @@ -1,7 +1,7 @@ { "id": "spring-aop", "title": "프록시·AOP 호출 경로의 반례", - "description": "프록시를 경유하는 호출과 객체 내부 호출을 실행 모델로 구분합니다.", + "description": "reserve를 직접 부르면 기록되는데 reserveTwice 안의 두 호출은 기록되지 않습니다. 같은 메서드 이름이어도 프록시를 통과하는 경로가 다른 이유를 따라갑니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,25 +10,25 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "같은 메서드라도 호출 경로가 다르면 advice가 적용되는가?", - "외부 호출 advice", - "자기 호출 우회 반례" + "호출 앞에서 추가 동작을 수행하는 프록시와 실제 Orders 객체를 구분한다.", + "프록시를 통한 reserve 호출에서 beforeReserve가 한 번 실행되는 이유를 설명한다.", + "reserveTwice 안의 reserve 호출 두 번이 calls를 늘리지 않는 이유를 설명한다." ], "theory": [ { "heading": "프록시가 가로채는 경계", - "text": "Spring AOP는 프록시 기반 호출 경계에서 동작합니다. 외부 호출과 대상 객체 내부의 this 호출은 같은 경로가 아닙니다." + "text": "프록시는 호출자가 실제 대상 객체에 도달하기 전에 거치는 대신 호출해 주는 객체입니다. 여기서 경계는 “호출이 프록시로 들어오는 지점”을 뜻합니다. proxied는 new Orders()를 대상으로 AspectJProxyFactory를 만들고 TraceAspect를 붙인 뒤 프록시를 반환합니다. @Before가 가리키는 advice는 대상 메서드 앞에 추가할 동작이며 이 예제에서는 beforeReserve의 calls++입니다.\n\n호출자가 반환된 프록시의 reserve()를 부르면 이름 reserve에 맞는 규칙이 적용되어 beforeReserve가 먼저 실행됩니다. 반면 reserveTwice()는 규칙이 정한 이름에 해당하지 않습니다. 그 안에서 실행되는 reserve(); reserve();는 실제 Orders 객체 자신을 향하는 호출이라 프록시로 다시 들어가지 않습니다. 따라서 새 TraceAspect에서 시작한 reserveTwice 호출은 calls가 0입니다." }, { "heading": "어노테이션보다 호출 경로", - "text": "트랜잭션이나 관측 기능을 적용할 때 표시만 보고 판단하지 말고 생성된 빈·프록시·호출자를 확인합니다. 필요하면 역할을 별도 협력 객체로 나눕니다. 강제 self 주입을 모든 문제의 기본 해법으로 사용하지 않습니다." + "text": "“메서드에 표시를 붙였으니 어디서 불러도 적용된다”라고 읽으면 이 차이를 놓칩니다. 호출자가 가진 것이 new Orders()인지 proxied(trace)의 반환값인지, 대상 메서드 내부에서 다시 부르는지 확인해야 합니다. 이 예제는 프록시 방식의 Spring AOP를 사용하며 AspectJProxyFactory라는 이름 때문에 바이트코드 전체를 바꾸는 AspectJ weaving과 같은 방식이라고 생각하면 안 됩니다.\n\n실무에서 트랜잭션이나 실행 시간 측정을 붙일 때도 실제 호출 경로를 따라가야 합니다. 내부 호출에도 추가 처리가 필요하다면 그 역할을 별도 객체로 분리해 프록시를 거치도록 하는 설계를 검토할 수 있습니다. 자기 자신을 강제로 다시 주입하는 방식을 기본 해법으로 삼기 전에 객체의 역할이 지나치게 묶여 있지 않은지 살펴봅니다." } ], "example": { "title": "AopLab 기준 구현", "code": "package edu.lab;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.aspectj.lang.annotation.Before;\nimport org.springframework.aop.aspectj.annotation.AspectJProxyFactory;\npublic final class AopLab {\n public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } }\n @Aspect public static class TraceAspect {\n private int calls;\n @Before(\"execution(* reserve(..))\") public void beforeReserve() { calls++; }\n public int calls() { return calls; }\n }\n public static Orders proxied(TraceAspect trace) {\n // BEGIN EXERCISE\n AspectJProxyFactory factory=new AspectJProxyFactory(new Orders());\n factory.addAspect(trace);\n return factory.getProxy();\n // END EXERCISE\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 외부 호출 advice · 자기 호출 우회 반례.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "proxied(trace)는 새 Orders 인스턴스를 그대로 돌려주지 않고 TraceAspect를 붙인 프록시를 반환합니다. 그 프록시의 reserve()를 호출하면 @Before의 이름 조건과 맞아 calls가 1 증가합니다. reserveTwice()는 이름 조건에 맞지 않고, 본문 안의 reserve 두 번도 대상 객체 내부 호출이라 프록시를 다시 통과하지 않습니다. 두 테스트는 각각 새 TraceAspect를 사용하므로 기대값이 1과 0입니다. reserve 본문이 비어 있는 이유는 예약 기능이 아니라 호출 경로를 관찰하기 위해서입니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", "files": [ { "path": "src/main/java/edu/lab/AopLab.java", @@ -53,7 +53,7 @@ "모든 메서드가 final이기 때문이다." ], "correctIndex": 2, - "explanation": "기본 Spring AOP 프록시는 프록시를 거친 진입을 가로챕니다. 대상 객체 내부의 this 호출은 그 진입 지점을 우회합니다." + "explanation": "reserveTwice 자체는 이름 reserve에 해당하지 않아 이 @Before의 대상이 아닙니다. 그 본문이 부르는 reserve 두 번은 같은 Orders 객체 안에서 바로 실행되어 프록시의 가로채는 지점으로 돌아오지 않습니다. 그래서 새 trace의 calls는 0으로 남습니다." }, { "id": "spring-aop-q2", @@ -65,18 +65,19 @@ "로그 출력 순서를 수동으로 기억한다." ], "correctIndex": 1, - "explanation": "프록시 생성과 실제 조언 적용은 다른 사실입니다. 호출 경로에 따라 달라지는 관찰 결과를 별도로 검증합니다." + "explanation": "프록시가 만들어졌어도 기대한 호출이 그 프록시를 거쳤는지는 별도 문제입니다. 기존 두 테스트처럼 새 trace로 외부 reserve를 실행해 1을 확인하고, 또 다른 새 trace로 reserveTwice를 실행해 0을 확인하면 경로 차이가 실제 관찰값으로 드러납니다." } ], "practice": { - "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "프록시를 경유하는 호출과 객체 내부 호출을 실행 모델로 구분합니다.", + "title": "기록이 빠진 예약 호출의 경로 찾기", + "scenario": "개별 reserve 호출은 기록되는데 묶음 메서드 reserveTwice를 통해 실행하면 기록이 없습니다. 애너테이션 이름만 확인하는 대신 호출자가 가진 객체부터 대상 내부 호출까지 추적합니다.", "tasks": [ - "proxied(TraceAspect)를 AspectJProxyFactory로 조립하세요.", - "외부 reserve 호출은 advice를 한 번 거칩니다. reserveTwice의 내부 reserve 호출들은 이 프록시 advice를 거치지 않는 반례를 확인하세요.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "proxied(TraceAspect)에서 AspectJProxyFactory에 Orders 대상을 넣고 addAspect 뒤 getProxy를 반환하세요.", + "새 trace로 외부 reserve 호출을 실행했을 때 beforeReserve가 한 번 호출되는 경로를 설명하세요.", + "다른 새 trace로 reserveTwice를 실행했을 때, 메서드 이름 조건과 대상 내부 호출을 각각 짚어 calls가 0인 이유를 설명하세요.", + "실제 기능에서 내부 호출도 기록해야 한다면 어느 역할을 별도 객체로 옮길지 설계해 보세요. 현재 빈 reserve 메서드의 테스트가 실제 예약이나 트랜잭션을 검증하는 것은 아닙니다." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "추가 동작이 실행되는지 판단하려면 “어떤 애너테이션인가”와 함께 “호출이 어떤 객체를 통과했는가”를 봅니다. 외부 호출과 내부 호출을 따로 재현하면 빠진 처리를 찾기 쉽습니다." }, "sources": [ { @@ -155,5 +156,44 @@ "starterSatisfied": false, "sourceFingerprint": "203bd280bfe19ad5cbaef8117c8ab0d00b95d07fe7f1a1f2f5988a187b82bad9", "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "reserve를 한 번 직접 호출하면 기록 횟수는 1입니다. 그런데 reserveTwice가 reserve를 두 번 실행해도 기록 횟수는 0입니다. 실행 횟수와 기록 횟수가 왜 다른지 읽어 봅니다.", + "question": "reserveTwice 안의 두 reserve 호출은 어느 객체를 향하며 프록시를 다시 통과할까요?", + "terms": [ + { + "term": "프록시", + "meaning": "대상 객체 앞에서 호출을 받아 추가 처리를 한 뒤 대상으로 전달하는 객체입니다. proxied의 getProxy() 반환값이 해당합니다." + }, + { + "term": "advice", + "meaning": "가로챈 호출에 덧붙이는 동작입니다. 이 예제의 beforeReserve는 calls를 1 늘립니다." + }, + { + "term": "pointcut", + "meaning": "어떤 호출에 추가 동작을 붙일지 고르는 조건입니다. execution(* reserve(..))는 이 예제에서 reserve를 고릅니다." + }, + { + "term": "자기 호출", + "meaning": "대상 객체의 메서드가 같은 객체의 다른 메서드를 바로 부르는 일입니다. reserveTwice 안의 reserve();가 그 예입니다." + } + ], + "walkthrough": [ + "proxied(trace)는 new Orders()를 대상으로 팩터리를 만들고 addAspect(trace)로 기록 동작을 등록합니다.", + "factory.getProxy()가 반환한 객체에 reserve()를 호출하면 프록시가 그 호출을 먼저 받습니다.", + "이름 reserve가 @Before의 조건에 맞으므로 beforeReserve가 calls를 늘린 뒤 실제 Orders.reserve가 실행됩니다.", + "reserveTwice()로 들어오면 메서드 이름이 조건과 다르므로 이 advice는 실행되지 않습니다. 본문은 실제 Orders 객체에서 실행됩니다.", + "본문 안의 reserve(); reserve();는 같은 대상 내부 호출입니다. 프록시를 다시 통과하지 않으므로 두 번 실행돼도 calls는 늘지 않습니다." + ], + "prediction": { + "question": "새 TraceAspect로 만든 같은 프록시에서 reserve()를 한 번, 이어서 reserveTwice()를 한 번 부르면 최종 calls()는 얼마인가요?", + "answer": "1입니다.", + "explanation": "첫 외부 reserve 호출에서만 beforeReserve가 실행됩니다. 이어지는 reserveTwice의 진입과 내부 reserve 두 번은 이 advice를 추가로 실행하지 않습니다. calls가 자동으로 초기화되는 것도 아닙니다." + }, + "takeaway": "대상 메서드의 실행 횟수와 advice의 실행 횟수는 다를 수 있습니다. 이 프록시 모델에서는 프록시를 거치는 진입을 기준으로 추가 처리를 판단합니다.", + "code": { + "file": "src/main/java/edu/lab/AopLab.java", + "source": " public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } }\n @Aspect public static class TraceAspect {\n private int calls;\n @Before(\"execution(* reserve(..))\") public void beforeReserve() { calls++; }\n public int calls() { return calls; }\n }\n public static Orders proxied(TraceAspect trace) {\n // BEGIN EXERCISE\n AspectJProxyFactory factory=new AspectJProxyFactory(new Orders());\n factory.addAspect(trace);\n return factory.getProxy();\n // END EXERCISE\n }\n}\n" + } } } diff --git a/curriculum/professional/lessons/spring-di/lesson.json b/curriculum/professional/lessons/spring-di/lesson.json index fbab735..a39903b 100644 --- a/curriculum/professional/lessons/spring-di/lesson.json +++ b/curriculum/professional/lessons/spring-di/lesson.json @@ -1,7 +1,7 @@ { "id": "spring-di", "title": "Spring IoC·DI와 객체 수명", - "description": "Spring의 빈 조립과 서비스의 업무 책임을 분리합니다.", + "description": "주문 서비스에 배송비 계산기를 누가 만들고 전달하는지 따라갑니다. 29,999원과 30,000원 주문을 계산하며 Spring의 객체 조립과 서비스의 금액 규칙을 구분합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,26 +10,26 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "컨테이너가 대신하는 일과 도메인이 책임질 일은 무엇인가?", - "컨테이너 조립", - "정책 계약", - "기본 singleton 확인" + "Config.policy의 반환 객체가 OrderService 생성자까지 전달되는 경로를 설명한다.", + "Spring이 PricePolicy와 OrderService를 조립하는 부분을 찾는다.", + "total(29999)와 total(30000)의 배송비·최종 금액을 계산한다.", + "같은 컨테이너에서 공유하는 singleton 빈과 요청별 값을 구분한다." ], "theory": [ { "heading": "IoC와 생성자 주입", - "text": "컨테이너가 객체 생성·조립을 관리해도 업무 불변식은 서비스와 도메인이 책임집니다. 생성자 주입은 필수 협력을 드러내고 대역을 넣어 테스트하기 쉽게 합니다." + "text": "OrderService는 배송비를 계산할 PricePolicy가 필요합니다. 생성자의 policy 인수로 필요한 객체를 밖에서 받는 것이 생성자 주입입니다. 이 예제에서 Config.policy()는 “소계 30000 이상이면 0, 미만이면 3000”을 반환하는 정책 객체를 만들고, Spring은 이 객체를 Config.orders(PricePolicy policy)의 인수로 전달합니다. orders는 new OrderService(policy)로 둘을 연결합니다. Spring이 이런 생성·연결을 관리하는 방식을 IoC라고 부르고, 관리 대상 객체를 빈(bean)이라고 부릅니다.\n\n객체가 연결됐다고 모든 주문이 유효해지는 것은 아닙니다. total(-1)을 거절하는 규칙은 OrderService.total에 남아 있습니다. 생성자가 policy를 받으므로 기존 테스트처럼 배송비 0인 대체 정책을 전달해 Spring 없이도 이 음수 검사를 확인할 수 있습니다." }, { "heading": "스코프와 가변 상태", - "text": "기본 singleton 빈은 컨테이너 내에서 공유됩니다. singleton이라는 사실이 필드 변경의 스레드 안전성을 보장하지 않습니다. 사용자별 요청 상태를 무심코 서비스 필드에 저장하지 않습니다." + "text": "기본 singleton 스코프는 같은 컨테이너의 같은 빈 정의를 조회할 때 한 객체를 공유한다는 뜻입니다. 기존 테스트가 OrderService를 getBean으로 두 번 조회하고 isSameAs로 비교하는 이유입니다. JVM 전체에 OrderService 하나만 존재한다는 의미는 아닙니다.\n\n공유 객체의 필드에 “현재 주문의 소계”를 저장하면 동시에 들어온 두 주문이 같은 필드를 덮어쓸 수 있습니다. 여기서는 subtotal을 total의 매개변수로 받고 요청값을 필드에 저장하지 않습니다. final policy는 정책 참조를 고정할 뿐, 전달된 정책 객체의 내부 변경까지 금지하지는 않습니다. singleton 설정 자체가 동시 접근을 안전하게 만들어 주는 것은 아니므로 정책의 상태도 함께 살펴야 합니다." } ], "example": { "title": "DiLab 기준 구현", "code": "package edu.lab;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\npublic final class DiLab {\n public interface PricePolicy { int fee(int subtotal); }\n public static final class OrderService {\n private final PricePolicy policy;\n public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); }\n public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); }\n }\n @Configuration\n public static class Config {\n @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; }\n @Bean public OrderService orders(PricePolicy policy) {\n // BEGIN EXERCISE\n return new OrderService(policy);\n // END EXERCISE\n }\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 컨테이너 조립 · 정책 계약 · 기본 singleton 확인.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "먼저 Config.policy()가 배송비 규칙을 담은 PricePolicy를 만듭니다. Spring이 orders의 policy 인수로 그 객체를 전달하면 new OrderService(policy)가 필드에 보관합니다. total(29999)은 policy.fee에서 3000을 받아 32999를 반환하고, total(30000)은 배송비 0을 더해 30000을 반환합니다. subtotal < 0 검사는 서비스가 직접 맡습니다. 기존 테스트는 이 계산과 동일 컨테이너에서의 OrderService 공유를 각각 확인합니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", "files": [ { "path": "src/main/java/edu/lab/DiLab.java", @@ -54,7 +54,7 @@ "순환 의존성이 모두 올바른 구조로 바뀐다." ], "correctIndex": 1, - "explanation": "생성자는 필수 협력 관계를 명확히 하지만 협력 객체의 동시성·수명·설계 적합성까지 자동으로 해결하지는 않습니다." + "explanation": "OrderService(PricePolicy policy)를 보면 이 서비스가 가격 정책 없이는 만들어질 수 없다는 점이 드러납니다. 테스트에서는 배송비 0인 정책을 직접 전달할 수 있습니다. 이렇게 연결을 바꾸기 쉬워져도 그 정책의 가변 상태나 동시 접근까지 생성자가 해결하지는 않습니다." }, { "id": "spring-di-q2", @@ -66,18 +66,19 @@ "필드가 자동으로 불변이 된다." ], "correctIndex": 1, - "explanation": "스코프는 인스턴스 수명과 공유 범위입니다. 공유되는 가변 상태의 동시성은 개발자가 설계해야 합니다." + "explanation": "같은 AnnotationConfigApplicationContext에서 같은 OrderService 빈을 두 번 조회하면 기본 스코프에서는 같은 객체를 받습니다. 다른 컨테이너의 객체까지 같은 것은 아닙니다. 또한 같은 객체를 공유한다는 것은 필드도 공유한다는 뜻이므로 요청별 값을 필드에 넣을지는 별도로 설계해야 합니다." } ], "practice": { - "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "Spring의 빈 조립과 서비스의 업무 책임을 분리합니다.", + "title": "배송비 정책을 받아 주문 금액 계산하기", + "scenario": "주문 서비스는 소계에 배송비를 더합니다. 실제 설정에서는 30,000원부터 무료 배송을 적용하고, 서비스 자체의 입력 검사는 Spring을 띄우지 않고도 확인하려 합니다.", "tasks": [ - "Config의 @Bean 메서드로 PricePolicy와 OrderService를 조립하세요.", - "PricePolicy는 소계 30000 이상 배송비 0, 미만 3000입니다. OrderService는 생성자로 정책을 받습니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "Config의 @Bean 메서드에서 PricePolicy를 받아 new OrderService(policy)로 연결하세요. Spring이 전달하는 곳과 생성자가 보관하는 곳을 구분하세요.", + "소계 29999와 30000에서 각각의 배송비와 total 결과를 계산해 기존 테스트와 대조하세요.", + "기존 domainChecksRemainInService가 대체 정책으로 OrderService를 직접 만든 뒤 total(-1)을 검사하는 이유를 설명하세요.", + "현재 subtotal이 매개변수인 점을 보고, 이 값을 공유 빈의 필드에 저장할 때 두 주문이 서로 덮어쓸 수 있는 상황을 설명하세요. 기존 singleton 검사는 동시성 검사가 아닙니다." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "Spring은 필요한 객체를 만들어 연결하고, OrderService는 받은 정책을 사용해 주문 규칙을 지킵니다. 누가 객체를 조립하는지와 누가 값을 검사하는지 나눠 읽으면 테스트와 수명 문제가 분명해집니다." }, "sources": [ { @@ -157,5 +158,44 @@ "starterSatisfied": false, "sourceFingerprint": "092bd10703609e7a73d032f092ed2ace7c53fdf37819c64a4c41504b156ed844", "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "배송비 계산이 여러 서비스에 흩어져 정책을 바꾸기 어렵습니다. OrderService에는 계산기를 전달하고 객체 연결은 Config에 모으려 합니다.", + "question": "Config에서 만든 PricePolicy가 어떤 순서로 OrderService.total의 계산에 쓰일까요?", + "terms": [ + { + "term": "주입(DI)", + "meaning": "필요한 객체를 외부에서 전달하는 방식입니다. OrderService가 내부에서 정책을 만들지 않고 생성자의 policy로 받습니다." + }, + { + "term": "빈과 컨테이너", + "meaning": "빈은 Spring이 생성·관리하는 객체이고 컨테이너는 그 객체들을 만들고 연결하는 관리 기능입니다. 여기서는 PricePolicy와 OrderService가 빈입니다." + }, + { + "term": "IoC", + "meaning": "이 예제에서는 객체 생성과 연결을 각 서비스 대신 Spring에 맡기는 뜻입니다. Config의 @Bean 메서드가 연결 방법을 제공합니다." + }, + { + "term": "singleton 스코프", + "meaning": "같은 컨테이너에서 한 빈 정의의 인스턴스를 공유하는 규칙입니다. getBean으로 OrderService를 다시 찾아도 같은 객체를 받습니다." + } + ], + "walkthrough": [ + "Config.policy()가 PricePolicy를 만듭니다. 이 정책의 fee는 subtotal이 30000 이상이면 0, 그보다 작으면 3000을 돌려줍니다.", + "Spring은 orders(PricePolicy policy)를 호출할 때 관리 중인 PricePolicy를 인수로 전달합니다.", + "orders의 new OrderService(policy)가 서비스 생성자를 호출하고, 생성자는 그 객체를 this.policy에 저장합니다.", + "OrderService.total(29999)은 음수 검사를 통과한 다음 policy.fee(29999)의 3000을 Math.addExact로 더해 32999를 반환합니다.", + "같은 서비스의 total(30000)은 배송비 0을 더합니다. 다른 요청의 subtotal은 매개변수로 전달되며 서비스 필드에 누적되지 않습니다." + ], + "prediction": { + "question": "기본 Config로 조립한 서비스에서 total(29999)와 total(30000)은 각각 얼마인가요?", + "answer": "32999와 30000입니다.", + "explanation": "무료 배송 조건은 subtotal >= 30000입니다. 기준에 1원 모자란 첫 주문에는 3000이 붙고 두 번째 주문에는 붙지 않습니다. Spring은 정책 객체를 전달하며 이 판단은 fee에서 합니다." + }, + "takeaway": "주입이라는 말은 이 코드에서 “PricePolicy를 생성자에 전달한다”는 구체적인 동작입니다. 전달받은 뒤의 금액 검사와 계산은 서비스 코드에서 확인합니다.", + "code": { + "file": "src/main/java/edu/lab/DiLab.java", + "source": " public interface PricePolicy { int fee(int subtotal); }\n public static final class OrderService {\n private final PricePolicy policy;\n public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); }\n public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); }\n }\n @Configuration\n public static class Config {\n @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; }\n @Bean public OrderService orders(PricePolicy policy) {\n // BEGIN EXERCISE\n return new OrderService(policy);\n // END EXERCISE\n }\n }\n}\n" + } } } diff --git a/curriculum/professional/lessons/sql-jdbc/lesson.json b/curriculum/professional/lessons/sql-jdbc/lesson.json index 49c5bd9..48b444f 100644 --- a/curriculum/professional/lessons/sql-jdbc/lesson.json +++ b/curriculum/professional/lessons/sql-jdbc/lesson.json @@ -1,7 +1,7 @@ { "id": "sql-jdbc", "title": "SQL·JDBC로 재고 변경과 롤백", - "description": "재고 감소와 예약 기록을 같은 DB 트랜잭션으로 묶고 SQL 실패 후 상태를 확인합니다.", + "description": "재고를 먼저 줄인 뒤 주문 저장이 실패하면 줄인 재고도 되돌려야 합니다. 같은 Connection의 UPDATE·INSERT·commit·rollback을 따라 두 변경이 함께 남는 이유를 확인합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,31 +10,31 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "두 개의 SQL 중 하나가 실패하면 어떤 상태가 남아야 하는가?", - "성공 시 두 변경 커밋", - "재고 부족 상태 보존", - "중복 키 실패 재고 롤백", - "잘못된 입력 거절" + "재고 감소 뒤 주문 INSERT가 실패하는 상황에서 남아야 할 DB 값을 설명한다.", + "재고 5에서 2개 예약이 성공하면 재고 3과 주문 한 행이 함께 저장되게 한다.", + "조건부 UPDATE의 영향 행 수로 재고 부족을 판단하고 기존 상태를 보존한다.", + "중복 orderId 실패가 앞선 재고 감소까지 롤백하는 경로를 설명한다.", + "잘못된 입력을 DB 연결 전에 거절하고 값 바인딩과 SQL 문자열 조합을 구분한다." ], "theory": [ { "heading": "불변식은 DB 변경 조건에 남긴다", - "text": "재고를 읽고 자바에서 비교한 뒤 갱신하면 두 요청 사이에 값이 바뀔 수 있습니다. stock >= quantity 조건을 UPDATE에 포함하고 영향 행 수로 성공을 판단합니다. 이 예제는 단일 DB 안의 원자성을 다루며 분산 예약 시스템을 보장하지 않습니다." + "text": "지켜야 할 규칙, 즉 불변식은 “재고가 음수가 되지 않고, 예약이 성공했다면 주문 기록과 감소된 재고가 함께 남는다”입니다. 먼저 stock을 SELECT하고 자바에서 충분하다고 판단한 뒤 UPDATE하면 그 사이 다른 요청이 재고를 바꿀 수 있습니다. 이 예제는 stock >= ?를 UPDATE 조건에 넣어, DB가 조건을 만족하는 행의 재고만 줄이게 합니다.\n\nexecuteUpdate가 돌려준 changed는 영향을 받은 행 수입니다. 테스트의 inventory는 sku가 기본 키이므로 BOOK 행은 최대 한 건입니다. changed가 0이면 조건에 맞는 행이 없다는 뜻이며, 준비된 BOOK 행에서 재고가 부족한 경우가 해당합니다. 코드는 rollback 후 false를 반환합니다. 이 방식의 동작을 운영 DB에 적용할 때는 해당 DB의 잠금·격리 수준도 확인해야 하며 여기서 모든 DB의 동시 예약을 검증한 것은 아닙니다." }, { "heading": "자원 수명과 실패 소유권", - "text": "Connection·PreparedStatement는 try-with-resources로 닫습니다. 자동 커밋을 끈 연결에서 감소와 INSERT를 수행하고 성공 시 커밋합니다. SQL 예외 시 롤백을 시도하되 롤백 실패는 원래 예외의 suppressed로 보존합니다." + "text": "트랜잭션은 여러 변경을 함께 확정하거나 함께 되돌리는 단위입니다. setAutoCommit(false) 뒤 같은 connection으로 재고 UPDATE와 orders INSERT를 실행하고, 둘 다 성공해야 commit합니다. 테스트처럼 재고 5에서 A 주문 2개를 예약하면 3이 됩니다. 이어 A로 1개를 또 예약하면 UPDATE는 잠시 2로 줄이지만 중복 기본 키로 INSERT가 실패합니다. catch에서 같은 연결을 rollback하면 두 번째 시도의 재고 감소가 취소되어 3으로 돌아갑니다.\n\nConnection과 두 PreparedStatement는 try-with-resources로 사용 후 닫습니다. 실패 처리 중 rollback도 실패하면 원래 failure에 suppressed 예외로 덧붙여 처음 원인을 보존합니다. commit 도중 통신이 끊긴 경우에는 호출자에게 예외가 왔다고 DB가 반드시 미커밋이라고 단정할 수 없습니다. 이 코드는 롤백을 시도하고 오류를 전달하며, 확정 여부 조회·재시도 정책까지 제공하지는 않습니다." }, { "heading": "바인딩과 값의 의미", - "text": "PreparedStatement의 ?에는 값을 바인딩합니다. 테이블 이름이나 정렬 방향 같은 SQL 문법을 값 파라미터로 바꿀 수 없습니다. 허용 목록과 별도 쿼리 선택이 필요한 경계를 구분합니다." + "text": "PreparedStatement의 ?는 SQL 문법을 넣는 자리가 아니라 값을 전달하는 자리입니다. UPDATE의 첫 번째 ?에는 줄일 quantity를, 두 번째 ?에는 최소 필요 재고인 같은 quantity를 setInt로 넣습니다. INSERT에는 setString으로 orderId, setInt로 quantity를 넣습니다. 이렇게 쿼리 형태와 입력값을 구분합니다.\n\n테이블 이름이나 ORDER BY 방향을 사용자 입력으로 선택하려는 상황은 다릅니다. ?에 테이블 이름을 값으로 넣어 SQL 구조를 바꿀 수 없으므로 허용된 쿼리 중에서 선택하는 별도 설계가 필요합니다. 이 예제는 BOOK 한 품목과 고정된 두 테이블만 사용하며, 실행계획이나 운영 데이터 규모의 성능을 측정한 예제가 아닙니다." } ], "example": { "title": "JdbcReservations 기준 구현", "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\nimport java.util.Objects;\npublic final class JdbcReservations {\n private final DataSource dataSource;\n public JdbcReservations(DataSource dataSource) { this.dataSource = Objects.requireNonNull(dataSource); }\n public boolean reserve(String orderId, int quantity) throws SQLException {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || quantity <= 0) throw new IllegalArgumentException(\"invalid reservation\");\n try (Connection connection = dataSource.getConnection()) {\n connection.setAutoCommit(false);\n try {\n int changed;\n try (PreparedStatement update = connection.prepareStatement(\"update inventory set stock = stock - ? where sku = 'BOOK' and stock >= ?\")) {\n update.setInt(1, quantity); update.setInt(2, quantity); changed = update.executeUpdate();\n }\n if (changed == 0) { connection.rollback(); return false; }\n try (PreparedStatement insert = connection.prepareStatement(\"insert into orders(id, quantity) values (?, ?)\")) {\n insert.setString(1, orderId); insert.setInt(2, quantity); insert.executeUpdate();\n }\n connection.commit(); return true;\n } catch (SQLException | RuntimeException failure) {\n try { connection.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); }\n throw failure;\n }\n }\n // END EXERCISE\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 4개 JUnit 테스트 통과.\n검증 항목: 성공 시 두 변경 커밋 · 재고 부족 상태 보존 · 중복 키 실패 재고 롤백 · 잘못된 입력 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "reserve는 orderId·quantity를 먼저 검사하고 하나의 connection에서 자동 커밋을 끕니다. 조건부 UPDATE가 재고를 줄이고 changed가 0이면 주문 INSERT 없이 false로 끝납니다. 1이면 같은 연결로 주문을 넣고 마지막에 commit해 true를 반환합니다. 중복 주문 ID 등으로 INSERT가 실패하면 catch가 앞선 UPDATE까지 rollback하고 예외를 다시 던집니다. 테스트의 재고 5에서 A·2개 예약 성공 후 A·1개 예약이 실패하면 최종 재고는 3입니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", "files": [ { "path": "src/main/java/edu/lab/JdbcReservations.java", @@ -59,7 +59,7 @@ "예외를 무시하고 커밋한다." ], "correctIndex": 1, - "explanation": "두 변경은 하나의 업무 상태 전이입니다. 같은 연결의 트랜잭션을 롤백해야 예약 기록 없는 재고 감소를 남기지 않습니다." + "explanation": "재고 5에서 A·2개 예약을 성공시켜 3이 된 뒤, 같은 A로 1개를 다시 예약하면 UPDATE 직후 재고는 아직 확정되지 않은 2입니다. INSERT의 중복 키 실패 때 같은 connection을 rollback해야 그 시도만 취소되어 3으로 복원됩니다. false만 반환하거나 예외를 삼키면 이 두 변경을 함께 취급한다는 규칙이 깨집니다." }, { "id": "sql-jdbc-q2", @@ -71,20 +71,20 @@ "SQL 전체 문장" ], "correctIndex": 2, - "explanation": "파라미터는 값의 위치를 표현합니다. SQL 식별자와 문법은 허용된 쿼리 구성으로 따로 제어해야 합니다." + "explanation": "INSERT의 첫 번째 ?에 setString(1, orderId)을 쓰는 것처럼 검색·저장할 값은 바인딩할 수 있습니다. 테이블 이름과 ASC는 쿼리의 구조를 정하는 문법이므로 값 파라미터로 바꾸지 않습니다. 구조를 바꿔야 한다면 허용된 쿼리 구성을 별도로 고릅니다." } ], "practice": { - "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "재고 감소와 예약 기록을 같은 DB 트랜잭션으로 묶고 SQL 실패 후 상태를 확인합니다.", + "title": "주문 저장 실패 뒤 재고가 남는 값 확인하기", + "scenario": "BOOK 재고는 5개입니다. A 주문으로 2개를 예약한 뒤 같은 ID로 1개를 다시 예약해 INSERT가 실패합니다. 두 번째 실패가 첫 예약까지 취소하거나 재고만 추가로 줄이지 않아야 합니다.", "tasks": [ - "reserve(orderId, quantity)의 quantity는 양수이고 orderId는 비어 있지 않습니다.", - "재고 부족이면 false, 성공이면 true이며 한 예약 행과 감소된 재고가 함께 커밋됩니다.", - "중복 orderId로 INSERT가 실패하면 재고 감소도 롤백되어야 합니다.", - "orders와 inventory는 테스트가 생성한 전용 H2 DB만 사용합니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "reserve의 quantity 양수·orderId 비어 있지 않음 검사를 getConnection 전에 수행하세요.", + "조건부 UPDATE의 changed가 0일 때 false로 끝내고, 성공일 때 주문 한 행과 줄어든 재고를 같은 connection에서 커밋하세요.", + "기존 duplicateRollsBackStock에서 A·2개 성공 뒤 A·1개 실패가 최종 재고 3을 남기는 순서를 UPDATE·INSERT·rollback으로 설명하세요.", + "orders와 inventory는 테스트가 생성한 전용 H2 DB만 사용합니다. 이 테스트의 DB와 실제 운영 DB의 잠금·격리·연결 풀 설정을 같은 것으로 보지 마세요.", + "rollback 실패를 suppressed로 보존하는 이유와 commit 통신 오류 뒤 무조건 재시도할 수 없는 이유를 적으세요. 현재 테스트에는 연결 단절·장기 부하·운영 DB 실행계획 검증이 없습니다." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "한 예약의 성공은 true만이 아니라 주문 행과 재고가 함께 남는 상태입니다. 실패한 두 번째 SQL을 따라가며 첫 번째 SQL의 변경도 같은 연결에서 되돌리는지 확인합니다." }, "sources": [ { @@ -176,5 +176,45 @@ "starterSatisfied": false, "sourceFingerprint": "408fed487770c066b90e1e7c078dc734c57e66818a2bfeb14f65d66372dc02ba", "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "BOOK 재고 5에서 A 주문 2개를 예약해 재고가 3이 됐습니다. 같은 주문 ID로 1개를 더 예약하면 주문 저장은 중복 키로 실패합니다. 이때 재고 2가 남으면 주문 기록 없는 감소가 생깁니다.", + "question": "INSERT에서 실패했는데 앞서 실행한 UPDATE의 감소까지 어떻게 취소할까요?", + "terms": [ + { + "term": "트랜잭션", + "meaning": "변경을 함께 확정하거나 함께 취소하는 단위입니다. 이 reserve는 같은 connection에서 재고 감소와 주문 INSERT를 묶습니다." + }, + { + "term": "자동 커밋", + "meaning": "SQL 문장마다 변경을 자동 확정하는 연결 모드입니다. setAutoCommit(false)로 끄고 두 SQL 뒤 직접 commit합니다." + }, + { + "term": "영향 행 수", + "meaning": "UPDATE로 영향을 받은 행의 수입니다. changed가 0이면 BOOK과 재고 조건을 만족한 행이 없어서 주문을 넣지 않습니다." + }, + { + "term": "롤백", + "meaning": "아직 확정하지 않은 현재 트랜잭션의 변경을 되돌리는 일입니다. 이 코드의 rollback은 실패한 두 번째 예약의 재고 감소도 취소합니다." + } + ], + "walkthrough": [ + "reserve의 앞부분은 잘못된 orderId·quantity를 거절합니다. getConnection 뒤 setAutoCommit(false)를 호출해 문장마다 확정되지 않게 합니다.", + "UPDATE의 두 ?에 quantity를 넣습니다. 재고가 충분한 BOOK 한 행의 stock을 줄이고 executeUpdate의 행 수를 changed에 저장합니다.", + "changed가 0이면 rollback 후 false로 끝냅니다. 주문을 INSERT하는 구간까지 내려가지 않습니다.", + "재고 감소가 성공하면 같은 connection으로 orders에 orderId와 quantity를 넣습니다. 성공해야 commit하고 true를 반환합니다.", + "이미 A가 있어 INSERT가 실패하면 catch의 failure가 그 예외를 받습니다. rollback이 두 번째 예약의 UPDATE도 되돌리고, throw failure가 호출자에게 실패를 알립니다.", + "rollback 자체의 SQLException은 failure.addSuppressed에 보관합니다. 처음 INSERT가 왜 실패했는지와 뒤처리 실패를 함께 살필 수 있습니다." + ], + "prediction": { + "question": "재고 5에서 reserve(\"A\", 2)가 성공한 다음 reserve(\"A\", 1)이 중복 키로 실패하고 롤백에 성공했습니다. 최종 재고는 2, 3, 5 중 얼마인가요?", + "answer": "3입니다.", + "explanation": "첫 호출의 재고 3은 이미 커밋됐습니다. 두 번째 호출의 3 → 2 변경만 그 호출의 트랜잭션에 속하므로 롤백하면 3으로 돌아갑니다." + }, + "takeaway": "두 SQL의 순서만 맞춰서는 충분하지 않습니다. 같은 연결에서 어디서 확정하고 어디서 되돌리는지를 따라가야 실패 뒤 DB 상태를 설명할 수 있습니다.", + "code": { + "file": "src/main/java/edu/lab/JdbcReservations.java", + "source": " connection.setAutoCommit(false);\n try {\n int changed;\n try (PreparedStatement update = connection.prepareStatement(\"update inventory set stock = stock - ? where sku = 'BOOK' and stock >= ?\")) {\n update.setInt(1, quantity); update.setInt(2, quantity); changed = update.executeUpdate();\n }\n if (changed == 0) { connection.rollback(); return false; }\n try (PreparedStatement insert = connection.prepareStatement(\"insert into orders(id, quantity) values (?, ?)\")) {\n insert.setString(1, orderId); insert.setInt(2, quantity); insert.executeUpdate();\n }\n connection.commit(); return true;\n } catch (SQLException | RuntimeException failure) {\n try { connection.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); }\n throw failure;\n }\n" + } } } diff --git a/curriculum/professional/lessons/test-collaborators/lesson.json b/curriculum/professional/lessons/test-collaborators/lesson.json index d494e6e..56c27c2 100644 --- a/curriculum/professional/lessons/test-collaborators/lesson.json +++ b/curriculum/professional/lessons/test-collaborators/lesson.json @@ -1,7 +1,7 @@ { "id": "test-collaborators", "title": "Mockito로 외부 협력의 실패를 통제", - "description": "외부 결제 협력의 호출 횟수와 실패 전파를 제어합니다.", + "description": "잘못된 주문이 결제 요청까지 도달하거나 결제 실패가 성공처럼 처리되는 일을 막습니다. Mockito 대역으로 charge의 입력·횟수·예외 전달을 확인합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,26 +10,26 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "대역으로 무엇을 검증하며 무엇은 검증하지 못하는가?", - "결제 한 번 호출", - "잘못된 입력에서 미호출", - "협력 오류 전파" + "실제 Checkout을 검사하면서 PaymentPort만 대역으로 바꾸는 이유를 설명한다.", + "submit(\"o-1\", 500)이 charge에 같은 주문 ID와 금액을 한 번 전달하는지 확인한다.", + "빈 주문 ID를 거절할 때 결제 호출이 추가되지 않아야 함을 확인한다.", + "charge가 던진 예외를 호출자에게 전달하고 대역 테스트의 한계를 구분한다." ], "theory": [ { "heading": "대역의 범위", - "text": "Mockito는 테스트에서 협력 객체의 응답과 실패를 통제합니다. 실제 네트워크·결제 시스템의 계약을 검증한 것으로 확대하지 않습니다." + "text": "Checkout은 스스로 결제 통신을 만들지 않고 생성자로 받은 PaymentPort에 charge를 요청합니다. 이렇게 필요한 객체를 밖에서 전달하는 방식을 주입이라고 부릅니다. 테스트에서는 실제 결제 대신 Mockito가 만든 PaymentPort 대역을 넣습니다. 대역은 호출을 기록하고 원하는 예외를 내도록 설정할 수 있으므로 돈을 결제하지 않고 실패 상황을 반복해서 확인할 수 있습니다.\n\n기존 propagatesFailure는 charge(\"o-1\", 500)가 IllegalStateException을 던지도록 설정합니다. 검사 대상은 대역이 아닌 실제 Checkout.submit입니다. 이 검사는 Checkout이 예외를 전달하는지는 보여 주지만 실제 결제 서버의 응답 형식·네트워크 지연·결제 확정 여부까지 확인하지는 않습니다." }, { "heading": "행동 중심 검증", - "text": "유효 입력에서 한 번 호출, 잘못된 입력에서 호출하지 않음, 협력 실패의 전파처럼 의미 있는 경계를 검증합니다. private 메서드 호출 순서와 같은 내부 구현 세부에 테스트를 결합하지 않습니다." + "text": "이 수업에서 중요한 약속은 “유효한 주문만 지정한 금액으로 한 번 요청하고 실패를 숨기지 않는다”입니다. submit은 orderId가 null·공백인지, cents가 양수인지 확인한 뒤 payments.charge를 호출합니다. 기존 oneCallAndInvalidInput은 정상 호출의 ID와 금액을 verify로 확인하고, 빈 ID 제출 후 호출이 더 생기지 않았는지 verifyNoMoreInteractions로 확인합니다.\n\n이 약속은 submit 한 번에 대한 호출 횟수입니다. submit을 두 번 부를 때 한 번만 결제된다는 뜻은 아닙니다. 네트워크 응답이 끊겨 실제 결제 여부를 모르는 상황에서는 무작정 재시도할 수 없습니다. 같은 요청을 다시 보내도 효과를 중복 적용하지 않는 멱등성 정책은 외부 결제 API와 함께 정해야 하며 이 코드에는 구현돼 있지 않습니다." } ], "example": { "title": "Checkout 기준 구현", "code": "package edu.lab;\npublic final class Checkout {\n public interface PaymentPort { void charge(String orderId, int cents); }\n private final PaymentPort payments;\n public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); }\n public void submit(String orderId, int cents) {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || cents <= 0) throw new IllegalArgumentException();\n payments.charge(orderId, cents);\n // END EXERCISE\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 결제 한 번 호출 · 잘못된 입력에서 미호출 · 협력 오류 전파.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "생성자는 null이 아닌 PaymentPort를 payments에 보관합니다. submit(\"o-1\", 500)은 if를 통과해 payments.charge(\"o-1\", 500)를 한 번 호출합니다. 빈 주문 ID는 charge에 도달하기 전에 IllegalArgumentException으로 끝납니다. charge 주변에 예외를 삼키는 catch가 없으므로 결제 포트가 던진 예외는 submit 호출자에게 전달됩니다. 이 코드는 한 번의 제출을 검사하며 중복 제출을 막는 저장소나 재시도 로직은 포함하지 않습니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", "files": [ { "path": "src/main/java/edu/lab/Checkout.java", @@ -54,7 +54,7 @@ "유효 입력에서 한 번 호출하고 잘못된 입력에서는 호출하지 않는다." ], "correctIndex": 3, - "explanation": "협력의 입력과 호출 횟수는 외부 효과의 계약입니다. 내부 구현 전체를 mock하면 실제 서비스의 조합 오류를 놓칠 수 있습니다." + "explanation": "submit이 정상 주문을 한 번 요청했는지 확인할 때 횟수만 아니라 \"o-1\"과 500이 그대로 전달됐는지도 봅니다. 빈 ID의 제출은 charge 앞에서 끝나야 합니다. Checkout까지 mock하면 이 if와 charge의 연결을 실행하지 않아 그 사이의 결함을 놓칠 수 있습니다." }, { "id": "test-collaborators-q2", @@ -66,19 +66,19 @@ "생성자 주입을 사용하면 재시도가 금지된다." ], "correctIndex": 0, - "explanation": "외부 효과의 확정 여부와 멱등 키·재시도 정책을 함께 정해야 하며, 단순 반복 호출은 안전을 보장하지 않습니다." + "explanation": "결제 서버가 처리한 직후 응답만 사라졌다면 호출자는 실패로 보아도 돈은 이미 결제됐을 수 있습니다. 다시 charge하면 중복 결제가 생길 수 있으므로 요청 식별 키와 결과 조회·재시도 규칙이 필요합니다. 현재 Checkout의 한 번 호출 테스트만으로 그런 멱등성이 확보되지는 않습니다." } ], "practice": { - "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "외부 결제 협력의 호출 횟수와 실패 전파를 제어합니다.", + "title": "실패한 결제를 성공으로 숨기지 않기", + "scenario": "주문 제출이 결제 포트를 호출합니다. 주문 ID가 비어 있으면 결제를 시작하지 말아야 하고, 결제 포트의 실패는 화면에서 실패로 처리할 수 있도록 호출자까지 전달되어야 합니다.", "tasks": [ - "Checkout.submit(String orderId, int cents)를 완성하세요.", - "orderId는 비어 있지 않고 cents는 양수여야 합니다. 잘못된 입력은 외부 호출 전에 거절합니다.", - "PaymentPort.charge를 한 번 호출하고 실패를 삼키지 않습니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "Checkout.submit(String orderId, int cents)를 완성하고 \"o-1\", 500이 PaymentPort.charge에 그대로 한 번 전달되는지 확인하세요.", + "orderId는 null·공백이 아니고 cents는 양수여야 합니다. 잘못된 입력을 charge 전에 거절하는 줄을 짚으세요.", + "기존 테스트가 charge에서 던지게 한 failure와 submit에서 받은 예외가 같은 객체인지 확인하는 이유를 설명하세요.", + "현재 테스트가 증명하는 한 번 호출과, submit을 반복해도 결제를 한 번만 하는 멱등성의 차이를 적으세요. 실제 결제 서버·타임아웃·중복 제출 검증은 이 대역 검사에 포함되지 않습니다." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "협력 대역은 실제 서비스의 결정을 검사하기 위한 도구입니다. 호출하지 않아야 할 때·전달할 값·실패를 알릴 방법을 확인하고 실제 외부 결제의 보장은 따로 검증합니다." }, "sources": [ { @@ -164,5 +164,44 @@ "starterSatisfied": false, "sourceFingerprint": "9b36daa6c1fdc7557702c6825819f8b92f1384bb562f8b95e8b9f79368bab733", "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "빈 주문 ID로 결제까지 요청되거나, 결제 서버의 오류를 삼켜 주문이 성공한 것처럼 보이면 곤란합니다. 실제 결제를 하지 않고 Checkout이 어느 지점에서 멈추는지 확인하려 합니다.", + "question": "PaymentPort를 대역으로 바꾸어도 Checkout의 어떤 동작은 실제로 검사할 수 있을까요?", + "terms": [ + { + "term": "협력 객체", + "meaning": "일부 작업을 부탁하는 다른 객체입니다. Checkout은 금액 결제를 PaymentPort의 charge에 부탁합니다." + }, + { + "term": "주입", + "meaning": "객체가 필요한 도구를 내부에서 만들지 않고 밖에서 받는 방식입니다. Checkout(PaymentPort payments)가 그 전달 지점입니다." + }, + { + "term": "테스트 대역", + "meaning": "실제 외부 시스템 대신 테스트에 넣는 객체입니다. Mockito로 만든 PaymentPort는 charge의 호출을 기록하거나 미리 정한 예외를 던집니다." + }, + { + "term": "실패 전파", + "meaning": "발생한 오류를 호출자가 알 수 있도록 밖으로 전달하는 일입니다. 이 submit은 charge의 예외를 성공으로 바꾸지 않습니다." + } + ], + "walkthrough": [ + "Checkout의 생성자가 전달받은 PaymentPort를 payments에 저장합니다. null이면 requireNonNull에서 바로 실패합니다.", + "submit의 if가 orderId와 cents를 검사합니다. 빈 ID는 payments.charge까지 내려가기 전에 IllegalArgumentException으로 거절됩니다.", + "submit(\"o-1\", 500)은 payments.charge(orderId, cents) 한 줄을 실행합니다. 테스트는 verify(port).charge(\"o-1\", 500)으로 이 호출의 값과 기본 한 번 호출을 확인합니다.", + "propagatesFailure 테스트의 대역은 charge에서 failure 예외를 던집니다. submit에 이를 잡는 코드가 없어서 같은 예외가 호출자에게 전달됩니다.", + "대역은 네트워크나 실제 결제를 실행하지 않습니다. 이 결과로 확인하는 것은 Checkout의 검사 순서와 호출·실패 전달입니다." + ], + "prediction": { + "question": "결제 대역의 charge가 failure 예외를 던지도록 설정하고 submit(\"o-1\", 500)을 호출하면 정상 종료할까요?", + "answer": "정상 종료하지 않고 charge가 던진 같은 failure 예외가 호출자에게 전달됩니다.", + "explanation": "입력 검사는 통과하지만 charge에서 실행이 중단됩니다. submit에는 예외를 잡거나 재시도하는 코드가 없으므로 실패가 그대로 드러납니다." + }, + "takeaway": "결제를 실제로 하지 않아도 “검사 후 요청하는가, 정확히 무엇을 전달하는가, 실패를 숨기는가”를 확인할 수 있습니다. 그 범위를 외부 결제 성공 보장과 구분합니다.", + "code": { + "file": "src/main/java/edu/lab/Checkout.java", + "source": " public interface PaymentPort { void charge(String orderId, int cents); }\n private final PaymentPort payments;\n public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); }\n public void submit(String orderId, int cents) {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || cents <= 0) throw new IllegalArgumentException();\n payments.charge(orderId, cents);\n // END EXERCISE\n }\n}\n" + } } } diff --git a/curriculum/professional/lessons/test-contracts/lesson.json b/curriculum/professional/lessons/test-contracts/lesson.json index 93240fe..b8d0e34 100644 --- a/curriculum/professional/lessons/test-contracts/lesson.json +++ b/curriculum/professional/lessons/test-contracts/lesson.json @@ -1,7 +1,7 @@ { "id": "test-contracts", "title": "JUnit·AssertJ로 경계와 상태 전이 검증", - "description": "상태 전이와 실패 후 보존할 상태를 JUnit·AssertJ로 검사합니다.", + "description": "재고 3개에서 4개 예약을 거절했는데 재고가 줄었다면 false만 확인한 테스트는 놓칩니다. 반환값과 호출 뒤 남은 재고를 JUnit·AssertJ로 함께 확인합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,26 +10,26 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "실패한 명령 뒤 객체의 상태는 무엇이어야 하는가?", - "전량 예약", - "실패 후 상태 보존", - "생성 불변식" + "reserve 호출 전 재고·반환값·호출 뒤 재고를 한 묶음으로 설명한다.", + "재고 3개를 전량 예약하면 true와 남은 재고 0이 함께 나와야 함을 확인한다.", + "초과·0·음수 예약을 거절한 뒤 재고 3이 보존되는지 확인한다.", + "생성자에서도 음수 재고를 받아들이지 않아야 하는 이유를 설명한다." ], "theory": [ { "heading": "기대값은 업무 계약에서", - "text": "테스트 기대값을 구현과 같은 수식으로 만들면 같은 결함을 공유할 수 있습니다. 예약 전후 수량과 실패 시 상태를 구체적인 값으로 확인합니다." + "text": "테스트를 쓰기 전에 사용자가 기대하는 동작을 문장으로 정합니다. 여기서 업무 계약은 “재고가 3개일 때 4개 요청은 거절하고 재고는 그대로 3개다”라는 약속입니다. JUnit의 @Test는 검사할 메서드를 표시하고, AssertJ의 assertThat은 실제 결과가 기대값에 맞는지 확인합니다. 기존 preservesStateOnFailure는 reserve(4), reserve(0), reserve(-1)의 false와 마지막 remaining()의 3을 검사합니다.\n\n기대값 3은 사용자가 요구한 상태에서 나와야 합니다. 구현의 뺄셈식을 그대로 복사해 기대값을 만들면, 둘이 같은 잘못된 계산을 해도 검사가 통과할 수 있습니다. “실패했으니 재고를 건드리지 않는다”를 구체적인 값으로 확인하는 이유입니다." }, { "heading": "경계 검사는 상태 전이를 포함", - "text": "0·음수·전체 재고·초과 수량을 선택하고 성공 여부뿐 아니라 이후 상태도 검증합니다. 테스트 통과는 선택한 사례의 증거이며 전체 상태 공간의 증명은 아닙니다." + "text": "경계값은 허용과 거절이 갈리는 근처의 값입니다. stock이 3일 때 quantity 3은 허용하고 4는 거절합니다. 0과 -1도 거절합니다. reserve의 if가 먼저 반환하기 때문에 이 경우 stock -= quantity까지 내려가지 않습니다. 반대로 reserve(3)은 stock을 0으로 만든 뒤 true를 반환합니다.\n\n상태 전이는 이처럼 호출 전후 stock이 어떻게 달라졌는지 뜻합니다. true만 검사하면 재고를 안 줄이는 결함을 놓치고, false만 검사하면 거절했는데 재고를 줄이는 결함을 놓칩니다. 테스트는 선택한 입력과 순서에 대한 증거입니다. 이 단일 객체 검사를 여러 스레드의 동시 예약이나 DB 재고의 안전성으로 확대하지 않습니다." } ], "example": { "title": "Reservation 기준 구현", "code": "package edu.lab;\npublic final class Reservation {\n private int stock;\n public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; }\n public boolean reserve(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0 || quantity > stock) return false;\n stock -= quantity; return true;\n // END EXERCISE\n }\n public int remaining() { return stock; }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 전량 예약 · 실패 후 상태 보존 · 생성 불변식.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "Reservation(3)으로 시작해 reserve(4)를 부르면 quantity > stock이 참이라 false를 반환하고 stock은 3에 머뭅니다. reserve(3)은 if를 지나 stock -= quantity를 실행하므로 true와 남은 재고 0이 함께 나옵니다. remaining()을 읽는 이유는 반환값이 맞아도 내부 재고 변경은 틀릴 수 있기 때문입니다. 생성자의 stock < 0 검사도 음수 재고를 가진 객체가 처음부터 만들어지는 것을 막습니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", "files": [ { "path": "src/main/java/edu/lab/Reservation.java", @@ -54,7 +54,7 @@ "private 필드를 모두 public으로 바꾼다." ], "correctIndex": 2, - "explanation": "거절 응답과 상태 보존은 독립적인 계약입니다. false를 반환하면서 재고를 줄이는 결함은 반환값 검사만으로 찾지 못합니다." + "explanation": "재고 3개에서 reserve(4)가 false를 반환해도, 앞서 stock을 줄여 놓는 잘못된 구현일 수 있습니다. remaining()이 계속 3인지까지 확인해야 “거절했다”와 “기존 재고를 보존했다”라는 두 약속을 모두 검사합니다." }, { "id": "test-contracts-q2", @@ -66,18 +66,19 @@ "DB 트랜잭션 격리 수준이 올라간다." ], "correctIndex": 0, - "explanation": "외부에서 관찰하는 반환·상태·필수 협력 계약을 중심으로 검사하면 내부 구조 변경에 더 유연합니다." + "explanation": "reserve 내부를 보조 메서드로 나누더라도 같은 입력에서 같은 반환값과 남은 재고를 준다면 사용자에게 보이는 동작은 같습니다. private 메서드의 순서만 고정하면 이런 구조 정리도 테스트를 깨뜨립니다. 이 예제는 reserve와 remaining으로 관찰할 수 있는 동작을 검사합니다." } ], "practice": { - "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "상태 전이와 실패 후 보존할 상태를 JUnit·AssertJ로 검사합니다.", + "title": "거절한 예약이 재고를 바꾸지 않는지 확인하기", + "scenario": "주문 화면에는 재고 부족이라고 표시됐는데 이후 정상 주문도 실패합니다. reserve가 false를 반환하면서 재고를 먼저 줄였을 가능성을 테스트로 구분해 봅니다.", "tasks": [ - "Reservation(int stock), boolean reserve(int quantity), int remaining() 계약을 완성하세요.", - "초기 음수는 거절합니다. 0 이하 또는 초과 예약은 false이고 상태를 보존합니다.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "Reservation(int stock), reserve(int quantity), remaining()을 읽고 재고 3에서 reserve(4)를 부른 뒤 기대하는 반환값과 재고를 먼저 적으세요.", + "초기 음수는 예외로 거절하고 0 이하·초과 예약은 false로 거절하세요. 이때 기존 재고를 보존해야 합니다.", + "reserve(3)의 true와 remaining()의 0을 함께 확인하세요. 이어지는 reserve(1)은 어떤 결과여야 하는지 설명하세요.", + "반환값만 검사할 때 놓치는 결함 하나와 남은 재고를 추가로 읽으면 잡을 수 있는 이유를 적으세요. 현재 테스트에는 다중 스레드 예약 검사가 없다는 점도 구분하세요." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "예약 결과는 true·false 하나로 끝나지 않습니다. 호출이 끝난 뒤 재고가 어떤 값이어야 하는지까지 적어야 실패 후 상태를 지키는 테스트가 됩니다." }, "sources": [ { @@ -157,5 +158,44 @@ "starterSatisfied": false, "sourceFingerprint": "cbdeec57521987b40feed67ef6c3af7d4964f6b8804b51c5abae9447dc147028", "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "재고 3개에 대한 4개 예약을 거절했습니다. 호출자는 false를 받았지만 이후 remaining()이 3이 아니라면 예약 처리에 결함이 있습니다.", + "question": "reserve가 false를 반환했다는 사실만으로 재고가 안전하다고 할 수 있을까요?", + "terms": [ + { + "term": "업무 계약", + "meaning": "사용자가 기대하는 입력·결과·상태의 약속입니다. reserve(4)를 거절했으면 stock 3도 그대로여야 합니다." + }, + { + "term": "상태 전이", + "meaning": "메서드를 부르기 전과 후에 객체 값이 바뀌는 일입니다. reserve(3)의 성공은 stock 3 → 0이고, 거절은 3 → 3입니다." + }, + { + "term": "경계값", + "meaning": "허용과 거절이 갈리는 근처의 입력입니다. 재고 3에서 수량 3과 4, 그리고 최소 허용 수량 1 근처의 0을 살펴봅니다." + }, + { + "term": "단언(assertion)", + "meaning": "실제 결과와 기대 결과가 맞는지 검사하는 문장입니다. 기존 테스트의 isFalse()와 isEqualTo(3)는 각각 반환값과 남은 재고를 확인합니다." + } + ], + "walkthrough": [ + "Reservation(3)은 생성자의 음수 검사를 통과하고 this.stock에 3을 저장합니다.", + "reserve(4)는 quantity > stock이 참이므로 if에서 false를 반환합니다. stock -= quantity는 실행되지 않습니다.", + "reserve(0)과 reserve(-1)도 quantity <= 0 때문에 같은 지점에서 끝납니다. remaining()은 여전히 3을 돌려줍니다.", + "reserve(3)은 두 거절 조건이 모두 거짓입니다. stock에서 3을 뺀 뒤 true를 반환하므로 remaining()은 0입니다.", + "기존 ContractTest는 거절의 false뿐 아니라 재고 보존도 검사합니다. 음수 초기 재고는 생성자에서 예외가 나는지도 별도로 확인합니다." + ], + "prediction": { + "question": "Reservation(3) 하나에서 reserve(4), reserve(3)을 순서대로 호출하면 두 반환값과 최종 remaining()은 무엇인가요?", + "answer": "순서대로 false, true이고 최종 remaining()은 0입니다.", + "explanation": "첫 호출은 뺄셈 전에 반환해 재고 3을 보존합니다. 두 번째 호출은 그 3개를 전부 예약합니다. 거절한 호출이 다음 호출에 남기는 상태까지 따라가 보세요." + }, + "takeaway": "실패를 알리는 값과 실패 뒤 남겨야 할 상태를 따로 확인합니다. 이 원칙은 예약·취소처럼 상태를 바꾸는 메서드의 테스트를 읽을 때 유용합니다.", + "code": { + "file": "src/main/java/edu/lab/Reservation.java", + "source": " private int stock;\n public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; }\n public boolean reserve(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0 || quantity > stock) return false;\n stock -= quantity; return true;\n // END EXERCISE\n }\n public int remaining() { return stock; }\n" + } } } diff --git a/curriculum/professional/lessons/tx-proxy/lesson.json b/curriculum/professional/lessons/tx-proxy/lesson.json index 8a3ff34..b9c1271 100644 --- a/curriculum/professional/lessons/tx-proxy/lesson.json +++ b/curriculum/professional/lessons/tx-proxy/lesson.json @@ -1,7 +1,7 @@ { "id": "tx-proxy", "title": "Spring 트랜잭션 프록시와 롤백 경계", - "description": "@Transactional 표기와 실제 프록시 진입을 구분하고 self-invocation의 실패 상태를 관찰합니다.", + "description": "같은 INSERT 뒤에 같은 예외를 던져도 호출 방법에 따라 행이 남을 수 있습니다. Spring이 호출을 가로채는 프록시와 실패한 변경을 되돌리는 롤백을 실제 두 메서드로 비교합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -10,30 +10,64 @@ "duration": 75, "category": "실무 프로젝트", "goals": [ - "트랜잭션 어노테이션이 있어도 롤백되지 않는 경로는 무엇인가?", - "외부 프록시 호출 롤백", - "자기 호출 반례 재현", - "호출 경로 설명" + "reserveThenFail을 호출하는 두 경로에서 실패 후 행 수가 다른 이유를 설명한다.", + "프록시를 통한 호출이 INSERT를 롤백해 0행을 남기는 과정을 따라간다.", + "같은 객체 안의 this 호출인 자기 호출이 이 예제에서 1행을 남기는 이유를 찾는다.", + "예외 종류와 실제 호출 경로를 보고 트랜잭션을 어디서 시작할지 정한다." ], + "learningGuide": { + "scenario": "예약 기록을 한 줄 넣고 곧바로 오류를 던지는 메서드가 있습니다. 오류는 두 호출 모두에서 보이지만, 한쪽 DB에만 reserved 행이 남습니다.", + "question": "실행한 메서드 본문이 같은데 왜 실패 뒤 DB 상태는 달라질까요?", + "terms": [ + { + "term": "프록시", + "meaning": "Spring이 원래 Orders 앞에 두는 대리 객체입니다. 외부 호출을 받아 트랜잭션 같은 동작을 적용합니다." + }, + { + "term": "자기 호출", + "meaning": "객체가 this로 자기 메서드를 호출하는 것입니다. 여기서는 bypassProxy 안의 this.reserveThenFail()입니다." + }, + { + "term": "롤백", + "meaning": "트랜잭션에서 아직 확정하지 않은 DB 변경을 취소하는 것입니다. 이 예제에서는 방금 넣은 reserved 행을 남기지 않습니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/TransactionLab.java", + "source": " @Transactional\n public void reserveThenFail() {\n // BEGIN EXERCISE\n jdbc.update(\"insert into audit_events(id) values('reserved')\");\n throw new IllegalStateException(\"simulated downstream failure\");\n // END EXERCISE\n }\n public void bypassProxy() { this.reserveThenFail(); }" + }, + "walkthrough": [ + "@Transactional이 붙은 reserveThenFail을 Spring의 Orders 프록시를 통해 직접 부르는 경우를 먼저 봅니다. 프록시가 트랜잭션을 적용한 뒤 메서드 본문으로 들어갑니다.", + "jdbc.update 줄은 audit_events에 reserved 한 행을 넣습니다. 다음 throw 줄은 IllegalStateException을 밖으로 전달합니다.", + "직접 호출 경로에서는 프록시가 이 예외를 보고 트랜잭션을 롤백합니다. 메서드를 호출한 쪽은 예외를 받고 DB에는 행이 남지 않습니다.", + "마지막 bypassProxy 줄을 통해 들어오면 this.reserveThenFail()이 프록시를 다시 거치지 않습니다. 이 예제의 bypassProxy에는 트랜잭션도 없어 INSERT가 자동 확정되고, 이후 예외만으로는 그 행을 되돌리지 못합니다." + ], + "prediction": { + "question": "매번 빈 새 DB에서 시작해 reserveThenFail 직접 호출과 bypassProxy 호출을 각각 실행하면 예외 뒤 행 수는 얼마일까요?", + "answer": "Spring 프록시를 통한 직접 호출은 0행, bypassProxy 호출은 1행입니다.", + "explanation": "첫 호출은 INSERT와 예외가 트랜잭션 안에 있어 변경이 취소됩니다. 두 번째는 자기 호출 앞에 새 트랜잭션을 적용할 프록시 진입이 없고 기존 트랜잭션도 없습니다. 두 경우 모두 예외가 발생했다는 사실만 보고 롤백을 판단하면 안 됩니다." + }, + "takeaway": "실패를 던지는 코드와 DB 변경을 되돌리는 트랜잭션은 함께 연결되어 있어야 합니다." + }, "theory": [ { "heading": "프록시가 여는 경계", - "text": "기본 프록시 모드에서는 외부에서 프록시를 통과한 호출에 트랜잭션 조언이 적용됩니다. 같은 객체의 this 호출은 프록시를 다시 통과하지 않습니다. 어노테이션 존재만으로 모든 호출 경로가 같은 트랜잭션을 얻지는 않습니다." + "text": "프록시는 원래 객체 앞에서 호출을 받아 부가 작업을 하는 대리 객체입니다. 이 예제에서는 Spring에서 받은 Orders를 통해 reserveThenFail을 부르면 프록시가 DB 트랜잭션을 적용합니다. 트랜잭션은 여러 DB 변경을 함께 확정하거나 취소하는 단위입니다. 반면 bypassProxy 안의 this.reserveThenFail()은 같은 객체가 자기 메서드를 부르는 자기 호출입니다. 이 호출은 프록시로 다시 들어가지 않아 내부 메서드의 @Transactional 설정을 새로 적용하지 않습니다." }, { "heading": "예외와 롤백 정책", - "text": "기본 선언적 트랜잭션은 RuntimeException과 Error에 롤백합니다. checked 예외나 예외를 잡아 정상 반환하는 흐름에는 별도 정책을 검토해야 합니다. 예외 타입, 경계 밖으로의 전파, DB 변경 시점을 함께 확인합니다." + "text": "롤백은 아직 확정하지 않은 트랜잭션의 DB 변경을 되돌리는 일입니다. reserveThenFail은 audit_events에 reserved를 넣은 직후 IllegalStateException을 던집니다. 이는 RuntimeException의 하위 타입이어서 이 예제의 기본 정책에서는 프록시가 예외를 받아 INSERT를 롤백합니다. checked 예외는 처리 또는 throws 선언을 요구하는 예외이며 기본 롤백 규칙이 다릅니다. 또 예외를 내부에서 잡아 정상 반환하면 프록시는 그 예외를 보지 못하므로 실패 정책을 따로 검토해야 합니다." }, { "heading": "실패 예제의 목적", - "text": "이 과제는 안전한 외부 호출과 안전하지 않은 자기 호출을 둘 다 재현합니다. self-invocation에서 남은 행은 모범 동작이 아니라 수정해야 할 반례입니다. 해결은 역할 분리 후 다른 빈을 호출하거나 명시적 트랜잭션 경계를 사용하는 설계와 비교합니다." + "text": "테스트는 각자 빈 H2 DB에서 시작합니다. 외부에서 reserveThenFail을 호출한 테스트는 실패 뒤 0행을, bypassProxy를 호출한 테스트는 1행을 기대합니다. 후자는 바람직한 저장 결과가 아니라 잘못된 호출 경로를 드러내는 반례입니다. bypassProxy에 기존 트랜잭션이 없는 이 조건에서 INSERT가 자동 확정되기 때문입니다. 자기 호출이 이미 진행 중인 트랜잭션까지 없앤다는 뜻은 아닙니다. 업무 코드에서는 트랜잭션이 필요한 작업을 다른 Spring 관리 객체로 분리해 호출하거나 명시적으로 트랜잭션을 여는 방법을 비교합니다." } ], "example": { "title": "TransactionLab 기준 구현", "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.util.UUID;\nimport org.h2.jdbcx.JdbcDataSource;\nimport org.springframework.context.annotation.*;\nimport org.springframework.jdbc.core.JdbcTemplate;\nimport org.springframework.jdbc.datasource.DataSourceTransactionManager;\nimport org.springframework.transaction.PlatformTransactionManager;\nimport org.springframework.transaction.annotation.*;\npublic final class TransactionLab {\n public static class Orders {\n private final JdbcTemplate jdbc;\n public Orders(JdbcTemplate jdbc) { this.jdbc = jdbc; }\n @Transactional\n public void reserveThenFail() {\n // BEGIN EXERCISE\n jdbc.update(\"insert into audit_events(id) values('reserved')\");\n throw new IllegalStateException(\"simulated downstream failure\");\n // END EXERCISE\n }\n public void bypassProxy() { this.reserveThenFail(); }\n }\n @Configuration\n @EnableTransactionManagement(proxyTargetClass=true)\n public static class Config {\n @Bean public DataSource dataSource() {\n JdbcDataSource ds = new JdbcDataSource(); ds.setURL(\"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"); return ds;\n }\n @Bean public JdbcTemplate jdbc(DataSource ds) { JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute(\"create table audit_events(id varchar(60) primary key)\"); return jdbc; }\n @Bean public PlatformTransactionManager transactionManager(DataSource ds) { return new DataSourceTransactionManager(ds); }\n @Bean public Orders orders(JdbcTemplate jdbc) { return new Orders(jdbc); }\n }\n}\n", "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 외부 프록시 호출 롤백 · 자기 호출 반례 재현 · 호출 경로 설명.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", - "explanation": "공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "explanation": "reserveThenFail의 두 핵심 줄은 INSERT와 IllegalStateException입니다. Spring에서 받은 Orders로 이 메서드를 직접 호출하면 트랜잭션이 적용되어 실패 뒤 행이 사라집니다. bypassProxy는 같은 객체의 this.reserveThenFail()을 실행하므로 내부 @Transactional을 새로 적용하지 않습니다. 기존 트랜잭션이 없는 이 예제에서는 INSERT가 이미 확정되어 예외 뒤에도 1행이 남습니다. 두 테스트가 서로 다른 행 수를 기대하는 이유입니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", "files": [ { "path": "src/main/java/edu/lab/TransactionLab.java", @@ -58,7 +92,7 @@ "private 메서드에도 항상 같은 트랜잭션이 새로 생성된다." ], "correctIndex": 1, - "explanation": "어노테이션은 적용 조건이 필요한 메타데이터입니다. 기본 프록시 모드의 자기 호출은 조언을 다시 통과하지 않습니다." + "explanation": "bypassProxy의 this는 실제 Orders 객체 자신을 가리킵니다. reserveThenFail 호출이 프록시를 다시 지나지 않으므로 그 메서드의 @Transactional을 새로 적용하지 않습니다. 이 예제는 외부 트랜잭션도 없어 INSERT가 남습니다. 어노테이션이나 private 여부만으로 새 트랜잭션이 생기는 것은 아닙니다." }, { "id": "tx-proxy-q2", @@ -70,20 +104,20 @@ "예외 타입은 롤백과 무관하다." ], "correctIndex": 2, - "explanation": "checked 예외의 업무 의미를 기준으로 롤백 정책을 명시하고 테스트해야 합니다." + "explanation": "현재 예제의 IllegalStateException은 RuntimeException이므로 기본 롤백 대상입니다. checked 예외로 바꾸면 같은 결과를 자동으로 기대할 수 없습니다. 업무상 변경을 취소해야 한다면 rollbackFor 같은 규칙을 정하고 실패 후 실제 DB 상태까지 검사해야 합니다." } ], "practice": { "title": "주문·재고·취소 프로젝트에 연결", - "scenario": "@Transactional 표기와 실제 프록시 진입을 구분하고 self-invocation의 실패 상태를 관찰합니다.", + "scenario": "주문 저장 뒤 후속 작업이 실패했는데 주문 기록만 남았다고 가정합니다. 메서드 위에 @Transactional이 있는지와 함께, 요청이 어떤 객체를 거쳐 그 메서드에 도착했는지를 추적합니다.", "tasks": [ - "외부 프록시를 통해 reserveThenFail을 호출하면 예외 뒤 audit_events 행이 0개입니다.", - "bypassProxy는 의도적 반례로 this.reserveThenFail을 호출합니다. 별도 트랜잭션이 없어서 행 1개가 남는 차이를 설명하세요.", - "트랜잭션 빈은 Spring 컨테이너에서 가져오고 직접 new로 대체하지 않습니다.", - "학습 완료 설명에서는 업무 서비스에서 자기 호출 문제를 제거할 대안을 제시하세요.", - "관련 실패 상황 하나를 설명하고 테스트의 검증 범위와 한계를 기록하세요." + "빈 DB에서 Spring의 Orders로 reserveThenFail을 호출하고 INSERT → 예외 → 롤백 → 0행의 순서를 설명하세요.", + "다른 빈 DB에서 bypassProxy를 호출했을 때 this.reserveThenFail() 앞에 트랜잭션을 여는 단계가 왜 없는지 설명하세요. 남은 1행은 의도한 반례입니다.", + "테스트가 컨테이너에서 Orders를 가져오는 이유와 직접 new Orders(...)로 만들 때 잃는 동작을 구분하세요.", + "업무 코드에서 자기 호출 문제를 없앨 대안으로 다른 Spring 관리 서비스에 작업을 분리하거나 명시적 트랜잭션을 사용하는 방법을 비교하세요.", + "예외를 잡아 정상 반환하거나 checked 예외를 던지는 경우에는 어떤 추가 검사가 필요한지 적으세요. 외부 API의 작업까지 이 DB 롤백이 취소해 주는 것은 아닙니다." ], - "takeaway": "도구를 사용한 사실보다 지켜야 하는 업무 계약과 실패 경계의 근거를 남깁니다." + "takeaway": "@Transactional은 호출이 실제로 통과한 경로와 함께 읽어야 합니다. 실패 뒤 DB 행이 남는지를 확인하면 경계 누락을 발견할 수 있습니다." }, "sources": [ { diff --git a/dist/app.js b/dist/app.js index c3a54b5..bbc2a76 100644 --- a/dist/app.js +++ b/dist/app.js @@ -38,6 +38,7 @@ const state = { const dom = Object.fromEntries([ 'workspace-grid', 'loading-state', 'lesson-list', 'lesson-card', 'lesson-panel', 'lesson-title', 'lesson-description', 'lesson-category', 'lesson-position', 'lesson-meta', 'lesson-achievements', + 'lesson-support', 'lesson-info', 'lesson-detail-meta', 'lesson-goals', 'lesson-footer', 'source-list', 'evidence-list', 'reference-column', 'version-select', 'lesson-search', 'curriculum-sidebar', 'mobile-menu-toggle', 'drawer-backdrop', ].map((id) => [id, document.getElementById(id)])); @@ -225,13 +226,13 @@ function setEditorTheme(theme) { announce(`코드 테마를 ${theme === 'dark' ? '다크' : theme === 'light' ? '라이트' : '고대비'}로 변경했습니다.`); } -function codePreferences() { +function codePreferences(id = 'editor-theme-select') { const settings = el('div', 'code-settings'); const label = el('label', 'code-theme-label', '코드 테마'); - label.htmlFor = 'editor-theme-select'; + label.htmlFor = id; const select = el('select', 'editor-theme-select'); - select.id = 'editor-theme-select'; - select.dataset.testid = 'editor-theme-select'; + select.id = id; + select.dataset.testid = id; for (const [value, name] of [['dark', '다크'], ['light', '라이트'], ['contrast', '고대비']]) { const option = el('option', '', name); option.value = value; select.append(option); } @@ -262,7 +263,6 @@ function updateStorageIndicator() { const label = state.storage === 'unavailable' ? '현재 화면 동안만 기록' : state.storage === 'invalid' ? '기존 기록을 읽지 못했습니다' : location; document.getElementById('storage-label').textContent = label; indicator.title = state.storage === 'unavailable' ? '학습 기록 저장소를 사용할 수 없습니다. 작성 코드를 별도로 복사해 주세요.' : isDesktop ? '진도와 코드는 이 PC의 앱 데이터에 저장되며 앱 재시작 후에도 유지됩니다.' : '진도와 코드는 현재 브라우저에만 저장됩니다.'; - document.getElementById('storage-location-badge').textContent = isDesktop ? '이 PC의 앱 기록' : '브라우저 기록'; const editorSave = document.getElementById('editor-save-state'); if (editorSave) editorSave.textContent = state.storage === 'unavailable' ? '저장 불가 · 코드 복사 권장' : location; const notesSave = document.getElementById('notes-save-state'); @@ -389,7 +389,6 @@ function continuationLesson() { function renderProgress() { const count = lessons.filter(lessonComplete).length; - document.getElementById('lesson-total').textContent = String(lessons.length); document.getElementById('overall-completed').textContent = String(count); document.getElementById('overall-denominator').textContent = ` / ${lessons.length}`; const progress = document.getElementById('overall-progress'); @@ -424,14 +423,22 @@ function renderAchievements(lesson = currentLesson()) { function renderLesson() { const lesson = currentLesson(); + const changedLesson = dom['lesson-card'].dataset.lessonId !== (lesson?.id || ''); const tabs = document.querySelector('.lesson-tabs'); destroyCodeEditor(); dom['lesson-panel'].replaceChildren(); dom['lesson-meta'].replaceChildren(); + dom['lesson-detail-meta'].replaceChildren(); + dom['lesson-goals'].replaceChildren(); dom['lesson-footer'].replaceChildren(); dom['lesson-achievements'].replaceChildren(); tabs.hidden = !lesson; dom['lesson-footer'].hidden = !lesson; + dom['lesson-support'].hidden = !lesson; + if (changedLesson) { + dom['lesson-info'].open = false; + dom['reference-column'].open = false; + } dom['lesson-card'].dataset.lessonId = lesson?.id || ''; if (!lesson) { dom['lesson-category'].textContent = 'FIND YOUR NEXT LESSON'; @@ -449,16 +456,20 @@ function renderLesson() { dom['lesson-description'].textContent = lesson.description; dom['lesson-position'].textContent = `${String(lessons.indexOf(lesson) + 1).padStart(2, '0')} / ${String(lessons.length).padStart(2, '0')}`; dom['lesson-meta'].append(el('span', 'meta-chip', `Java ${lesson.minVersion}+`)); - if (lesson.featureSince) dom['lesson-meta'].append(el('span', 'meta-chip is-neutral', `기능 도입 ${String(lesson.featureSince)}`)); + if (lesson.featureSince) dom['lesson-detail-meta'].append(el('span', '', `기능 도입 ${String(lesson.featureSince)}`)); const trackLabel = lesson.track === 'both' ? '전환 · 심화 공통' : lesson.track === 'advanced' ? '실무 심화' : 'Java 전환'; - dom['lesson-meta'].append(el('span', 'meta-chip is-neutral', trackLabel)); + dom['lesson-detail-meta'].append(el('span', '', trackLabel)); if (isProject(exerciseFor(lesson))) dom['lesson-meta'].append(el('span', 'meta-chip is-neutral', '실무 프로젝트 · Windows 앱 실행')); if (lesson.duration) { const time = el('span', 'meta-chip is-neutral'); time.append(icon('clock'), document.createTextNode(typeof lesson.duration === 'number' ? `${lesson.duration}분` : lesson.duration)); - dom['lesson-meta'].append(time); + dom['lesson-detail-meta'].append(time); } if (lesson.preview) dom['lesson-meta'].append(el('span', 'meta-chip is-preview', 'Preview 기능 포함')); + dom['lesson-goals'].append(el('h3', '', '학습 목표')); + const goals = el('ul'); + (lesson.goals || []).forEach((goal) => { const item = el('li'); item.append(icon('check'), el('span', '', goal)); goals.append(item); }); + dom['lesson-goals'].append(goals); renderAchievements(lesson); document.querySelectorAll('[data-step]').forEach((tab) => { const selected = tab.dataset.step === state.step; @@ -469,6 +480,7 @@ function renderLesson() { const renderers = { theory: renderTheory, example: renderExample, quiz: renderQuiz, practice: renderPractice, lab: renderLab }; renderers[state.step](lesson); renderLessonFooter(lesson); + requestAnimationFrame(() => revealStepTab(state.step)); } function renderLessonFooter(lesson) { @@ -671,13 +683,119 @@ function renderTheoryVisual(lesson, visual) { return figure; } +function learningGuideFor(lesson) { + const value = lesson.learningGuide; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const text = (item) => typeof item === 'string' && item.trim() ? item : ''; + const terms = Array.isArray(value.terms) ? value.terms + .filter((item) => item && text(item.term) && text(item.meaning)) + .map((item) => ({ term: item.term, meaning: item.meaning })) : []; + const walkthrough = Array.isArray(value.walkthrough) ? value.walkthrough.filter((item) => text(item)) : []; + const code = text(value.code?.source) ? { file: text(value.code.file) || '코드 발췌', source: value.code.source } : null; + const prediction = text(value.prediction?.question) && text(value.prediction?.answer) + ? { question: value.prediction.question, answer: value.prediction.answer, explanation: text(value.prediction.explanation) } : null; + const guide = { scenario: text(value.scenario), question: text(value.question), terms, code, walkthrough, prediction, takeaway: text(value.takeaway) }; + return guide.scenario || guide.question || terms.length || code || walkthrough.length || prediction || guide.takeaway ? guide : null; +} + +function renderGuideCode(excerpt) { + const section = el('section', 'guide-section guide-code-section'); + section.append(el('h3', '', '작은 코드로 확인하기')); + const scope = el('p', 'guide-code-scope', '이론 코드 발췌 · 전체 코드는 예제 단계에서 확인하세요.'); + scope.id = 'theory-code-scope'; + section.append(scope); + const shell = el('div', 'code-shell guide-code'); + shell.dataset.editorTheme = state.editorTheme; + const heading = el('div', 'code-heading'); + const filename = el('span', 'example-filename'); + filename.append(icon('code'), el('span', 'guide-code-filename', excerpt.file)); + const pre = el('pre', 'code-block'); + pre.id = 'theory-code-panel'; + pre.tabIndex = 0; + pre.setAttribute('aria-label', `${excerpt.file} · 이론 코드 발췌`); + pre.setAttribute('aria-describedby', 'theory-code-scope theory-code-hint'); + const code = el('code', '', excerpt.source); + let highlighted = false; + if (editorLibrary && /\.java$/i.test(excerpt.file)) { + try { + editorLibrary.highlightJavaCode(code, excerpt.source); + highlighted = true; + } catch { code.textContent = excerpt.source; } + } + pre.append(code); + const copy = actionButton('코드 복사', 'copy-button', () => copyText(excerpt.source, pre), 'copy-theory-code'); + copy.prepend(icon('copy')); + heading.append(filename, copy); + const tools = el('div', 'guide-code-tools'); + tools.append(codePreferences('theory-theme-select')); + if (highlighted) { + const legend = el('details', 'code-legend-disclosure'); + legend.append(el('summary', '', '구문 색상 안내'), syntaxLegend()); + tools.append(legend); + } + shell.append(heading, tools); + shell.append(pre); + const hint = el('p', 'guide-code-hint', '긴 줄은 코드 영역 안에서 가로로 스크롤할 수 있습니다.'); + hint.id = 'theory-code-hint'; + if (/\.java$/i.test(excerpt.file) && !highlighted) hint.append(document.createTextNode(' 문법 강조를 불러오지 못해 원문으로 표시합니다.')); + section.append(shell, hint); + return section; +} + +function renderLearningGuide(guide) { + const container = el('div', 'learning-guide'); + if (guide.scenario || guide.question) { + const scenario = el('section', 'guide-scenario'); + scenario.append(el('h3', '', '이 상황부터 생각해보세요')); + if (guide.scenario) scenario.append(el('p', 'guide-scenario-text', guide.scenario)); + if (guide.question) scenario.append(el('p', 'guide-question', guide.question)); + container.append(scenario); + } + if (guide.terms.length) { + const section = el('section', 'guide-section guide-terms'); + section.append(el('h3', '', '먼저 알아둘 용어')); + const list = el('dl', 'guide-terms-list'); + guide.terms.forEach(({ term, meaning }) => { + const item = el('div', 'guide-term'); + item.append(el('dt', '', term), el('dd', '', meaning)); + list.append(item); + }); + section.append(list); + container.append(section); + } + if (guide.code) container.append(renderGuideCode(guide.code)); + if (guide.walkthrough.length) { + const section = el('section', 'guide-section guide-walkthrough'); + section.append(el('h3', '', '순서대로 따라가 보기')); + const list = el('ol'); + guide.walkthrough.forEach((step) => list.append(el('li', '', step))); + section.append(list); + container.append(section); + } + if (guide.prediction) { + const section = el('section', 'guide-section guide-prediction-section'); + section.append(el('h3', '', '결과를 먼저 예상해보세요'), el('p', 'guide-prediction-question', guide.prediction.question)); + const details = el('details', 'guide-prediction'); + details.append(el('summary', '', guide.prediction.explanation ? '예상 답과 이유 확인' : '예상 답 확인')); + const answer = el('div', 'guide-prediction-answer'); + answer.append(el('p', 'guide-answer-label', '예상 답'), el('p', 'guide-answer-text', guide.prediction.answer)); + if (guide.prediction.explanation) answer.append(el('p', 'guide-answer-label', '이유'), el('p', 'guide-answer-explanation', guide.prediction.explanation)); + details.append(answer); + section.append(details); + container.append(section); + } + if (guide.takeaway) { + const takeaway = el('div', 'guide-takeaway'); + takeaway.append(el('p', 'small-label', '한 문장으로 정리하면'), el('p', '', guide.takeaway)); + container.append(takeaway); + } + return container; +} + function renderTheory(lesson) { - const goals = el('div', 'learning-goals'); - goals.append(el('p', 'small-label', 'AFTER THIS LESSON')); - const list = el('ul'); - (lesson.goals || []).forEach((goal) => { const item = el('li'); item.append(icon('check'), el('span', '', goal)); list.append(item); }); - goals.append(list); - dom['lesson-panel'].append(goals); + const guide = learningGuideFor(lesson); + if (guide) dom['lesson-panel'].append(renderLearningGuide(guide)); + if (guide && lesson.theory?.length) dom['lesson-panel'].append(el('h3', 'theory-reading-title', '핵심 개념 자세히 읽기')); const parts = lesson.theory || []; const visual = validLessonVisual(lessonVisuals[lesson.id]); const afterSection = visual && Number.isInteger(visual.afterSection) ? Math.min(Math.max(visual.afterSection, 0), Math.max(parts.length - 1, 0)) : 0; @@ -1655,6 +1773,19 @@ async function executeCode(exercise, action) { } } +function revealStepTab(step) { + const tab = document.getElementById(`tab-${step}`); + const tabs = tab?.parentElement; + if (!tabs || tabs.scrollWidth <= tabs.clientWidth) return; + const bounds = tabs.getBoundingClientRect(); + const selected = tab.getBoundingClientRect(); + // Keep the tab and its focus ring inside the strip without moving the page. + const left = bounds.left + tabs.clientLeft + 8; + const right = bounds.left + tabs.clientLeft + tabs.clientWidth - 8; + if (selected.left < left) tabs.scrollLeft += selected.left - left; + else if (selected.right > right) tabs.scrollLeft += selected.right - right; +} + function selectStep(step, focus = false) { if (!STEPS.includes(step) || !currentLesson()) return; if (state.step === 'lab' && step !== 'lab') cancelPending('단계 변경', false); @@ -1774,9 +1905,15 @@ function bindEvents() { else return; event.preventDefault(); selectStep(STEPS[nextIndex]); - document.getElementById(`tab-${STEPS[nextIndex]}`).focus(); + document.getElementById(`tab-${STEPS[nextIndex]}`).focus({ preventScroll: true }); }); }); + if (typeof ResizeObserver === 'function') { + const tabObserver = new ResizeObserver(() => revealStepTab(state.step)); + tabObserver.observe(document.querySelector('.lesson-tabs')); + } else { + window.addEventListener('resize', () => revealStepTab(state.step)); + } document.getElementById('continue-button').addEventListener('click', () => { const lesson = continuationLesson(); if (!lesson) return; diff --git a/dist/data/curriculum.js b/dist/data/curriculum.js index fcaeaad..ff8b92e 100644 --- a/dist/data/curriculum.js +++ b/dist/data/curriculum.js @@ -3,7 +3,7 @@ export const lessons = [ { "id": "java-basics", "title": "Java의 값 전달과 객체 동등성", - "description": "다른 언어의 직관을 Java의 타입·참조·boxing 계약과 대조합니다.", + "description": "메서드에서 값을 바꿨는데 호출한 쪽의 값은 왜 다를까요? 변수 재할당·객체 변경·내용 비교를 예제의 출력으로 구분합니다.", "track": "transition", "level": "전환", "minVersion": 8, @@ -12,29 +12,29 @@ export const lessons = [ "duration": 35, "category": "Java 사고 모델", "goals": [ - "변수의 값과 그 값이 가리키는 객체를 구분한다.", - "참조 재할당과 객체 변경의 결과를 설명한다.", - "내용 동등성과 null·boxing의 경계를 다룬다." + "update(box) 뒤 값이 99가 아닌 15인 이유를 변수와 객체로 나눠 설명한다.", + "두 String의 ==와 equals 결과가 다른 이유를 설명한다.", + "null인 Integer를 계산할 때 발생하는 실패를 예상한다." ], "theory": [ { "heading": "객체도 값으로 전달된다", - "text": "Java의 메서드 호출은 값 전달입니다. 기본형은 그 값, 참조형은 참조값을 복사합니다. 두 참조가 같은 객체를 가리키면 한쪽의 객체 변경을 다른 쪽에서도 볼 수 있지만, 매개변수에 새 객체를 대입해도 호출자의 변수는 재할당되지 않습니다. 메모리 주소를 직접 다룬다는 설명 대신 변수·참조·객체를 나눠 호출 전후 상태를 그려 보세요." + "text": "예제의 main과 update에는 이름이 같은 box 변수가 각각 있습니다. 호출할 때 복사되는 것은 객체 자체가 아니라 그 객체를 가리키는 참조값입니다. 그래서 update의 box.value += 10은 두 변수가 함께 보는 기존 Box를 바꿉니다. 반면 box = new Box(99)는 update 안의 변수만 새 객체를 가리키게 합니다. 이를 값 전달이라고 합니다. int도 같은 규칙을 따라 replaceNumber 안에서 99를 대입해도 main의 number는 5입니다." }, { "heading": "같은 객체와 같은 내용", - "text": "참조형의 ==는 참조 동일성을 검사합니다. String.equals는 문자열 내용을 비교하고, Objects.equals는 null을 포함한 동등성 비교를 제공합니다. 사용자 정의 타입은 업무상 같은 값의 의미를 먼저 정해야 합니다. equals를 재정의한다면 hashCode 계약도 함께 지켜야 하며, 데이터베이스 식별자와 객체의 모든 필드가 항상 같은 동등성 기준인 것은 아닙니다." + "text": "main은 내용이 java인 String을 두 번 new로 만듭니다. left == right는 ‘같은 객체인가’를 물으므로 false이고, left.equals(right)는 String의 문자 내용을 비교하므로 true입니다. Objects.equals는 한쪽이나 양쪽이 null인 경우까지 다룹니다. 직접 만든 상품 타입에서는 무엇이 같아야 같은 상품인지 먼저 정해야 합니다. equals를 바꾼다면 equals가 true인 객체는 같은 hashCode를 갖도록 맞추세요. DB 식별자로 비교할지 모든 필드로 비교할지는 업무 요구에 따라 다릅니다." }, { "heading": "boxing은 null과 타입 변환을 숨길 수 있다", - "text": "Integer와 int 사이에는 boxing·unboxing이 일어납니다. null인 Integer를 산술식에 사용하면 unboxing 중 NullPointerException이 발생할 수 있습니다. Integer 객체의 == 결과를 값 비교 규칙으로 사용하지 마세요. 정수 나눗셈, 좁은 타입으로의 변환, null 허용 여부도 공개 메서드 계약에 포함할 대상입니다." + "text": "boxing은 int 값을 Integer 객체 형태로 다루는 변환이고, unboxing은 Integer에서 int 값을 꺼내는 변환입니다. count가 null인데 count + 1을 계산하면 꺼낼 int 값이 없어 NullPointerException이 납니다. Integer의 ==를 숫자 내용 비교로 사용하지 말고, 먼저 null을 오류·기본값·값 없음 중 무엇으로 볼지 정하세요. 정수 나눗셈이나 작은 타입으로의 변환도 호출자가 예상할 결과에 영향을 줍니다." } ], "example": { "title": "참조 복사와 객체 변경 · Main.java", "code": "class Main {\n static class Box {\n int value;\n Box(int value) { this.value = value; }\n }\n\n static void replaceNumber(int value) {\n value = 99;\n }\n\n static void update(Box box) {\n box.value += 10;\n box = new Box(99);\n }\n\n public static void main(String[] args) {\n int number = 5;\n Box box = new Box(5);\n replaceNumber(number);\n update(box);\n String left = new String(\"java\");\n String right = new String(\"java\");\n System.out.println(number);\n System.out.println(box.value);\n System.out.println(left == right);\n System.out.println(left.equals(right));\n }\n}", "output": "5\n15\nfalse\ntrue", - "explanation": "number는 5를 유지합니다. update는 기존 Box의 value를 15로 바꾸지만, 매개변수 box의 재할당은 호출자의 box를 바꾸지 않습니다. 두 String 객체는 별개이므로 ==는 false, 내용 비교는 true입니다." + "explanation": "출력은 5, 15, false, true입니다. replaceNumber는 자신이 받은 숫자 복사본만 99로 바꾸므로 number는 5입니다. update는 기존 Box의 필드를 15로 바꾼 뒤 자신의 box만 새 객체로 돌립니다. main의 box는 여전히 기존 객체를 봅니다. left와 right는 서로 다른 String이지만 내용은 같습니다." }, "quiz": [ { @@ -47,7 +47,7 @@ export const lessons = [ "항상 컴파일 오류가 발생한다." ], "correctIndex": 1, - "explanation": "참조값도 복사되어 전달됩니다. 매개변수 재할당은 호출자의 변수 재할당과 다릅니다. 같은 객체의 필드를 수정한 경우에는 그 객체를 함께 보는 참조에서 변경이 관찰됩니다." + "explanation": "호출자의 변수와 매개변수 x는 별개입니다. x에 새 객체를 넣어도 호출자의 참조는 그대로입니다. 예제의 update에서 box = new Box(99)와 box.value += 10이 서로 다른 결과를 만드는 이유입니다." }, { "id": "java-basics-q2", @@ -59,18 +59,18 @@ export const lessons = [ "모든 Java 버전에서 컴파일이 금지된다." ], "correctIndex": 2, - "explanation": "산술 연산에는 int 값이 필요하여 count를 unboxing합니다. null에서 int 값을 얻을 수 없어 실행 시 예외가 발생합니다. null 정책을 먼저 정하고 검사해야 합니다." + "explanation": "count + 1을 계산하려면 count에서 int 값을 꺼내야 합니다. null에는 꺼낼 값이 없어 실행 중 NullPointerException이 납니다. 컴파일 가능 여부와 실행 시 값의 유효성은 별도로 확인해야 합니다." } ], "practice": { "title": "외부 입력의 동등성 계약 정하기", - "scenario": "외부 시스템에서 읽은 상품 코드가 같은 문자열이지만 서로 다른 객체로 생성됩니다. 일부 입력에는 null이 있습니다.", + "scenario": "외부 시스템의 상품 코드 두 개가 모두 같은 문자열인데 == 비교는 false입니다. 어떤 요청은 코드가 null이고, 어떤 요청은 앞뒤 공백을 포함합니다.", "tasks": [ "같은 코드의 기준을 대소문자·공백·null 정책으로 명시하세요.", "참조 비교로 생기는 결함을 재현하고 내용 비교로 수정하세요.", "정규화가 원본 식별자의 의미를 바꿀 수 있는 사례를 하나 설명하세요." ], - "takeaway": "데이터를 비교하기 전에 같음의 업무 의미를 정합니다. null 처리와 정규화는 숨은 구현 편의가 아니라 호출 계약입니다." + "takeaway": "같은 상품 코드의 기준을 정한 뒤 비교하세요. 공백 제거·대소문자 변환·null 대체는 편의 기능이지만, 원래 다른 식별자를 같은 것으로 만들 수도 있습니다." }, "sources": [ { @@ -95,12 +95,46 @@ export const lessons = [ "jobTags": [ "Java" ], - "exerciseId": "java-basics-lab" + "exerciseId": "java-basics-lab", + "learningGuide": { + "scenario": "main에서 Box(5)를 update에 넘깁니다. 메서드 마지막에는 Box(99)를 대입했는데, 호출 뒤 출력은 15입니다.", + "question": "같은 box에 쓰는 두 줄인데, 왜 한 줄의 변경만 호출한 쪽에 남을까요?", + "terms": [ + { + "term": "참조값", + "meaning": "어떤 객체를 가리키는 값. box 변수와 Box 객체는 구별합니다." + }, + { + "term": "값 전달", + "meaning": "호출한 쪽의 변수값을 매개변수에 복사하는 규칙. 참조값도 복사됩니다." + }, + { + "term": "재할당과 객체 변경", + "meaning": "box = ...는 변수의 참조값을 바꾸고, box.value = ...는 참조가 가리키는 객체의 필드를 바꿉니다." + } + ], + "walkthrough": [ + "main의 box가 가리키는 Box의 value는 5입니다. update의 매개변수 box도 같은 객체를 가리키며 시작합니다.", + "box.value += 10은 그 기존 객체의 value를 15로 바꿉니다.", + "box = new Box(99)는 update 안의 box만 새 객체로 돌립니다. main의 box에 대입한 것이 아닙니다.", + "update가 끝나면 main은 기존 Box를 계속 가리키므로 box.value에서 15를 읽습니다." + ], + "prediction": { + "question": "update(box) 호출 뒤 main의 box.value는 5, 15, 99 중 무엇일까요?", + "answer": "15입니다.", + "explanation": "기존 객체의 필드 변경은 남지만, 매개변수 재할당은 호출자의 변수를 바꾸지 않습니다. 두 대입의 대상이 다릅니다." + }, + "takeaway": "메서드가 객체를 바꾼 것인지, 자신의 변수가 가리키는 대상을 바꾼 것인지부터 확인하면 변경이 사라진 것처럼 보이는 버그를 설명할 수 있습니다.", + "code": { + "file": "examples/java8/java-basics/Main.java", + "source": " static void update(Box box) {\n box.value += 10;\n box = new Box(99);\n }" + } + } }, { "id": "oop", "title": "OOP: 책임과 불변식을 객체에 두기", - "description": "필드를 숨기는 수준을 넘어 잘못된 상태가 만들어지는 경로를 줄입니다.", + "description": "잔액 100에서 40을 쓰고 70을 다시 요청합니다. 거절 뒤에도 잔액이 60으로 남도록 상태 변경 규칙을 객체 안에 모읍니다.", "track": "both", "level": "심화", "minVersion": 8, @@ -109,29 +143,29 @@ export const lessons = [ "duration": 50, "category": "객체 설계", "goals": [ - "객체가 유지해야 할 불변식을 문장으로 정의한다.", - "생성자·명령 메서드·조회 메서드의 책임을 구분한다.", - "가변 상태를 외부에 노출하는 비용을 설명한다." + "‘잔액은 항상 0 이상’처럼 객체가 지켜야 할 상태 규칙을 정한다.", + "생성 시 검사와 사용 중 검사를 구분하고, 실패 후 상태가 그대로인지 확인한다.", + "필드·컬렉션을 외부에 노출했을 때 규칙을 우회할 수 있는 경로를 찾는다." ], "theory": [ { "heading": "캡슐화는 private 필드보다 넓다", - "text": "캡슐화의 핵심은 객체의 상태 변경 규칙을 한곳에서 지키는 것입니다. private 필드를 두고 모든 setter를 공개해도 호출자가 잘못된 상태를 만들 수 있다면 규칙은 보호되지 않습니다. 생성자는 유효한 초기 상태를 만들고, 명령 메서드는 성공·실패 후 유지해야 할 조건을 명시해야 합니다." + "text": "Credit의 balance는 private이지만, 이것만으로 잔액 규칙이 완성되지는 않습니다. 아무 숫자나 넣는 setBalance가 공개되어 있다면 음수도 들어갈 수 있습니다. 예제는 생성자가 음수 초기값을 거절하고, 잔액 변경은 spend에서만 처리하도록 합니다. 이렇게 상태를 바꿀 수 있는 경로와 그 규칙을 함께 관리하는 것이 캡슐화입니다. balance()는 값을 알려 주고, spend는 요청을 검사한 뒤 상태를 바꿉니다." }, { "heading": "불변식과 실패의 원자성", - "text": "예를 들어 적립 잔액이 0 이상이어야 한다면 차감 전 검증과 차감 후 조건이 필요합니다. 오류를 발견하기 전에 일부 필드를 바꾸면 호출자는 실패 후 상태를 예측하기 어렵습니다. 여러 상태를 한 번에 바꾸는 메서드는 검증 순서와 실패 시 보존할 상태를 설계하세요. 이 수업의 단일 객체 규칙은 멀티스레드·분산 트랜잭션 보장을 뜻하지 않습니다." + "text": "불변식은 객체가 유효한 상태에서 항상 지켜야 하는 규칙입니다. Credit에서는 balance가 0 이상이어야 합니다. spend(40)는 100을 60으로 바꾸지만, 이어진 spend(70)는 차감 전에 false를 반환해 60을 보존합니다. 실패했을 때 일부 변경만 남기지 않는 성질을 여기서는 실패의 원자성이라고 부릅니다. 먼저 차감한 뒤 부족함을 발견하는 순서라면 이 규칙을 놓치기 쉽습니다. 이 예제는 단일 객체의 단일 스레드 동작이며, 동시 호출이나 DB 트랜잭션까지 보장하지 않습니다." }, { "heading": "불변 객체와 협력 경계", - "text": "final 참조는 재할당을 막지만 참조 대상 객체의 내부 변경까지 막지는 않습니다. 컬렉션을 반환할 때는 읽기 전용 뷰, 복사본, 요소의 가변성까지 구분해야 합니다. 모든 객체를 불변으로 만드는 것보다 누가 변경을 소유하는지 설명할 수 있는 설계를 목표로 합니다. 서비스·저장소·도메인 객체의 책임은 실제 변경 요구로 검토합니다." + "text": "final List 필드는 다른 리스트로 재할당하지 못하게 할 뿐, 그 리스트의 add까지 금지하지는 않습니다. 객체가 내부 목록을 그대로 반환하면 호출자가 내용을 바꿔 객체의 규칙을 우회할 수 있습니다. 변경 불가 뷰를 줄지, 목록을 복사할지, 목록 안의 요소도 변경 가능한지 나눠 판단하세요. ‘모든 것을 불변으로 만들기’보다 어떤 객체가 변경을 담당하는지 분명히 하는 것이 목표입니다. 서비스·저장소·도메인 객체의 역할도 같은 기준으로 검토합니다." } ], "example": { "title": "잔액 규칙을 지키는 객체 · Main.java", "code": "class Main {\n static final class Credit {\n private int balance;\n\n Credit(int initialBalance) {\n if (initialBalance < 0) {\n throw new IllegalArgumentException(\"negative balance\");\n }\n balance = initialBalance;\n }\n\n boolean spend(int amount) {\n if (amount <= 0 || amount > balance) {\n return false;\n }\n balance -= amount;\n return true;\n }\n\n int balance() { return balance; }\n }\n\n public static void main(String[] args) {\n Credit credit = new Credit(100);\n System.out.println(credit.spend(40));\n System.out.println(credit.spend(70));\n System.out.println(credit.balance());\n }\n}", "output": "true\nfalse\n60", - "explanation": "첫 차감 후 잔액은 60입니다. 두 번째 차감은 잔액을 초과하므로 실패하고 상태를 바꾸지 않습니다. 호출자가 balance를 임의로 설정하는 setter는 제공하지 않습니다." + "explanation": "생성자가 잔액 100을 받아 시작합니다. spend(40)는 검사를 통과해 60을 저장하고 true를 반환합니다. spend(70)는 amount > balance에서 false를 반환하므로 차감 줄에 도달하지 않습니다. 마지막 balance()는 그대로 60입니다. 잔액을 직접 넣는 setter 대신 ‘사용 요청’ 메서드가 검증과 변경을 함께 담당합니다." }, "quiz": [ { @@ -144,7 +178,7 @@ export const lessons = [ "setter 이름을 바꾸면 문제가 해결된다." ], "correctIndex": 2, - "explanation": "접근 제한자는 도구입니다. 공개된 변경 메서드가 업무 규칙을 무시하면 잘못된 상태를 만들 수 있습니다. 규칙을 가진 명령 메서드와 유효성 검증을 함께 설계해야 합니다." + "explanation": "private은 직접 접근을 제한하지만 setStock(-1)이 허용되면 공개 메서드로 음수 상태를 만들 수 있습니다. 생성자와 이후 변경 메서드 모두에서 ‘재고는 0 이상’이라는 규칙을 지켜야 합니다." }, { "id": "oop-q2", @@ -156,18 +190,18 @@ export const lessons = [ "다른 스레드와 공유해도 모든 복합 연산이 원자적이다." ], "correctIndex": 0, - "explanation": "final은 해당 필드의 참조 재할당을 제한합니다. 리스트의 변경 가능성, 요소의 가변성, 동시성 보장은 별도 계약입니다." + "explanation": "final이 막는 것은 names에 다른 리스트를 대입하는 일입니다. 현재 리스트가 ArrayList처럼 변경을 허용하면 add는 가능합니다. 요소까지 불변인지, 여러 작업을 묶어 안전하게 처리하는지는 별도 문제입니다." } ], "practice": { "title": "예약 도메인의 상태 변경 경로 줄이기", - "scenario": "재고 수량을 여러 서비스가 직접 수정하면서 음수 재고와 실패 후 부분 변경이 발생했습니다.", + "scenario": "예약 실패가 반환됐는데 재고는 이미 줄어 있습니다. 여러 서비스가 재고 값을 직접 설정해 어떤 경로에서 잘못된 상태가 생겼는지 찾기 어렵습니다.", "tasks": [ "생성·예약·취소 각각의 사전조건과 사후조건을 적으세요.", "호출자가 규칙을 우회하는 setter와 가변 컬렉션 노출을 찾으세요.", "실패한 예약 뒤 상태가 유지되는 테스트를 먼저 제시하세요." ], - "takeaway": "객체를 나눴다는 사실보다 어떤 규칙을 누가 지키는지와 실패 후 상태가 명확한지가 중요합니다." + "takeaway": "상태를 바꾸는 코드를 한곳으로 모으고, 성공 뒤 값뿐 아니라 거절 뒤 보존할 값도 테스트하세요. 상태 규칙의 책임이 분명해져야 호출자도 실패를 다룰 수 있습니다." }, "sources": [ { @@ -193,12 +227,46 @@ export const lessons = [ "Java", "Spring Boot" ], - "exerciseId": "oop-lab" + "exerciseId": "oop-lab", + "learningGuide": { + "scenario": "잔액 100인 Credit에 40 사용을 요청한 뒤 70 사용을 요청합니다. 두 번째 요청이 실패해도 첫 번째 결과인 잔액 60은 유지해야 합니다.", + "question": "spend는 어느 줄까지 실행해야 실패 뒤에도 잔액을 지킬 수 있을까요?", + "terms": [ + { + "term": "캡슐화", + "meaning": "필드를 숨기는 것과 함께, 상태를 바꿀 수 있는 경로와 규칙을 객체 안에서 관리하는 것." + }, + { + "term": "불변식", + "meaning": "객체가 항상 지켜야 하는 상태 규칙. 여기서는 balance가 0 이상이어야 합니다." + }, + { + "term": "실패의 원자성", + "meaning": "작업이 실패했을 때 일부 변경만 남지 않도록 하는 것. 여기서는 거절 시 기존 잔액을 유지합니다." + } + ], + "walkthrough": [ + "첫 spend(40) 호출은 amount가 양수이고 balance 100 이하이므로 if를 통과합니다.", + "balance -= amount에서 잔액을 60으로 저장하고 true를 반환합니다.", + "다음 spend(70) 호출은 70 > 60이므로 if 안에서 false를 반환합니다.", + "거절 경로는 balance -= amount에 도달하지 않습니다. 그래서 실패 후에도 잔액 60을 읽을 수 있습니다." + ], + "prediction": { + "question": "두 번의 spend가 끝난 뒤 성공 여부와 잔액은 무엇일까요?", + "answer": "true, false, 잔액 60입니다.", + "explanation": "검사가 변경보다 먼저입니다. 두 번째 요청은 검증 단계에서 끝나므로 70을 차감한 흔적이 남지 않습니다." + }, + "takeaway": "예약·차감·취소 메서드는 실패 메시지만 확인하지 말고, 거절 뒤 어떤 필드가 그대로 남아야 하는지도 확인하세요.", + "code": { + "file": "examples/java8/oop/Main.java", + "source": " boolean spend(int amount) {\n if (amount <= 0 || amount > balance) {\n return false;\n }\n balance -= amount;\n return true;\n }" + } + } }, { "id": "polymorphism", "title": "OOP: 다형성, 계약과 합성", - "description": "바뀌는 정책을 분리하면서 기존 호출자의 기대를 유지합니다.", + "description": "Report의 render는 그대로 두고 출력 형식만 바꿉니다. 다른 구현을 끼워 넣어도 호출자가 기대하는 입력·결과를 지키는지 살펴봅니다.", "track": "both", "level": "심화", "minVersion": 8, @@ -207,29 +275,29 @@ export const lessons = [ "duration": 55, "category": "객체 설계", "goals": [ - "다형성을 타입 분기 제거와 계약 유지의 관점에서 설명한다.", - "상속과 합성의 비용을 비교한다.", - "의존 관계와 테스트 대역의 연결을 이해한다." + "Report가 구체적인 Formatter 종류를 검사하지 않아도 동작하는 이유를 설명한다.", + "새 구현이 기존에 허용하던 입력과 결과 약속을 깨뜨리는지 확인한다.", + "상속·합성·인터페이스 추가의 이득과 읽기 비용을 실제 변경 요청으로 비교한다." ], "theory": [ { "heading": "교체 가능성에는 행동 계약이 필요하다", - "text": "인터페이스를 구현했다는 사실만으로 안전하게 교체할 수 있는 것은 아닙니다. 허용 입력, 반환값, 실패 조건과 부수 효과를 호출자가 기대할 수 있어야 합니다. LSP를 검토할 때는 새 구현이 기존 호출자에게 더 강한 사전조건을 요구하거나 약속한 결과를 약화하는지 살펴보세요. 이는 언어 문법 이상의 설계 판단입니다." + "text": "Report는 Formatter의 format을 호출합니다. 구현을 바꿔도 같은 메서드 이름으로 요청할 수 있는 것이 다형성입니다. 하지만 타입이 같다고 업무 행동까지 같아지는 것은 아닙니다. 예를 들어 0 이상 금액을 받던 정책을 10000 이상만 받는 정책으로 바꾸면 기존 호출이 실패합니다. 입력 조건, 반환 결과, 예외, 외부 상태 변경까지 호출자가 기대할 수 있는 약속을 행동 계약이라고 부릅니다. LSP는 이런 교체가 기존 호출을 깨뜨리지 않는지 검토하는 원칙입니다." }, { "heading": "상속과 합성은 변경 이유로 선택한다", - "text": "상속은 상위 타입의 구현·확장 규칙과 결합됩니다. 정책 교체가 목적이라면 별도 객체를 주입해 협력시키는 합성이 변경 지점을 더 명확하게 만들 수 있습니다. 반대로 모든 메서드에 인터페이스를 추가하면 작은 변경에도 파일과 탐색 비용이 늘어납니다. SOLID는 점수표가 아니라 결합과 변경 비용을 검토하는 관점으로 사용합니다." + "text": "Report는 Formatter를 상속하지 않고 필드에 보관해 일을 맡깁니다. 필요한 객체를 조합하는 이 방식이 합성입니다. 출력 형식이 늘어도 Report의 render는 그대로 두고 Formatter를 바꿀 수 있습니다. 상속은 상위 클래스의 구현과 확장 규칙을 함께 받아들이므로 변경 영향이 다릅니다. 반대로 교체할 필요가 없는 작은 기능마다 인터페이스를 만들면 찾아갈 파일만 늘 수 있습니다. SOLID는 인터페이스 개수를 늘리는 규칙이 아니라 변경 영향과 책임을 검토하는 관점으로 사용하세요." }, { "heading": "수동 의존성 주입에서 실행 원리 이해하기", - "text": "생성자나 메서드 인자로 협력 객체를 전달하면 선택과 사용을 분리할 수 있습니다. 테스트에서는 같은 계약을 지키는 대역으로 외부 환경을 통제합니다. 이 수업은 순수 Java의 수동 조립입니다. Spring 컨테이너·프록시·AOP를 실행하는 예제가 아니며, 프레임워크 과정에서는 이 구조를 기반으로 객체 생성과 호출 경로가 어떻게 바뀌는지 다룹니다." + "text": "main이 ‘어떤 Formatter를 쓸지’를 선택하고 생성자로 Report에 넘깁니다. Report는 ‘받은 Formatter를 사용하기’만 합니다. 사용할 객체를 외부에서 전달하는 것을 의존성 주입이라고 합니다. 테스트에서는 같은 계약을 지키는 단순한 구현을 넣어 외부 환경을 통제할 수 있습니다. 예제는 사람이 main에서 조립하는 순수 Java 코드입니다. Spring의 컨테이너·프록시·AOP를 실행한 것이 아니며, 후속 과정에서는 누가 조립하고 어떤 경로로 호출하는지 비교합니다." } ], "example": { "title": "출력 정책을 조합하는 객체 · Main.java", "code": "class Main {\n interface Formatter {\n String format(String value);\n }\n\n static final class Report {\n private final Formatter formatter;\n\n Report(Formatter formatter) {\n this.formatter = java.util.Objects.requireNonNull(formatter);\n }\n\n String render(String value) {\n return formatter.format(value);\n }\n }\n\n public static void main(String[] args) {\n Report bracketed = new Report(value -> \"[\" + value + \"]\");\n Report prefixed = new Report(value -> \"report:\" + value);\n System.out.println(bracketed.render(\"stock\"));\n System.out.println(prefixed.render(\"stock\"));\n }\n}", "output": "[stock]\nreport:stock", - "explanation": "Report는 포맷터의 구체 타입을 확인하지 않고 계약으로 협력합니다. 생성자의 수동 주입으로 같은 Report를 서로 다른 정책과 조합합니다. 람다는 Java 8에서 사용할 수 있습니다." + "explanation": "bracketed와 prefixed는 둘 다 Report입니다. 생성자에 전달한 람다만 달라 첫 객체는 [stock], 두 번째 객체는 report:stock을 만듭니다. render는 formatter.format(value) 한 줄로 위임하며 포맷터 종류를 검사하지 않습니다. requireNonNull은 협력 객체 자체가 빠진 구성을 생성 시 거절합니다. 람다는 Java 8에서 지원합니다." }, "quiz": [ { @@ -242,7 +310,7 @@ export const lessons = [ "인터페이스 파일의 개수" ], "correctIndex": 1, - "explanation": "교체 전후 호출자가 기대하는 유효 입력이 달라졌습니다. 더 강한 사전조건을 요구하면 타입이 같아도 기존 사용을 깨뜨릴 수 있습니다." + "explanation": "기존 호출자는 0부터 9999까지도 유효한 입력이라고 믿고 사용했습니다. 새 구현이 이 범위를 거절하면 인터페이스가 같아도 교체 때문에 기존 기능이 깨집니다. 이를 입력 조건을 더 강하게 요구한 문제로 설명할 수 있습니다." }, { "id": "polymorphism-q2", @@ -254,18 +322,18 @@ export const lessons = [ "실제 교체·테스트 요구와 추가 추상화 비용을 비교해 선택한다." ], "correctIndex": 3, - "explanation": "합성은 유용한 도구지만 무조건적인 규칙은 아닙니다. 변경 이유, 계약, 테스트 경계, 읽기 비용을 근거로 판단합니다." + "explanation": "출력 형식을 실제로 교체해야 하는 Report에는 합성이 도움이 됩니다. 그러나 교체 요구가 없는 한 줄짜리 계산까지 분리하면 이해해야 할 파일이 늘어납니다. 변경 이유와 테스트 요구가 그 비용을 정당화하는지 판단합니다." } ], "practice": { "title": "할인 정책 변경 요청 대응", - "scenario": "정액 할인만 있던 결제 모듈에 비율 할인과 기간 한정 정책이 추가됩니다.", + "scenario": "결제 모듈의 정액 할인에 비율 할인과 기간 한정 할인이 추가됩니다. 할인 종류마다 결제 코드를 바꾸다 보니 음수 할인이나 결제액보다 큰 할인을 놓칩니다.", "tasks": [ "정책이 반환할 할인액의 허용 범위와 실패 조건을 정하세요.", "정책 종류를 검사하는 조건문 없이 교체 가능한 협력을 만드세요.", "새 정책의 계약 위반을 잡는 테스트와 추상화 비용을 설명하세요." ], - "takeaway": "다형성은 구현 선택을 숨기는 데 그치지 않고, 교체 후에도 호출자가 믿는 계약을 유지해야 합니다." + "takeaway": "정책을 교체하기 전에 허용 입력·할인액 범위·실패 조건을 공통으로 정하세요. 같은 타입으로 호출할 수 있다는 사실과 기존 기능을 깨뜨리지 않는다는 사실은 다릅니다." }, "sources": [ { @@ -291,12 +359,50 @@ export const lessons = [ "Java", "Spring Boot" ], - "exerciseId": "polymorphism-lab" + "exerciseId": "polymorphism-lab", + "learningGuide": { + "scenario": "같은 stock 내용을 화면에서는 [stock], 로그에서는 report:stock으로 표시하려고 합니다. Report의 본문 처리까지 두 벌로 복사하고 싶지는 않습니다.", + "question": "Report는 Formatter의 구체적인 종류를 모르는데 어떻게 서로 다른 결과를 만들까요?", + "terms": [ + { + "term": "다형성", + "meaning": "같은 타입과 메서드로 요청하되, 연결된 구현에 따라 다른 동작을 수행하는 것." + }, + { + "term": "합성", + "meaning": "상속으로 기능을 가져오는 대신 필요한 객체를 필드에 두고 일을 맡기는 방식." + }, + { + "term": "의존성 주입", + "meaning": "사용할 협력 객체를 내부에서 고르지 않고 생성자 같은 외부 경로로 받는 것." + }, + { + "term": "행동 계약", + "meaning": "호출자가 기대해도 되는 입력 조건·결과·실패·상태 변경의 약속." + } + ], + "walkthrough": [ + "Report 생성자는 전달받은 formatter를 필드에 저장합니다. requireNonNull은 null인 협력 객체를 거절합니다.", + "전체 예제의 main은 대괄호를 붙이는 람다와 report:를 붙이는 람다를 서로 다른 Report에 넣습니다.", + "render(\"stock\")는 현재 Report가 가진 formatter의 format에 stock을 전달합니다.", + "같은 render 코드라도 연결된 람다가 다르므로 [stock]과 report:stock이 각각 반환됩니다." + ], + "prediction": { + "question": "두 Report에서 render 구현이 같다면 출력도 반드시 같을까요?", + "answer": "아닙니다. 각각 [stock]과 report:stock을 출력합니다.", + "explanation": "render가 요청하는 메서드 계약은 같지만 실제 처리를 맡은 Formatter가 다릅니다. 선택은 main에서, 사용은 Report에서 이루어집니다." + }, + "takeaway": "할인·알림·포맷 정책을 바꿀 때 호출 코드의 조건문을 늘리기 전에, 교체할 부분을 협력 객체로 넘길 수 있는지 확인하세요.", + "code": { + "file": "examples/java8/polymorphism/Main.java", + "source": " static final class Report {\n private final Formatter formatter;\n\n Report(Formatter formatter) {\n this.formatter = java.util.Objects.requireNonNull(formatter);\n }\n\n String render(String value) {\n return formatter.format(value);\n }\n }" + } + } }, { "id": "collections", "title": "컬렉션: 순서, 동등성과 변경 계약", - "description": "List·Set·Map을 자료구조 이름이 아니라 호출 계약으로 선택합니다.", + "description": "원본 목록의 첫 값을 바꿨는데 복사본에는 이전 값이 남습니다. 순서·중복·해시와 함께 뷰와 복사본의 차이를 코드로 확인합니다.", "track": "both", "level": "기본", "minVersion": 8, @@ -305,29 +411,29 @@ export const lessons = [ "duration": 40, "category": "타입과 데이터", "goals": [ - "순서·중복·키 조회 요구로 컬렉션을 선택한다.", - "equals와 hashCode가 조회에 미치는 영향을 설명한다.", - "뷰와 복사본, 요소의 가변성을 구분한다." + "counts가 java를 두 번 세는 과정을 키 조회와 값 갱신으로 설명한다.", + "같은 키의 기준인 equals와 hashCode가 어긋나면 조회가 깨질 수 있음을 설명한다.", + "unmodifiableList로 감싸는 것과 그 전에 ArrayList로 복사하는 것을 구분한다." ], "theory": [ { "heading": "인터페이스와 구현이 약속하는 것", - "text": "List는 위치와 중복을, Set은 중복 없는 요소를, Map은 키와 값의 대응을 다룹니다. Map은 Collection의 하위 인터페이스가 아닙니다. HashMap의 순회 순서를 업무 요구로 가정하지 마세요. 최초 등장 순서가 필요한 중복 제거처럼 순서가 계약이라면 LinkedHashSet 등의 구현을 근거로 선택합니다." + "text": "예제의 tags에는 java가 두 번 들어 있습니다. List는 위치가 있고 중복도 허용하므로 이 입력을 그대로 담습니다. counts는 태그를 키, 개수를 값으로 연결하는 Map이며, Map은 Collection의 하위 인터페이스가 아닙니다. 중복 없는 값 자체가 필요하면 Set을 검토합니다. 순회 순서도 별도로 정해야 합니다. HashMap의 순서를 믿지 말고, 처음 등장한 순서를 유지해야 하는 중복 제거라면 LinkedHashSet처럼 그 순서를 약속하는 구현을 선택하세요." }, { "heading": "동등성과 해시의 계약", - "text": "equals로 같은 두 객체는 같은 hashCode를 반환해야 합니다. 역은 성립하지 않아 해시 충돌이 있을 수 있습니다. 해시 기반 컬렉션에 넣은 뒤 동등성·해시에 사용한 필드를 바꾸면 원래 기대한 조회가 깨질 수 있습니다. 값 객체나 안정적인 식별자를 키로 쓰는 이유를 이 계약과 연결해 설명하세요." + "text": "counts.get(\"java\")가 기존 항목을 찾으려면 키의 ‘같음’이 일관되어야 합니다. 해시 기반 컬렉션에서는 equals로 같은 객체가 같은 hashCode를 반환해야 합니다. 반대로 해시값이 같아도 다른 객체일 수 있습니다. 키를 저장한 뒤 equals나 hashCode에 쓰는 필드를 바꾸면 같은 객체를 갖고도 기대한 조회가 깨질 수 있습니다. 그래서 상품 코드처럼 비교 기준이 안정적인 값을 키로 쓰는지 확인합니다." }, { "heading": "변경 불가 뷰는 스냅샷이 아니다", - "text": "Collections.unmodifiableList는 지정 리스트에 대한 변경 불가 뷰입니다. 원본 리스트가 바뀌면 뷰에서 변경을 볼 수 있습니다. new ArrayList로 컨테이너를 복사한 뒤 감싸면 구조 변경을 분리할 수 있지만 요소 자체를 깊게 복사하지는 않습니다. Arrays.asList는 크기가 고정된 리스트이므로 일반 ArrayList와 같은 추가·삭제 계약으로 사용하면 안 됩니다." + "text": "unmodifiableList는 원본을 복사하는 기능이 아닙니다. 같은 목록을 읽되 그 뷰를 통한 변경을 막습니다. 예제는 먼저 new ArrayList(tags)로 별도 목록을 만든 다음 감싸므로 tags.set(0, \"other\")가 snapshot에는 반영되지 않습니다. 이를 얕은 복사라고 부릅니다. 목록은 별개지만 각 요소 객체까지 새로 만드는 것은 아닙니다. 또 tags를 만든 Arrays.asList는 크기가 고정되어 add·remove와 set의 허용 범위가 일반 ArrayList와 다릅니다." } ], "example": { "title": "집계와 컨테이너 복사 · Main.java", "code": "class Main {\n public static void main(String[] args) {\n java.util.List tags =\n java.util.Arrays.asList(\"java\", \"sql\", \"java\");\n java.util.Map counts =\n new java.util.LinkedHashMap();\n for (String tag : tags) {\n counts.put(tag, counts.getOrDefault(tag, 0) + 1);\n }\n java.util.List snapshot =\n java.util.Collections.unmodifiableList(\n new java.util.ArrayList(tags));\n tags.set(0, \"other\");\n System.out.println(counts.get(\"java\"));\n System.out.println(snapshot.get(0));\n System.out.println(tags.get(0));\n }\n}", "output": "2\njava\nother", - "explanation": "집계용 Map은 java를 두 번 센 결과를 가집니다. snapshot의 컨테이너를 먼저 복사했으므로 tags.set의 영향을 받지 않습니다. 이 예제의 String은 불변이지만, 가변 요소를 쓸 때는 요소의 공유도 별도로 판단해야 합니다." + "explanation": "반복문은 java를 처음 만나면 0+1, 다시 만나면 1+1을 counts에 저장하므로 첫 출력은 2입니다. snapshot을 만들 때 목록을 먼저 복사했기 때문에 원본 tags의 첫 항목을 other로 교체해도 snapshot은 java를 유지합니다. String 자체는 불변입니다. 요소가 가변 객체라면 별도 목록 안에서도 같은 요소 객체를 공유한다는 점을 추가로 확인해야 합니다." }, "quiz": [ { @@ -340,7 +446,7 @@ export const lessons = [ "HashMap에서는 hashCode가 사용되지 않는다." ], "correctIndex": 1, - "explanation": "같다고 판단한 객체는 같은 해시값을 가져야 합니다. 해시값이 같은 두 객체가 반드시 equals까지 true인 것은 아닙니다." + "explanation": "해시로 조회 위치를 좁힌 뒤 같음을 판정하는 컬렉션이 일관되게 동작하려면 equals가 true인 키의 해시도 같아야 합니다. 다만 서로 다른 키가 같은 해시를 갖는 충돌은 허용됩니다." }, { "id": "collections-q2", @@ -352,18 +458,18 @@ export const lessons = [ "모든 요소가 깊게 복사된다." ], "correctIndex": 2, - "explanation": "unmodifiableList는 지정 리스트를 읽는 뷰입니다. 뷰 경유 변경은 제한하지만 원본이 다른 경로에서 바뀌는 것까지 차단하지 않습니다." + "explanation": "뷰는 별도 목록을 만들지 않고 원본을 읽습니다. 원본에 추가하면 뷰에서도 보입니다. 예제의 snapshot이 다른 이유는 unmodifiableList 전에 new ArrayList로 원본과 별도의 목록을 만들었기 때문입니다." } ], "practice": { "title": "중복 이벤트 식별자의 보관 방식 선택", - "scenario": "배치에서 읽은 이벤트 코드의 중복을 제거하되 최초 순서를 유지해야 합니다.", + "scenario": "배치의 이벤트 코드 목록에 중복이 있습니다. 최초 등장 순서는 유지해야 하고, 결과를 받은 호출자가 목록을 수정해도 배치가 보관한 원본은 바뀌지 않아야 합니다.", "tasks": [ "중복 판정 기준과 null 허용 여부를 정하세요.", "요구 순서를 보장하는 컬렉션을 선택하세요.", "반환 결과 변경이 원본 데이터에 영향을 주지 않는 테스트를 작성하세요." ], - "takeaway": "자료구조 선택은 성능 이름표만으로 결정하지 않습니다. 동등성·순서·변경 가능성도 API 계약입니다." + "takeaway": "‘Set을 쓸까 List를 쓸까’보다 중복·순서·변경 허용 요구부터 적으세요. 복사한 것이 목록인지 요소까지인지 확인해야 반환값을 안전하게 공유할 수 있습니다." }, "sources": [ { @@ -388,12 +494,46 @@ export const lessons = [ "jobTags": [ "Java" ], - "exerciseId": "collections-lab" + "exerciseId": "collections-lab", + "learningGuide": { + "scenario": "tags의 java를 센 뒤 현재 목록을 보관하려고 합니다. 이후 원본 첫 값을 other로 바꿔도 snapshot에는 java가 남아야 합니다.", + "question": "unmodifiableList만으로 충분할까요? 그 안의 new ArrayList(tags)는 무엇을 지킬까요?", + "terms": [ + { + "term": "변경 불가 뷰", + "meaning": "원본을 읽되, 이 뷰를 통한 추가·삭제·교체를 막는 객체. 원본을 자동 복사하지 않습니다." + }, + { + "term": "얕은 복사", + "meaning": "목록 자체는 별도로 만들지만 요소 객체의 참조는 공유하는 복사." + }, + { + "term": "키의 동등성", + "meaning": "Map이 어떤 두 키를 같은 항목으로 취급할지 정하는 기준. 해시 기반 구현에서는 equals와 hashCode가 함께 맞아야 합니다." + } + ], + "walkthrough": [ + "counts는 tags의 java, sql, java를 차례로 읽습니다. getOrDefault는 아직 없는 키의 개수를 0으로 보고, put은 증가한 개수를 저장합니다.", + "new ArrayList(tags)가 현재 요소들을 담는 별도 목록을 만듭니다.", + "unmodifiableList는 그 복사된 목록을 감쌉니다. snapshot을 통해 항목을 교체하지 못하게 합니다.", + "tags.set(0, \"other\")는 원본 목록의 첫 자리만 바꿉니다. counts의 집계 결과와 snapshot의 첫 자리는 다시 계산되거나 함께 교체되지 않습니다." + ], + "prediction": { + "question": "마지막 세 출력은 무엇일까요?", + "answer": "2, java, other입니다.", + "explanation": "집계는 변경 전에 끝나 java의 개수가 2입니다. snapshot은 별도 목록을 읽으므로 java를 유지하고, 원본 tags만 other로 바뀝니다." + }, + "takeaway": "조회 결과를 다른 코드에 넘길 때 ‘수정하지 못하게 함’과 ‘나중 원본 변경을 보지 않게 함’을 따로 결정하세요.", + "code": { + "file": "examples/java8/collections/Main.java", + "source": " java.util.List tags =\n java.util.Arrays.asList(\"java\", \"sql\", \"java\");\n java.util.Map counts =\n new java.util.LinkedHashMap();\n for (String tag : tags) {\n counts.put(tag, counts.getOrDefault(tag, 0) + 1);\n }\n java.util.List snapshot =\n java.util.Collections.unmodifiableList(\n new java.util.ArrayList(tags));\n tags.set(0, \"other\");\n System.out.println(counts.get(\"java\"));\n System.out.println(snapshot.get(0));\n System.out.println(tags.get(0));" + } + } }, { "id": "generics", "title": "제네릭: 불공변성, 경계와 타입 소거", - "description": "캐스팅으로 경고를 숨기지 않고 읽기·쓰기 역할에 맞는 API를 설계합니다.", + "description": "정수 목록과 소수 목록을 같은 sum으로 읽습니다. 받을 수 있는 목록을 넓히면서 잘못된 값 추가는 막는 타입 규칙을 이해합니다.", "track": "both", "level": "심화", "minVersion": 8, @@ -402,29 +542,29 @@ export const lessons = [ "duration": 45, "category": "타입과 데이터", "goals": [ - "List가 List의 하위 타입이 아닌 이유를 설명한다.", - "extends와 super를 읽기·쓰기 역할로 선택한다.", - "타입 소거와 raw type의 한계를 이해한다." + "sum이 Integer 목록과 Double 목록을 모두 읽을 수 있는 이유를 설명한다.", + "List를 List로 다루면 어떤 잘못된 추가가 가능해지는지 설명한다.", + "extends·super·타입 소거를 읽기·쓰기·실행 시 검사와 연결한다." ], "theory": [ { "heading": "하위 타입 관계가 그대로 전달되지 않는다", - "text": "Integer가 Number의 하위 타입이어도 List는 List의 하위 타입이 아닙니다. 만약 대입을 허용하면 Number를 받는 쪽에서 Double을 추가해 Integer 전용 리스트의 약속을 깨뜨릴 수 있습니다. 배열의 공변성과 런타임 검사에 익숙하다면 제네릭의 컴파일 시점 제약과 비교해 보세요." + "text": "Integer는 Number의 하위 타입이지만 List를 List 변수에 넣을 수는 없습니다. 허용한다면 그 변수를 통해 Double도 추가할 수 있어 ‘정수만 들어 있다’는 원래 목록의 약속이 깨집니다. 요소 타입의 상하 관계가 목록 타입으로 그대로 이어지지 않는 성질을 불공변성이라고 합니다. 배열은 다른 규칙으로 대입을 허용하고 잘못된 저장을 실행 중 검사하므로, 배열에서 되던 대입을 제네릭 목록에도 그대로 기대하면 안 됩니다." }, { "heading": "생산자는 extends, 소비자는 super", - "text": "List에서는 Number로 읽을 수 있지만 특정 Number 값을 안전하게 추가할 수는 없습니다. 실제 리스트의 요소 타입을 모르기 때문입니다. List에는 Integer를 추가할 수 있지만 읽은 값의 일반적인 정적 타입은 Object입니다. 양쪽 역할이 필요한 API는 wildcard를 기계적으로 붙이지 말고 타입 매개변수 관계를 먼저 정하세요." + "text": "sum은 목록에 값을 넣지 않고 각 값을 Number로 읽어 doubleValue를 호출합니다. 그래서 List가 맞습니다. 물음표는 정확한 요소 타입은 모르지만 Number 또는 그 하위 타입이라는 뜻입니다. 실제로 Double 목록일 수 있으므로 non-null Integer를 임의로 추가할 수는 없습니다. 반대로 List에는 Integer를 추가할 수 있지만 읽은 값의 일반적 정적 타입은 Object입니다. 이는 타입 안전성에 관한 규칙이며, 변경 불가 목록의 add 성공까지 보장하지 않습니다. 읽고 쓰는 역할이 모두 필요하면 두 타입 사이의 관계부터 정하세요. extends 자체가 null 추가나 clear·remove를 타입 수준에서 금지하는 것은 아니므로 ‘불변 목록’으로 이해하지 마세요." }, { "heading": "타입 소거가 만드는 제한", - "text": "제네릭의 많은 타입 정보는 컴파일 과정에서 소거됩니다. new T(), instanceof List처럼 런타임의 구체 타입을 요구하는 사용에는 제한이 있습니다. raw type과 unchecked cast를 섞으면 컴파일러가 보장하던 안전성을 약화할 수 있습니다. 경고를 없애기 전에 API 경계를 수정할 수 있는지 검토하세요. 제네릭 자체의 도입은 Java 5이며 본 수업 예제 대상은 Java 8입니다." + "text": "타입 소거는 컴파일할 때 제네릭의 많은 타입 정보를 상한 타입 등으로 바꾸는 처리입니다. 따라서 실행 중에 T가 정확히 무엇인지 안다고 가정해 new T()를 쓰거나 instanceof List으로 요소 타입을 검사할 수는 없습니다. 타입 인자를 생략한 raw type과 확인되지 않은 캐스팅을 섞으면 잘못된 요소가 들어와도 경고만 남고, 나중에 값을 꺼낼 때 실패할 수 있습니다. 경고를 숨기기 전에 API의 타입 관계를 고치세요. 제네릭은 Java 5부터이며 이 예제는 Java 8 대상으로 작성했습니다." } ], "example": { "title": "읽기 역할에 맞춘 상한 경계 · Main.java", "code": "class Main {\n static double sum(java.util.List values) {\n double total = 0;\n for (Number value : values) {\n total += value.doubleValue();\n }\n return total;\n }\n\n public static void main(String[] args) {\n System.out.println(sum(java.util.Arrays.asList(1, 2, 3)));\n System.out.println(sum(java.util.Arrays.asList(0.5, 1.5)));\n }\n}", "output": "6.0\n2.0", - "explanation": "sum은 항목을 읽어 Number의 메서드만 사용합니다. Integer 리스트와 Double 리스트를 받되, 어떤 숫자 타입을 리스트에 새로 추가할 권한은 요구하지 않습니다." + "explanation": "sum은 첫 호출에서 1, 2, 3을 Number로 읽고 각각 double 값으로 더해 6.0을 반환합니다. 다음 호출도 같은 코드를 사용해 0.5와 1.5를 더한 2.0을 반환합니다. 값을 추가하지 않고 읽기만 하므로 실제 목록이 Integer용인지 Double용인지 구분할 필요가 없습니다." }, "quiz": [ { @@ -437,7 +577,7 @@ export const lessons = [ "Java 8에는 제네릭이 없기 때문이다." ], "correctIndex": 2, - "explanation": "제네릭은 기본적으로 불공변입니다. 요소 타입의 하위 관계가 파라미터화된 타입의 같은 하위 관계를 자동으로 만들지 않습니다." + "explanation": "List에는 Double도 넣을 수 있습니다. List를 그 타입으로 대입할 수 있다면 정수 전용 목록에 소수가 들어가는 문제가 생깁니다. 컴파일러가 이 경로를 막는 것이 제네릭 불공변성의 이유입니다." }, { "id": "generics-q2", @@ -449,18 +589,18 @@ export const lessons = [ "target을 List으로 간주" ], "correctIndex": 0, - "explanation": "실제 요소 타입은 Integer 또는 그 상위 타입입니다. Integer는 추가할 수 있지만 읽은 값은 더 넓은 타입의 값일 수 있으므로 일반적으로 Object로 취급해야 합니다." + "explanation": "실제 목록의 요소 타입은 Integer 또는 그 상위 타입이므로 Integer 값은 타입상 안전하게 추가할 수 있습니다. 하지만 Number나 Object 목록에는 Integer가 아닌 값도 있을 수 있어, 읽은 값을 곧바로 Integer라고 할 수는 없습니다. add의 실제 지원 여부는 목록 구현의 계약을 따릅니다." } ], "practice": { "title": "공통 복사 API의 타입 계약", - "scenario": "Integer 목록을 Number 저장소로, String 목록을 Object 저장소로 복사하는 코드가 반복됩니다.", + "scenario": "Integer 목록을 Number 목록 뒤에 붙이는 코드와 String 목록을 Object 목록 뒤에 붙이는 코드가 반복됩니다. 타입 검사를 없애지 않고 공통 메서드로 만들려 합니다.", "tasks": [ "source와 target의 읽기·쓰기 역할을 표시하세요.", "raw type 없이 공통 copy 메서드의 타입 매개변수를 정하세요.", "target의 기존 항목과 source가 보존되는지 검사하세요." ], - "takeaway": "유연한 API와 타입 안전성은 양립할 수 있습니다. 어느 방향으로 값을 읽고 쓰는지부터 결정하세요." + "takeaway": "복사 메서드에서는 source가 값을 제공하고 target이 값을 받습니다. 이 방향을 정한 뒤 타입 매개변수와 경계를 쓰면, 유연하게 받으면서 잘못된 요소 추가를 컴파일 단계에서 막을 수 있습니다." }, "sources": [ { @@ -485,12 +625,46 @@ export const lessons = [ "jobTags": [ "Java" ], - "exerciseId": "generics-lab" + "exerciseId": "generics-lab", + "learningGuide": { + "scenario": "정수 1·2·3과 소수 0.5·1.5를 각각 더하고 싶습니다. 숫자 종류마다 sum을 다시 만들 필요는 없어 보입니다.", + "question": "sum이 List 대신 List를 받는 이유는 무엇일까요?", + "terms": [ + { + "term": "불공변성", + "meaning": "Integer가 Number의 하위 타입이어도 List를 List로 바로 대입할 수 없는 규칙." + }, + { + "term": "상한 경계 extends", + "meaning": "정확한 요소 타입은 몰라도 Number 또는 그 하위 타입임을 보장해 Number로 읽게 하는 조건." + }, + { + "term": "타입 소거", + "meaning": "컴파일 과정에서 많은 제네릭 타입 정보가 상한 타입 등으로 바뀌어 실행 시 구별에 한계가 생기는 것." + } + ], + "walkthrough": [ + "sum의 values는 정확히 어떤 숫자 타입의 목록인지는 몰라도 각 요소를 Number로 읽을 수 있습니다.", + "total은 0에서 시작합니다. for문의 value가 받은 각 숫자에 doubleValue를 호출합니다.", + "정수 목록에서는 1.0, 2.0, 3.0을 누적해 6.0을 반환합니다.", + "소수 목록도 같은 코드를 지나 0.5와 1.5의 합인 2.0을 반환합니다. values에 새 요소를 추가하는 줄은 없습니다." + ], + "prediction": { + "question": "같은 sum에 두 예제 목록을 전달하면 어느 호출이 컴파일되고 어떤 결과를 낼까요?", + "answer": "둘 다 컴파일되며 6.0과 2.0을 반환합니다.", + "explanation": "Integer와 Double 모두 Number로 읽을 수 있고 doubleValue를 제공합니다. sum은 읽기에 필요한 공통 기능만 요구합니다." + }, + "takeaway": "공통 API를 만들 때 모든 목록을 같은 구체 타입으로 바꾸려 하지 말고, 메서드가 값을 읽는지 쓰는지부터 확인하세요.", + "code": { + "file": "examples/java8/generics/Main.java", + "source": " static double sum(java.util.List values) {\n double total = 0;\n for (Number value : values) {\n total += value.doubleValue();\n }\n return total;\n }" + } + } }, { "id": "exceptions", "title": "예외: 실패 책임과 자원 소유권", - "description": "예외 종류를 외우는 데서 나아가 처리·전파·진단의 경계를 설계합니다.", + "description": "숫자 대신 many가 들어온 경우를 따라가며, 호출자에게 실패를 알리고 원래 예외와 자원 종료 정보를 남기는 방법을 익힙니다.", "track": "both", "level": "기본", "minVersion": 8, @@ -499,29 +673,29 @@ export const lessons = [ "duration": 40, "category": "실패와 검증", "goals": [ - "실패를 처리할 계층과 전파할 정보를 정한다.", - "예외 변환 시 원인과 맥락을 보존한다.", - "자원을 연 코드가 종료 책임을 지도록 설계한다." + "readQuantity의 입력 오류를 성공값 0으로 숨기면 생기는 문제를 설명한다.", + "새 예외의 cause에서 원래 파싱 오류를 찾는다.", + "return이나 예외로 빠져나가도 reader를 닫는 경로를 설명한다." ], "theory": [ { "heading": "checked 여부와 복구 가능성은 같은 말이 아니다", - "text": "checked 예외는 처리하거나 throws로 선언해야 하는 컴파일 규칙을 가집니다. 이것만으로 현 계층이 복구할 수 있는지는 결정되지 않습니다. 호출자가 다시 입력할지, 재시도할지, 실패로 종료할지 판단하는 데 필요한 정보를 보존하세요. catch(Exception)으로 모든 실패를 정상값으로 바꾸면 호출자가 결과를 오해할 수 있습니다." + "text": "readQuantity(\"many\")는 숫자를 읽지 못한 실패입니다. 이를 catch해서 0을 반환하면 호출자는 ‘입력값이 0’과 ‘읽기에 실패함’을 구별할 수 없습니다. checked 예외는 catch로 처리하거나 throws로 알리도록 컴파일러가 요구하는 예외입니다. 그렇다고 그 자리에서 복구할 수 있다는 뜻은 아닙니다. 예제의 IOException은 밖으로 전달하고 입력 형식 오류는 IllegalArgumentException으로 알립니다. 입력을 다시 받을지 작업을 중단할지는 판단할 정보가 있는 호출자가 결정해야 합니다." }, { "heading": "예외를 바꾸더라도 원인을 잃지 않는다", - "text": "낮은 계층의 예외를 업무 의미의 예외로 변환할 수 있지만 cause를 유지하면 원인 추적이 가능합니다. 민감한 원문 전체를 메시지에 넣는 대신 실패한 작업과 안전한 맥락을 전달합니다. 잘못된 입력과 시스템 장애를 같은 임의 기본값으로 숨기지 마세요. 로그 기록과 사용자 응답도 서로 다른 책임입니다." + "text": "parseInt가 던진 NumberFormatException을 호출자에게 의미 있는 invalid quantity 오류로 바꾸되 원인까지 지우지는 않습니다. 예제는 새 IllegalArgumentException의 두 번째 인자로 cause를 넘깁니다. 그러면 호출자는 ‘수량 입력에 실패했다’는 맥락과 ‘숫자로 해석하지 못했다’는 원인을 함께 확인할 수 있습니다. 로그에는 진단에 필요한 안전한 정보를 남기고 사용자 응답에는 필요한 안내를 줍니다. 민감한 입력 원문 전체를 예외 메시지에 붙일 필요는 없습니다." }, { "heading": "자원 해제와 suppressed exception", - "text": "AutoCloseable 자원은 try-with-resources로 수명을 표현할 수 있습니다. 여러 자원은 선언 역순으로 닫힙니다. 본문과 close가 모두 실패하면 본문 예외가 주 예외가 되고 닫기 실패는 suppressed 예외로 보존될 수 있습니다. 메서드에 전달받은 자원을 누가 닫는지는 소유권 계약으로 명시하세요." + "text": "readQuantity가 만든 reader는 try-with-resources의 괄호 안에 선언되어 있습니다. 본문이 값을 반환하거나 예외로 끝나도 닫기 처리가 이루어집니다. 여러 자원은 선언한 순서의 반대로 닫힙니다. 본문과 close가 모두 실패하면 본문 예외를 주로 전달하고 닫기 오류는 suppressed 예외로 붙여 진단할 수 있게 합니다. 이는 새로운 예외의 cause와 다른 정보입니다. 밖에서 전달받은 자원이라면 이 메서드가 닫아도 되는지 소유 책임을 먼저 정하세요." } ], "example": { "title": "읽기 자원의 수명 명시 · Main.java", "code": "class Main {\n static int readQuantity(String text) throws java.io.IOException {\n try (java.io.BufferedReader reader =\n new java.io.BufferedReader(new java.io.StringReader(text))) {\n String line = reader.readLine();\n if (line == null) {\n throw new IllegalArgumentException(\"quantity is required\");\n }\n try {\n return Integer.parseInt(line);\n } catch (NumberFormatException cause) {\n throw new IllegalArgumentException(\"invalid quantity\", cause);\n }\n }\n }\n\n public static void main(String[] args) throws java.io.IOException {\n System.out.println(readQuantity(\"12\"));\n try {\n readQuantity(\"many\");\n } catch (IllegalArgumentException failure) {\n System.out.println(failure.getCause().getClass().getSimpleName());\n }\n }\n}", "output": "12\nNumberFormatException", - "explanation": "메서드 내부에서 만든 reader를 try-with-resources가 닫습니다. 숫자 파싱 실패는 입력 계약의 예외로 변환하면서 원본 NumberFormatException을 cause에 보존합니다." + "explanation": "readQuantity(\"12\")는 12를 반환하고 reader를 닫습니다. \"many\"는 parseInt에서 NumberFormatException을 만들며, 이를 cause로 담은 IllegalArgumentException이 밖으로 나옵니다. main은 getCause에서 원래 예외 이름을 읽어 NumberFormatException을 출력합니다. 실패 경로에서도 reader 닫기는 try-with-resources가 담당합니다." }, "quiz": [ { @@ -534,7 +708,7 @@ export const lessons = [ "새 예외 생성자의 cause 인자로 원본 예외를 전달한다." ], "correctIndex": 3, - "explanation": "원인 예외를 연결하면 낮은 계층에서 어떤 실패가 발생했는지 확인할 수 있습니다. 정상값을 반환하거나 예외를 삼키면 호출자가 실패와 성공을 구분하기 어렵습니다." + "explanation": "새 IllegalArgumentException에 cause를 넘기면 바깥 예외는 업무 맥락을, 원인 예외는 실제 실패 이유를 전달합니다. 예제의 main이 getCause로 NumberFormatException을 확인할 수 있는 이유입니다." }, { "id": "exceptions-q2", @@ -546,18 +720,18 @@ export const lessons = [ "항상 프로그램이 컴파일되지 않는다." ], "correctIndex": 1, - "explanation": "try-with-resources는 주 예외와 자원 종료 중 발생한 예외를 함께 진단할 수 있도록 suppressed 예외를 사용합니다. 자원 종료가 실패할 수 있다는 사실도 설계해야 합니다." + "explanation": "본문에서 발생한 실패를 주 예외로 전달하고 close에서 난 실패는 suppressed 정보로 보존합니다. 하나의 실패가 다른 실패를 무조건 지워 버리는 것이 아닙니다. 호출자는 getSuppressed로 종료 중 문제도 조사할 수 있습니다." } ], "practice": { "title": "환경 설정 입력의 실패 계약", - "scenario": "설정 파일에서 읽은 포트 값이 잘못되어도 0을 반환해, 실제 원인을 늦게 발견하고 있습니다.", + "scenario": "설정 파일의 포트 값이 many여도 읽기 메서드가 0을 반환합니다. 서버를 시작하는 코드에서 뒤늦게 실패해 입력 오류였다는 사실을 찾기 어렵습니다.", "tasks": [ "입력 부재·형식 오류·범위 오류를 분류하세요.", "호출자가 실패를 확실하게 알도록 계약을 바꾸세요.", "파싱 실패의 cause가 남는지 검사하세요." ], - "takeaway": "복구하지 못한 실패를 성공처럼 포장하지 않습니다. 예외·자원·진단 정보의 책임을 함께 정합니다." + "takeaway": "오류를 처리했다는 것은 catch를 썼다는 뜻이 아닙니다. 복구하지 못했다면 실패를 명확히 전달하고, 원인과 자원 종료 책임도 함께 남겨야 합니다." }, "sources": [ { @@ -582,12 +756,51 @@ export const lessons = [ "jobTags": [ "Java" ], - "exerciseId": "exceptions-lab" + "exerciseId": "exceptions-lab", + "learningGuide": { + "scenario": "수량을 읽는 readQuantity는 \"12\"를 받으면 성공하지만 \"many\"를 받으면 실패합니다. 호출자에게 읽기 실패를 알리면서 원래 이유도 남기려 합니다.", + "question": "새 IllegalArgumentException을 던지는데 main은 어떻게 NumberFormatException을 확인할까요?", + "terms": [ + { + "term": "예외 전파", + "meaning": "현재 메서드가 처리하지 못한 실패를 호출한 쪽으로 전달하는 것." + }, + { + "term": "cause", + "meaning": "현재 예외가 생긴 원인 예외. 새 예외를 만들 때 연결해야 원래 이유를 따라갈 수 있습니다." + }, + { + "term": "자원 소유권", + "meaning": "누가 자원을 만들고 누가 닫을 책임을 지는지에 대한 약속." + }, + { + "term": "suppressed 예외", + "meaning": "주된 실패를 전달하면서 함께 보관하는 추가 실패. 자원을 닫는 중 난 오류 등이 여기에 남습니다." + } + ], + "walkthrough": [ + "readQuantity가 StringReader를 감싼 BufferedReader를 만들고 reader에서 첫 줄을 읽습니다.", + "line이 없으면 quantity is required를 던집니다. 값이 있으면 Integer.parseInt로 숫자 변환을 시도합니다.", + "\"many\"의 변환은 NumberFormatException을 던집니다. 안쪽 catch가 이를 cause라는 변수로 받습니다.", + "new IllegalArgumentException(\"invalid quantity\", cause)는 읽기 실패라는 맥락과 원래 예외를 연결해 밖으로 던집니다.", + "try-with-resources가 reader를 닫습니다. 전체 예제의 main은 failure.getCause()를 통해 원래 예외 종류를 출력합니다." + ], + "prediction": { + "question": "두 번째 입력 many를 처리한 뒤 main이 출력하는 원인 예외 이름은 무엇일까요?", + "answer": "NumberFormatException입니다.", + "explanation": "바깥으로 전달한 예외는 IllegalArgumentException이지만 두 번째 생성자 인자에 원인 예외를 보존했기 때문입니다." + }, + "takeaway": "실패를 업무용 예외로 바꿀 때는 호출자에게 필요한 맥락을 더하고, 진단에 필요한 원인은 유지하세요.", + "code": { + "file": "examples/java8/exceptions/Main.java", + "source": " static int readQuantity(String text) throws java.io.IOException {\n try (java.io.BufferedReader reader =\n new java.io.BufferedReader(new java.io.StringReader(text))) {\n String line = reader.readLine();\n if (line == null) {\n throw new IllegalArgumentException(\"quantity is required\");\n }\n try {\n return Integer.parseInt(line);\n } catch (NumberFormatException cause) {\n throw new IllegalArgumentException(\"invalid quantity\", cause);\n }\n }\n }" + } + } }, { "id": "streams", "title": "Stream: 지연 실행과 부수 효과 없는 집계", - "description": "짧은 코드보다 실행 시점·순서·공유 상태의 계약을 먼저 봅니다.", + "description": "java·sql·jvm·java에서 [JAVA, JVM]을 만드는 과정을 읽으며, 처리를 연결하는 시점과 실제 결과를 구하는 시점을 구별합니다.", "track": "both", "level": "기본", "minVersion": 8, @@ -596,29 +809,29 @@ export const lessons = [ "duration": 40, "category": "데이터 처리", "goals": [ - "중간 연산과 최종 연산을 구분한다.", - "소스·파이프라인·결과의 역할을 설명한다.", - "외부 가변 상태 없이 조건 집계를 구현한다." + "filter·distinct·map이 각각 어떤 입력을 남기거나 바꾸는지 설명한다.", + "collect가 결과 계산을 시작하고 selected에 결과를 담는 역할을 설명한다.", + "원본 names를 변경하는 일과 새 결과를 수집하는 일을 구분한다." ], "theory": [ { "heading": "Stream은 데이터를 저장하는 컬렉션이 아니다", - "text": "Stream은 소스에서 결과를 만드는 연산 흐름입니다. filter와 map 같은 중간 연산은 지연되며 최종 연산이 파이프라인 평가를 시작합니다. 이미 소비한 Stream을 다시 순회하려고 하지 말고 소스에서 새로 만드세요. 필터·변환·집계 각각이 무엇을 약속하는지 설명할 수 있어야 합니다." + "text": "예제의 names는 데이터를 보관한 List이고, names.stream() 뒤의 연산은 그 데이터로 무엇을 계산할지 나타냅니다. filter는 j로 시작하는 값을 고르고, distinct는 중복을 제거하며, map은 대문자로 바꿉니다. 이런 중간 연산은 연결만으로 결과를 계산하지 않습니다. collect 같은 최종 연산이 실행될 때 계산이 시작되는 성질을 지연 실행이라고 합니다. 한번 소비한 Stream을 다시 사용하려 하지 말고 names에서 새 Stream을 만드세요." }, { "heading": "외부 상태 변경은 집계와 다른 책임이다", - "text": "forEach에서 외부 ArrayList나 일반 카운터를 변경하면 상태 소유권이 흩어지고 병렬 처리에서 문제가 생길 수 있습니다. 결과 수집은 collect, 숫자 집계는 원시형 스트림의 합계처럼 의도가 드러나는 연산을 검토하세요. 콜백은 가능한 한 소스를 바꾸지 않고 입력에 대한 결과를 계산하도록 구성합니다." + "text": "예제의 selected는 collect가 수집한 결과입니다. 외부의 빈 ArrayList를 만들고 forEach 안에서 add하는 방식은 콜백이 바깥 상태까지 바꾸게 합니다. 이런 외부 상태 변경을 부수 효과라고 부르며, 특히 병렬 실행에서는 누가 언제 수정하는지 맞추기 어려워집니다. 결과를 모으려면 collect, 숫자 합계라면 해당 합계 연산을 사용해 목적을 드러내세요. 예제의 콜백은 names의 내용을 수정하지 않고 각 입력으로부터 결과를 계산합니다." }, { "heading": "병렬화와 API 버전은 별도 판단이다", - "text": "parallelStream은 자동 성능 개선 스위치가 아닙니다. 작업량, 순서 요구, 공유 상태, 결합 가능한 연산과 실행 환경을 측정해야 합니다. Java 8에서는 collect(Collectors.toList())를 사용합니다. Stream.toList()는 Java 16에서 추가되었고 반환 리스트의 변경 계약도 다르므로 단순 문법 치환으로 취급하지 않습니다." + "text": "같은 코드를 parallelStream으로 바꾼다고 항상 빨라지지는 않습니다. 작업을 나눌 비용, 순서 유지 비용, 공유 상태와 실행 환경을 함께 봐야 합니다. Java 8의 이 예제는 collect(Collectors.toList())를 사용합니다. Java 16에서 추가된 Stream.toList()는 Java 8에 없고 반환 목록도 변경 불가입니다. Collectors.toList() 역시 구체 목록 타입이나 변경 가능성을 보장하지 않으므로, 단순히 이름이 비슷하다고 같은 계약으로 취급하지 마세요." } ], "example": { "title": "필터·변환·수집 파이프라인 · Main.java", "code": "class Main {\n public static void main(String[] args) {\n java.util.List names =\n java.util.Arrays.asList(\"java\", \"sql\", \"jvm\", \"java\");\n java.util.List selected = names.stream()\n .filter(name -> name.startsWith(\"j\"))\n .distinct()\n .map(name -> name.toUpperCase(java.util.Locale.ROOT))\n .collect(java.util.stream.Collectors.toList());\n System.out.println(selected);\n System.out.println(names.size());\n }\n}", "output": "[JAVA, JVM]\n4", - "explanation": "소스의 순서를 따라 j로 시작하는 항목을 고르고 중복 제거 후 대문자로 변환합니다. collect가 최종 연산입니다. 소스 names는 수정하지 않았으므로 크기는 4입니다." + "explanation": "filter 뒤의 값은 java, jvm, java이고 distinct가 뒤의 java 중복을 없앱니다. map이 남은 두 값을 JAVA, JVM으로 바꾸고 collect가 [JAVA, JVM]을 selected에 담습니다. 이 설명은 값의 흐름이며 각 단계마다 임시 List를 만든다는 뜻은 아닙니다. names를 수정하지 않았으므로 두 번째 출력은 원래 크기인 4입니다." }, "quiz": [ { @@ -631,7 +844,7 @@ export const lessons = [ "System.gc를 호출한다." ], "correctIndex": 1, - "explanation": "Stream의 중간 연산은 지연됩니다. collect, sum 같은 최종 연산이 결과 계산을 시작합니다. 필요한 모든 요소가 항상 처리된다는 뜻은 아니며 단락 연산도 있습니다." + "explanation": "filter는 ‘어떤 항목을 통과시킬지’를 연결한 중간 연산입니다. collect나 sum 같은 최종 연산이 있어야 결과 계산을 시작합니다. findFirst처럼 필요한 결과를 일찍 얻으면 모든 요소를 끝까지 처리하지 않는 최종 연산도 있습니다." }, { "id": "streams-q2", @@ -643,18 +856,18 @@ export const lessons = [ "동일 Stream을 무제한 재사용할 수 있다." ], "correctIndex": 2, - "explanation": "Stream.toList는 Java 16 API입니다. Java 8 대상에서는 지원 API를 사용하고, 반환 컬렉션의 변경 가능성도 별도로 확인해야 합니다." + "explanation": "Stream.toList()는 Java 16부터 있어 Java 8 대상 예제에서는 사용할 수 없습니다. 지원 버전뿐 아니라 반환 목록의 변경 허용 여부도 확인해야 합니다. 이름이 유사해도 모든 API를 그대로 바꿔 쓸 수는 없습니다." } ], "practice": { "title": "집계 배치의 공유 누산기 없애기", - "scenario": "여러 조건을 거친 정수 값을 외부 변수에 더하는 배치 코드를 읽기 쉽고 검증 가능하게 바꾸려 합니다.", + "scenario": "배치 코드가 조건을 통과한 숫자를 바깥 변수에 계속 더합니다. 병렬화하거나 조건을 바꿀 때 누락된 합계가 생겨, 필터 조건과 집계 책임을 분명히 하려 합니다.", "tasks": [ "필터 조건과 합계의 입력 범위를 분리해 적으세요.", "외부 상태 없이 조건에 맞는 값을 집계하세요.", "빈 입력·null 요소·중복 값을 처리하는 계약을 확인하세요." ], - "takeaway": "데이터 흐름을 명확하게 만든 뒤 성능을 측정합니다. 짧은 표현이나 병렬화 자체가 설계의 목적은 아닙니다." + "takeaway": "먼저 어떤 값을 남기고 어떤 결과를 계산하는지 드러내세요. 외부 변수를 바꾸지 않는 집계로 만든 뒤, 필요한 경우 성능을 측정해 병렬화를 판단합니다." }, "sources": [ { @@ -679,12 +892,50 @@ export const lessons = [ "jobTags": [ "Java" ], - "exerciseId": "streams-lab" + "exerciseId": "streams-lab", + "learningGuide": { + "scenario": "태그 java, sql, jvm, java에서 j로 시작하는 값만 남기고, 중복 없이 대문자로 표시해야 합니다. 원본 목록은 다른 곳에서도 사용합니다.", + "question": "처리 규칙은 어느 줄에서 연결되고, 최종 결과는 어느 줄에서 만들어질까요?", + "terms": [ + { + "term": "파이프라인", + "meaning": "원본에서 어떤 값을 고르고 바꾸고 모을지 연결한 연산 흐름." + }, + { + "term": "중간 연산·지연 실행", + "meaning": "filter나 map처럼 다음 연산을 연결하며, 최종 연산 전에는 소스의 결과 계산을 시작하지 않는 방식." + }, + { + "term": "최종 연산", + "meaning": "collect처럼 연결된 흐름을 실행해 결과나 효과를 얻는 연산." + }, + { + "term": "부수 효과", + "meaning": "계산 결과를 돌려주는 것 외에 원본이나 외부 변수의 상태도 바꾸는 일." + } + ], + "walkthrough": [ + "names.stream()에서 시작해 filter가 j로 시작하는 java, jvm, java를 통과시키도록 연결합니다.", + "distinct는 같은 문자열 java가 반복돼도 하나만 남기도록 하고, map은 남은 문자열을 대문자로 바꾸도록 연결합니다.", + "collect(Collectors.toList())가 최종 연산입니다. 앞서 연결한 규칙으로 값을 처리해 selected를 만듭니다.", + "selected는 [JAVA, JVM]입니다. 콜백에 names를 수정하는 코드가 없으므로 원본 크기는 4를 유지합니다." + ], + "prediction": { + "question": "selected를 만든 뒤 names.size()도 2로 줄어들까요?", + "answer": "아닙니다. selected는 [JAVA, JVM]이고 names.size()는 4입니다.", + "explanation": "이 예제는 원본의 요소를 삭제하지 않고 조건에 맞는 값으로 별도 결과를 수집합니다. 모든 Stream 사용이 자동으로 원본 변경을 막는다는 뜻은 아닙니다." + }, + "takeaway": "원본·처리 규칙·결과 목록을 따로 생각하면, 읽기 위한 변환인지 외부 상태를 바꾸는 작업인지 구별하기 쉬워집니다.", + "code": { + "file": "examples/java8/streams/Main.java", + "source": " java.util.List names =\n java.util.Arrays.asList(\"java\", \"sql\", \"jvm\", \"java\");\n java.util.List selected = names.stream()\n .filter(name -> name.startsWith(\"j\"))\n .distinct()\n .map(name -> name.toUpperCase(java.util.Locale.ROOT))\n .collect(java.util.stream.Collectors.toList());\n System.out.println(selected);\n System.out.println(names.size());" + } + } }, { "id": "concurrency", "title": "동시성: JMM, 원자성과 작업 수명", - "description": "가시성·원자성·완료 대기를 구분하고 스레드 안전성의 근거를 제시합니다.", + "description": "두 작업이 각각 1000번 증가시킨 카운터가 왜 2000이어야 하는지, 증가 자체의 안전성과 완료 대기를 나눠 설명합니다.", "track": "advanced", "level": "심화", "minVersion": 8, @@ -693,29 +944,29 @@ export const lessons = [ "duration": 65, "category": "동시성과 실행 모델", "goals": [ - "원자성·가시성·happens-before를 구분한다.", - "공유 상태 변경과 작업 완료의 보장을 설명한다.", - "실패·취소·실행기 종료의 책임을 정한다." + "count++에서 증가가 사라질 수 있는 이유와 AtomicInteger의 역할을 설명한다.", + "first.get·second.get 없이 결과를 읽을 때 생기는 문제를 구별한다.", + "작업 실패·인터럽트·실행기 종료 요청을 누가 처리하는지 정한다." ], "theory": [ { "heading": "보이는 것과 한 번에 처리되는 것은 다르다", - "text": "JMM은 여러 스레드 사이에서 어떤 읽기 결과가 허용되는지 설명합니다. volatile은 특정 가시성·순서 보장에 쓰지만 count++처럼 읽기·계산·쓰기의 복합 동작을 원자적으로 만들지는 않습니다. 공유 상태가 필요 없는 설계, 불변 데이터, 락 또는 원자 연산 중 요구에 맞는 방법을 선택하세요." + "text": "두 작업이 동시에 0을 읽고 각자 1을 기록하면 증가 요청은 두 번인데 결과는 1일 수 있습니다. count++가 읽기·계산·쓰기를 묶은 동작이기 때문입니다. 다른 스레드의 변경을 어떤 규칙으로 볼 수 있는지는 가시성, 한 동작이 중간에 쪼개져 섞이지 않게 보장되는지는 원자성의 문제입니다. volatile로 가시성과 순서를 다뤄도 ++ 전체가 원자적이 되지는 않습니다. 예제는 total.incrementAndGet으로 각 증가를 원자적으로 수행합니다. JMM은 이런 스레드 사이 읽기 결과와 순서 보장을 규정합니다." }, { "heading": "happens-before와 안전한 공개", - "text": "프로그램 순서만으로 다른 스레드가 언제 값을 볼지 결론내리지 않습니다. 모니터 잠금, volatile, 스레드 시작·종료, 동시성 API의 메모리 일관성 계약을 확인하세요. ExecutorService의 작업 제출과 Future.get도 명시된 관계를 제공합니다. 잠시 sleep한 뒤 값이 맞았다는 관찰은 동기화의 근거가 아닙니다." + "text": "증가를 안전하게 해도 아직 작업 중일 때 total을 읽으면 값은 2000보다 작을 수 있습니다. 예제는 first.get과 second.get이 모두 정상 반환한 뒤 읽습니다. 이 호출은 해당 작업의 완료를 기다리며 작업의 결과를 읽는 데 필요한 메모리 일관성 관계도 제공합니다. 이런 ‘앞선 작업의 영향이 뒤의 관찰에 반영되도록 하는 관계’를 happens-before라고 부릅니다. 시계상 먼저 실행됐다는 뜻으로만 이해하지 마세요. 락·volatile·스레드 시작과 종료·동시성 API의 명시된 보장을 확인해야 하며, sleep으로 잠시 기다리는 것은 완료 확인을 대신하지 않습니다." }, { "heading": "작업과 자원의 생명주기를 설계한다", - "text": "ExecutorService는 작업 제출과 스레드 관리를 분리합니다. 결과를 기다릴지, 실패를 어떻게 알릴지, 언제 종료할지 정해야 합니다. InterruptedException을 처리하고 상위로 인터럽트 의사를 전달할 때는 상태 복원도 검토합니다. AtomicInteger 하나가 안전해도 여러 필드나 DB 행을 함께 변경하는 업무 규칙까지 자동으로 원자적이 되지는 않습니다." + "text": "ExecutorService는 작업을 제출하는 코드와 실행할 스레드를 관리하는 코드를 나눕니다. 두 submit은 작업을 맡기고 Future를 돌려주며, get은 대기 중 인터럽트나 작업 실패를 알릴 수 있습니다. 예제는 예외를 밖으로 전달하면서 finally에서 shutdownNow로 종료를 요청합니다. 이 요청이 모든 실행 중 작업을 강제로 끝냈다는 보장은 아닙니다. 호출자가 InterruptedException을 받아 상위로 의사를 전달할 때는 전파나 인터럽트 상태 복원 정책을 정하세요. 또 AtomicInteger 하나가 안전해도 여러 필드나 DB 행을 함께 바꾸는 업무 전체가 자동으로 원자적이 되지는 않습니다." } ], "example": { "title": "완료 후 읽는 원자 카운터 · Main.java", "code": "class Main {\n public static void main(String[] args) throws Exception {\n java.util.concurrent.ExecutorService executor =\n java.util.concurrent.Executors.newFixedThreadPool(2);\n java.util.concurrent.atomic.AtomicInteger total =\n new java.util.concurrent.atomic.AtomicInteger();\n try {\n java.util.concurrent.Future first = executor.submit(() -> {\n for (int index = 0; index < 1000; index++) {\n total.incrementAndGet();\n }\n });\n java.util.concurrent.Future second = executor.submit(() -> {\n for (int index = 0; index < 1000; index++) {\n total.incrementAndGet();\n }\n });\n first.get();\n second.get();\n System.out.println(total.get());\n } finally {\n executor.shutdownNow();\n }\n }\n}", "output": "2000", - "explanation": "증가에는 원자 연산을 사용하고 두 Future가 완료된 뒤 최종 값을 읽습니다. finally에서 실행기를 종료합니다. 이 예제는 작은 메모리 내 카운터이며 DB 트랜잭션이나 분산 환경의 원자성을 보여 주지는 않습니다." + "explanation": "두 작업은 각자 1000번 total.incrementAndGet을 호출합니다. 각 증가가 원자적이므로 정상 완료하면 합계는 2000입니다. first.get과 second.get으로 둘 다 끝났음을 확인한 뒤 total.get을 출력합니다. finally의 shutdownNow는 실행기 종료를 요청합니다. 이 예제는 메모리 안의 카운터이며 여러 DB 변경이나 분산 처리의 보장을 보여 주는 것은 아닙니다." }, "quiz": [ { @@ -728,7 +979,7 @@ export const lessons = [ "항상 컴파일 오류가 발생한다." ], "correctIndex": 2, - "explanation": "count++는 읽기·계산·쓰기를 포함합니다. volatile의 보장을 복합 연산 전체의 원자성으로 확대하면 안 됩니다. 요구에 맞는 락이나 원자 연산 등이 필요합니다." + "explanation": "두 스레드가 모두 0을 읽고 각자 1을 쓰는 경우를 생각해 보세요. volatile이어도 읽기·계산·쓰기 전체가 한 번에 처리되지는 않아 증가가 사라질 수 있습니다. 예제의 incrementAndGet처럼 요구에 맞는 원자 연산이나 락이 필요합니다." }, { "id": "concurrency-q2", @@ -740,18 +991,18 @@ export const lessons = [ "sleep을 두 번 호출하면 어떤 실행 환경에서도 안전하다." ], "correctIndex": 1, - "explanation": "작업량과 스케줄링은 달라질 수 있습니다. Future.get, join 등 명시적 완료·동기화 계약을 사용해야 합니다. 반복 성공은 잘못된 동기화의 반례를 없애지 않습니다." + "explanation": "100ms가 지났다는 사실은 특정 작업이 끝났다는 신호가 아닙니다. 환경과 작업량에 따라 아직 실행 중일 수 있고, sleep 자체가 작업 결과의 전달 규칙을 만들지도 않습니다. Future.get이나 join처럼 완료·동기화 계약이 있는 방법을 사용해야 합니다." } ], "practice": { "title": "병렬 작업의 완료·실패·종료 계약", - "scenario": "배치 카운트가 간헐적으로 작게 나오고 실행기 스레드가 작업 후에도 남습니다.", + "scenario": "병렬 배치가 2000개를 처리했어야 하는데 카운트가 간헐적으로 작습니다. 값 증가가 충돌했는지, 완료 전에 읽었는지부터 나누어 확인하고 작업 후 남는 스레드도 정리하려 합니다.", "tasks": [ "공유 카운터의 원자성 문제와 결과를 너무 일찍 읽는 문제를 분리하세요.", "작업을 제출하고 모두 완료된 후 반환하며 실행기를 닫으세요.", "인터럽트·작업 실패가 발생했을 때 호출자가 무엇을 관찰할지 설명하세요." ], - "takeaway": "동시성 품질은 반복 테스트 횟수만으로 결정하지 않습니다. 어떤 계약으로 값과 작업의 순서를 보장했는지 설명해야 합니다." + "takeaway": "‘여러 번 실행해 맞았다’보다 어느 연산이 증가를 보호하고 어느 호출이 완료를 확인하는지 설명하세요. 실패와 종료까지 포함해야 호출자가 결과를 믿을 수 있습니다." }, "sources": [ { @@ -776,12 +1027,50 @@ export const lessons = [ "jobTags": [ "Java" ], - "exerciseId": "concurrency-lab" + "exerciseId": "concurrency-lab", + "learningGuide": { + "scenario": "두 작업이 total을 각각 1000번 증가시킵니다. 합계가 2000이 되려면 증가를 잃지 않아야 하고, 두 작업이 끝나기 전에 읽어서도 안 됩니다.", + "question": "incrementAndGet과 Future.get은 서로 다른 어떤 문제를 해결할까요?", + "terms": [ + { + "term": "원자성", + "meaning": "하나로 취급해야 할 동작의 중간에 다른 작업이 끼어 결과를 깨뜨리지 않도록 하는 보장." + }, + { + "term": "가시성", + "meaning": "한 스레드의 변경을 다른 스레드가 읽을 때 어떤 결과를 볼 수 있는지에 관한 보장." + }, + { + "term": "Future", + "meaning": "제출한 작업의 완료와 결과·실패를 확인하는 객체. get은 필요하면 완료를 기다립니다." + }, + { + "term": "happens-before", + "meaning": "앞선 동작의 영향과 뒤의 관찰 사이에 Java가 보장하는 순서·메모리 일관성 관계. 실제 시각만 비교하는 말은 아닙니다." + } + ], + "walkthrough": [ + "first와 second는 각각 1000번 반복하는 작업을 executor에 제출해 얻은 Future입니다.", + "각 작업의 total.incrementAndGet은 한 번의 증가를 원자적으로 처리합니다. 두 작업이 섞여도 증가 자체를 잃지 않습니다.", + "first.get은 첫 작업을 기다립니다. 이어지는 second.get까지 정상 반환해야 두 작업의 완료를 확인한 것입니다.", + "그 뒤 total.get으로 읽으므로 정상 완료한 두 루프의 합인 2000을 출력합니다. 전체 예제의 finally는 실행기 종료를 요청합니다." + ], + "prediction": { + "question": "incrementAndGet을 사용했으니 두 get 호출을 빼도 출력이 항상 2000일까요?", + "answer": "보장할 수 없습니다.", + "explanation": "증가가 안전하다는 것과 증가 작업이 이미 끝났다는 것은 다릅니다. get을 빼면 출력 시점에도 작업이 진행 중일 수 있습니다." + }, + "takeaway": "동시성 버그를 볼 때 ‘공유 값을 어떻게 바꾸는가’와 ‘언제 결과를 읽어도 되는가’를 별도로 추적하세요.", + "code": { + "file": "examples/java8/concurrency/Main.java", + "source": " java.util.concurrent.Future first = executor.submit(() -> {\n for (int index = 0; index < 1000; index++) {\n total.incrementAndGet();\n }\n });\n java.util.concurrent.Future second = executor.submit(() -> {\n for (int index = 0; index < 1000; index++) {\n total.incrementAndGet();\n }\n });\n first.get();\n second.get();\n System.out.println(total.get());" + } + } }, { "id": "modern-apis", "title": "Java 11: 입력 처리와 표준 HTTP API", - "description": "새 API의 Since와 호출 계약을 확인하며 Java 8 코드에서의 전환을 판단합니다.", + "description": "여러 줄 입력에서 빈 줄을 빼고, HTTP 연결 제한을 설정합니다. 출력 2와 3이 각각 어떤 사실을 보여 주는지 확인합니다.", "track": "advanced", "level": "심화", "minVersion": 11, @@ -790,29 +1079,29 @@ export const lessons = [ "duration": 40, "category": "버전 전환", "goals": [ - "strip·isBlank·lines의 입력 처리 계약을 설명한다.", - "API 최초 도입 버전과 예제 실행 버전을 구분한다.", - "HTTP 클라이언트의 시간 제한과 실패 경계를 설계한다." + "lines·strip·isBlank가 입력의 어느 부분을 처리하는지 설명한다.", + "지역 변수 var의 도입 버전과 이 예제의 Java 11 API 요구를 구별한다.", + "HTTP 클라이언트 설정과 실제 요청 성공을 구분한다." ], "theory": [ { "heading": "문자열 API가 바꾸는 입력 정책", - "text": "Java 11의 strip, isBlank, lines는 여러 줄 입력 처리의 의도를 표현하는 데 유용합니다. strip과 isBlank는 Character.isWhitespace의 공백 기준을 사용하며, trim의 기준과 같지 않습니다. 모든 시각적 공백 문자를 전부 제거한다는 뜻도 아닙니다. 문자 인코딩과 줄 구분자까지 포함해 어떤 입력을 허용할지 정하세요." + "text": "예제의 text에는 build, 빈 줄, test가 들어 있고 두 단어의 앞뒤에 공백이 있습니다. lines는 줄 단위로 읽게 하고, strip은 각 줄 앞뒤의 공백을 제거하며, isBlank는 비었거나 공백뿐인 줄을 구별합니다. 이 조합은 ‘의미 있는 줄 두 개’를 세려는 의도를 드러냅니다. strip과 isBlank의 공백 기준은 Character.isWhitespace이며 trim의 기준과 다릅니다. 눈에 공백처럼 보이는 모든 문자를 제거한다고 가정하지 말고 실제 입력 문자·인코딩·줄 구분자를 확인하세요." }, { "heading": "var와 API 도입 버전을 분리한다", - "text": "지역 변수 타입 추론 var는 Java 10에 도입되었고 람다 매개변수의 var 문법은 Java 11에 추가되었습니다. var는 동적 타입이 아니며 컴파일 시점에 타입이 정해집니다. 이 수업의 String API와 표준 java.net.http.HttpClient는 Java 11이 필요합니다. 본문에 등장하는 기능의 최초 버전과 실제 예제 전체의 최소 대상 버전을 구분합니다." + "text": "API를 읽을 때는 ‘코드를 실행할 JDK 버전’과 ‘기능이 처음 생긴 버전’을 나누어 확인합니다. 지역 변수 var는 Java 10에서 추가된 컴파일 시점 타입 추론입니다. 값을 대입한 뒤 아무 타입으로나 바뀌는 동적 타입이 아닙니다. Java 11에서 추가된 것은 람다 매개변수의 var 문법입니다. 이 예제의 strip·isBlank·lines와 표준 java.net.http.HttpClient는 Java 11을 요구하므로 예제 전체 대상도 11입니다." }, { "heading": "HTTP는 성공 응답 한 번으로 끝나지 않는다", - "text": "표준 HttpClient는 동기 send와 비동기 sendAsync를 제공합니다. 연결 시간 제한과 요청 시간 제한은 적용 지점이 다르며, 통신 성공과 업무상 성공 상태 코드도 분리해야 합니다. 자동 재시도는 요청의 멱등성과 중복 처리 위험을 검토한 뒤 적용합니다. 아래 예제는 클라이언트 설정만 만들며 외부 네트워크 호출을 하지 않습니다." + "text": "예제의 client는 연결 시간 제한을 3초로 설정한 객체입니다. send나 sendAsync를 호출하지 않았으므로 서버 응답을 받은 것은 아닙니다. 실제 호출에서는 연결을 기다리는 시간과 요청 응답을 기다리는 시간의 제한을 각각 설계해야 합니다. 응답이 도착해도 상태 코드가 업무 성공을 뜻하는지 따로 봅니다. 같은 요청을 다시 보내도 중복 차감 같은 추가 효과가 없는 성질을 멱등성이라고 합니다. 재시도 전에는 이 성질과 중복 처리 위험을 확인하세요." } ], "example": { "title": "Java 11 API와 명시적 설정 · Main.java", "code": "class Main {\n public static void main(String[] args) {\n String text = \" build \\n\\n test \";\n long meaningfulLines = text.lines()\n .map(String::strip)\n .filter(line -> !line.isBlank())\n .count();\n java.net.http.HttpClient client =\n java.net.http.HttpClient.newBuilder()\n .connectTimeout(java.time.Duration.ofSeconds(3))\n .build();\n System.out.println(meaningfulLines);\n System.out.println(client.connectTimeout().get().getSeconds());\n }\n}", "output": "2\n3", - "explanation": "빈 줄을 제외하면 의미 있는 줄은 2개입니다. HTTP 클라이언트에 설정한 연결 시간 제한은 3초입니다. 클라이언트를 만들었다는 사실은 어떤 서버 요청이 성공했다는 증거가 아닙니다." + "explanation": "lines는 세 줄을 읽고 strip과 filter를 거치면 build와 test만 남아 count가 2를 반환합니다. 뒤의 3은 client에 저장된 연결 시간 제한을 초 단위로 읽은 값입니다. 3초 동안 기다렸다거나 실제 요청이 성공했다는 출력이 아닙니다. 이 예제는 외부 네트워크를 호출하지 않습니다." }, "quiz": [ { @@ -825,7 +1114,7 @@ export const lessons = [ "Java 17의 제네릭 제거 기능" ], "correctIndex": 2, - "explanation": "지역 변수 var는 Java 10입니다. Java 11은 람다 매개변수에서의 var 문법을 추가했습니다. var를 사용해도 타입은 컴파일 시점에 정해집니다." + "explanation": "지역 변수 var는 초기값 등을 보고 컴파일러가 타입을 정하는 Java 10 기능입니다. Java 11의 람다 매개변수 var와 최초 버전을 혼동하지 마세요. 선언이 짧아져도 컴파일 시 정해지는 타입이라는 성격은 같습니다." }, { "id": "modern-apis-q2", @@ -837,18 +1126,18 @@ export const lessons = [ "strip을 사용하면 네트워크 오류가 사라진다는 점" ], "correctIndex": 0, - "explanation": "strip은 Java 11 API이며 trim과 공백 판정 기준이 다릅니다. 입력 정규화가 업무 의미를 바꾸지 않는지 실제 요구와 대조해야 합니다." + "explanation": "두 API의 공백 판정 기준이 달라 실제 입력에서 결과가 달라질 수 있습니다. strip은 Java 11부터이므로 배포 대상도 확인해야 합니다. 원본 String을 제자리에서 바꾸는 기능은 아닙니다." } ], "practice": { "title": "텍스트 설정을 줄 단위 값으로 바꾸기", - "scenario": "운영자가 여러 줄로 붙여 넣은 항목에서 빈 줄과 앞뒤 공백을 제거해야 합니다.", + "scenario": "운영자가 build, 빈 줄, test처럼 항목을 여러 줄로 붙여 넣습니다. 앞뒤 공백과 빈 줄을 제거하되 중복 항목을 유지할지는 업무 규칙으로 정해야 합니다.", "tasks": [ "공백·줄 구분자·중복 보존 정책을 명시하세요.", "Java 11 API로 의도가 드러나는 변환을 작성하세요.", "Java 8 배포 대상에 이 API가 들어갔을 때 어떤 검증이 막아야 하는지 설명하세요." ], - "takeaway": "짧아진 코드를 확인하는 데서 끝내지 않습니다. API 계약과 빌드·런타임 대상이 일치하는지 확인합니다." + "takeaway": "API 이름이 간단해졌다고 입력 정책까지 같아지는 것은 아닙니다. 허용 문자와 결과를 먼저 정하고, 대상 버전에서 지원되는 API인지 확인하세요." }, "sources": [ { @@ -874,12 +1163,47 @@ export const lessons = [ "Java", "REST API" ], - "exerciseId": "modern-apis-lab" + "exerciseId": "modern-apis-lab", + "learningGuide": { + "scenario": "붙여 넣은 텍스트에서 실제 항목 수를 세고, 이후 사용할 HTTP 클라이언트의 연결 제한도 지정합니다. 콘솔에는 2와 3이 출력됩니다.", + "question": "각 숫자는 계산한 결과일까요, 실행한 네트워크 요청의 결과일까요?", + "terms": [ + { + "term": "입력 정규화", + "meaning": "비교·처리하기 전에 공백 등 입력 표현을 정해진 규칙으로 맞추는 것. 원래 의미가 바뀌지 않는지 확인해야 합니다." + }, + { + "term": "연결 시간 제한", + "meaning": "새 연결을 만드는 데 허용할 대기 시간에 관한 설정. 응답 처리 전체의 시간 제한과 구별합니다." + }, + { + "term": "최초 도입 버전", + "meaning": "문법이나 API를 처음 사용할 수 있게 된 Java 버전. 현재 실행하는 JVM의 버전과는 다른 정보입니다." + } + ], + "walkthrough": [ + "text.lines()가 build가 있는 줄, 빈 줄, test가 있는 줄을 제공합니다.", + "map(String::strip)이 각 줄의 앞뒤 공백을 처리하고 filter가 빈 줄을 제외합니다.", + "count()가 남은 두 줄을 세어 meaningfulLines에 2를 저장합니다.", + "HttpClient의 builder는 3초 연결 제한을 저장해 client를 만듭니다. 여기까지 요청 전송은 없습니다.", + "마지막 출력은 meaningfulLines의 2와 설정값을 초로 읽은 3입니다." + ], + "prediction": { + "question": "출력 3을 보고 서버가 3초 안에 응답했다고 판단할 수 있을까요?", + "answer": "아닙니다. 연결 시간 제한 설정값만 읽었습니다.", + "explanation": "코드에는 요청을 보내는 send나 sendAsync가 없습니다. 설정 객체를 생성한 사실과 실제 통신 결과는 다릅니다." + }, + "takeaway": "새 API를 읽을 때 입력 변환·설정 생성·실제 실행을 구별하면, 코드가 보장하는 것보다 큰 효과를 기대하지 않게 됩니다.", + "code": { + "file": "examples/java11/modern-apis/Main.java", + "source": " String text = \" build \\n\\n test \";\n long meaningfulLines = text.lines()\n .map(String::strip)\n .filter(line -> !line.isBlank())\n .count();\n java.net.http.HttpClient client =\n java.net.http.HttpClient.newBuilder()\n .connectTimeout(java.time.Duration.ofSeconds(3))\n .build();\n System.out.println(meaningfulLines);\n System.out.println(client.connectTimeout().get().getSeconds());" + } + } }, { "id": "records-sealed", "title": "Java 17: record와 sealed 결과 모델", - "description": "값의 묶음과 결과 종류를 드러내되 불변성과 확장 제약을 정확히 이해합니다.", + "description": "계산 결과를 숫자 하나나 true/false 대신 성공 값과 실패 이유로 돌려줍니다. record의 값 비교와 sealed의 종류 제한을 실제 반환값에서 확인합니다.", "track": "advanced", "level": "심화", "minVersion": 17, @@ -888,29 +1212,29 @@ export const lessons = [ "duration": 45, "category": "버전 전환", "goals": [ - "record가 제공하는 구성 요소와 생성 메서드를 설명한다.", - "sealed 계층으로 허용된 결과 종류를 제한한다.", - "Java 17에서 정식인 기능과 preview 기능을 구분한다." + "doublePositive가 성공 값과 실패 이유를 서로 다른 타입으로 반환하는 과정을 설명한다.", + "record가 생성하는 접근자·동등성·문자열 표현을 예제와 연결한다.", + "record·sealed의 정식 버전과 Java 17에서 사용할 수 없는 정식 패턴 문법을 구분한다." ], "theory": [ { "heading": "record는 투명한 데이터 운반자", - "text": "record는 구성 요소에 대응하는 final 필드, 접근자, 생성자와 값 기반 equals·hashCode·toString을 제공하는 특별한 클래스입니다. 구성 요소가 가변 리스트라면 record라고 해서 그 리스트까지 깊게 불변이 되지 않습니다. 값의 의미와 유효성 검증을 먼저 정하고 compact constructor나 방어적 복사가 필요한지 검토하세요. record의 정식 도입은 Java 16입니다." + "text": "Success(6)은 value가 6인 결과 값입니다. record는 이 구성 요소에 대한 private final 필드, value() 같은 접근자, 생성자와 equals·hashCode·toString을 제공합니다. 예제의 두 Success(6)은 별개 객체여도 같은 record 타입과 구성 요소 값을 가져 equals가 true입니다. record를 쓰면 데이터의 모양이 선언에 드러납니다. 다만 구성 요소가 가변 List라면 참조를 final로 보관해도 목록 내용은 바뀔 수 있습니다. 필요한 입력 검사는 compact constructor에서, 공유를 막을 필요는 방어적 복사로 다룹니다. record의 정식 도입은 Java 16입니다." }, { "heading": "sealed는 확장 가능한 종류를 제한한다", - "text": "sealed 클래스·인터페이스는 허용된 직접 하위 타입을 제한합니다. 하위 타입은 final, sealed, non-sealed 등 규칙을 따라 확장 정책을 드러냅니다. 예측 가능한 결과의 종류를 모델링할 때 유용하지만 외부 확장을 의도한 API에는 제약 비용이 있습니다. sealed의 정식 도입은 Java 17이므로 두 기능을 함께 쓰는 이 예제의 대상은 17입니다." + "text": "Result는 Success 또는 Failure만 직접 구현할 수 있게 선언했습니다. 이렇게 허용할 직접 하위 타입을 정하는 기능이 sealed입니다. 이 예제는 성공이면 숫자, 실패면 이유를 주므로 호출자가 처리할 결과 종류를 읽기 쉽습니다. 하위 타입은 final·sealed·non-sealed 등의 확장 규칙을 따라야 하며, 외부에서 자유롭게 결과 종류를 추가해야 하는 API라면 제한 비용도 있습니다. sealed는 Java 17부터 정식이므로 record와 함께 쓰는 이 예제의 최소 대상은 17입니다." }, { "heading": "모델의 종류와 처리 문법은 별개다", - "text": "Java 17에서 record와 sealed를 사용할 수 있어도 pattern switch가 정식이라는 뜻은 아닙니다. 본 수업은 Java 17 preview 문법을 쓰지 않습니다. switch 표현식은 Java 14, text block은 15, instanceof 패턴은 16에서 정식 도입되었으며, record pattern과 pattern switch의 정식 학습은 Java 21 수업에서 이어집니다." + "text": "Java 17에서 결과 종류를 record와 sealed로 선언할 수 있다는 것과, 그 결과를 어떤 switch 문법으로 처리할 수 있다는 것은 별개입니다. 이 예제는 preview 문법을 쓰지 않습니다. switch 표현식은 14, text block은 15, instanceof 패턴은 16에서 정식 도입되었습니다. record 구성 요소를 패턴으로 꺼내거나 타입 패턴을 switch에 쓰는 정식 기능은 Java 21 수업에서 다룹니다. 새 문법을 사용할 때는 클래스 선언과 처리 문법의 버전을 각각 확인하세요." } ], "example": { "title": "닫힌 계산 결과와 record 동등성 · Main.java", "code": "class Main {\n sealed interface Result permits Success, Failure {}\n record Success(int value) implements Result {}\n record Failure(String reason) implements Result {}\n\n static Result doublePositive(int value) {\n if (value <= 0) {\n return new Failure(\"positive-required\");\n }\n return new Success(Math.multiplyExact(value, 2));\n }\n\n public static void main(String[] args) {\n System.out.println(doublePositive(3).equals(new Success(6)));\n System.out.println(doublePositive(0));\n }\n}", "output": "true\nFailure[reason=positive-required]", - "explanation": "Success와 Failure만 Result의 직접 구현으로 허용합니다. 두 Success(6)은 record의 값 동등성으로 같습니다. 이 단순 예제에서 양수의 두 배가 int 범위를 넘으면 Math.multiplyExact가 ArithmeticException을 던집니다." + "explanation": "doublePositive(3)는 Success(6)을 반환합니다. 새로 만든 다른 Success(6)과도 equals가 true여서 첫 출력은 true입니다. doublePositive(0)는 계산 전에 Failure(\"positive-required\")를 반환하고 record의 문자열 표현을 출력합니다. 이 예제는 모든 실패를 Failure로 바꾸지는 않습니다. 양수라도 두 배가 int 범위를 넘으면 Math.multiplyExact가 ArithmeticException을 던집니다." }, "quiz": [ { @@ -923,7 +1247,7 @@ export const lessons = [ "record의 equals는 항상 참조 동일성만 비교한다." ], "correctIndex": 2, - "explanation": "record가 제공하는 final 필드는 참조의 재할당을 제한합니다. 가변 구성 요소를 공유할지 방어적으로 복사할지는 별도 설계입니다." + "explanation": "record가 고정하는 것은 구성 요소 필드의 참조입니다. 그 참조가 가리키는 ArrayList가 다른 경로에서 바뀌는 것까지 막지는 않습니다. 목록 복사 여부와 요소의 변경 가능성은 별도로 정해야 합니다." }, { "id": "records-sealed-q2", @@ -935,18 +1259,18 @@ export const lessons = [ "record 11, sealed 16" ], "correctIndex": 1, - "explanation": "record는 Java 16, sealed는 Java 17에서 정식 도입되었습니다. 이 수업은 둘을 함께 사용하므로 최소 대상 버전이 17입니다." + "explanation": "record는 Java 16에서, sealed는 Java 17에서 정식 도입되었습니다. 하나의 예제에서 함께 쓰므로 더 높은 요구인 Java 17을 대상으로 잡습니다. 예제 대상 버전이 각 기능의 최초 버전과 항상 같지는 않습니다." } ], "practice": { "title": "성공과 거절 이유를 타입으로 표현", - "scenario": "예약 메서드가 true/false만 반환해 재고 부족과 잘못된 수량을 호출자가 구별하기 어렵습니다.", + "scenario": "예약 결과가 false로만 돌아와 수량이 잘못됐는지 재고가 부족한지 알 수 없습니다. 호출자가 실패 이유에 따라 다른 안내를 할 수 있는 반환 모델이 필요합니다.", "tasks": [ "성공 결과와 실패 결과의 정보를 정하세요.", "record와 sealed로 결과 종류를 모델링하세요.", "새 실패 종류를 추가했을 때 호출 코드가 검토해야 할 지점을 설명하세요." ], - "takeaway": "짧은 DTO 선언보다 상태와 결과의 의미를 명확히 하는 것이 먼저입니다. 닫힌 계층이 만드는 변경 비용도 함께 봅니다." + "takeaway": "record를 골라 선언을 줄이기 전에 성공 때 줄 정보와 실패 때 줄 정보를 정하세요. sealed로 종류를 제한하면 새 종류 추가 시 어떤 호출부를 함께 검토해야 할지도 드러납니다." }, "sources": [ { @@ -972,12 +1296,46 @@ export const lessons = [ "Java", "Java 17" ], - "exerciseId": "records-sealed-lab" + "exerciseId": "records-sealed-lab", + "learningGuide": { + "scenario": "양수의 두 배를 계산하되, 0 이하 입력은 값 대신 거절 이유를 반환하려 합니다. 호출자는 성공과 실패를 구별할 수 있어야 합니다.", + "question": "Result 한 타입으로 받으면서도 성공 값과 실패 이유를 어떻게 나눠 표현할까요?", + "terms": [ + { + "term": "record", + "meaning": "구성 요소를 선언하면 그 값을 담는 필드·접근자·동등성 등의 기본 동작을 제공하는 클래스." + }, + { + "term": "sealed", + "meaning": "직접 하위 타입으로 허용할 종류를 선언에서 제한하는 기능." + }, + { + "term": "값 동등성", + "meaning": "같은 인스턴스인지가 아니라, 타입이 정한 값 기준으로 같은지 비교하는 것. record는 구성 요소를 사용합니다." + } + ], + "walkthrough": [ + "Result는 직접 구현할 타입을 Success와 Failure로 제한합니다. Success는 value를, Failure는 reason을 보관합니다.", + "doublePositive(3)은 value <= 0을 통과하지 않고 Math.multiplyExact로 6을 계산해 Success로 반환합니다.", + "전체 예제의 main은 그 결과를 새 Success(6)과 비교합니다. 같은 record 타입과 값이므로 true입니다.", + "doublePositive(0)은 첫 조건에서 Failure를 반환합니다. reason은 positive-required이며 아래의 곱셈 줄에는 도달하지 않습니다." + ], + "prediction": { + "question": "doublePositive(0)은 숫자 0이나 Success(0)을 돌려줄까요?", + "answer": "Failure[reason=positive-required]에 해당하는 Failure 객체를 반환합니다.", + "explanation": "메서드의 입력 정책이 0 이하를 거절하도록 정해져 있습니다. record와 sealed는 그 정책의 결과를 표현하며, 정책 자체를 자동으로 정해 주지는 않습니다." + }, + "takeaway": "반환값이 false 하나라서 호출자가 후속 행동을 결정하지 못한다면, 성공 데이터와 실패 이유를 드러내는 결과 타입을 검토하세요.", + "code": { + "file": "examples/java17/records-sealed/Main.java", + "source": " sealed interface Result permits Success, Failure {}\n record Success(int value) implements Result {}\n record Failure(String reason) implements Result {}\n\n static Result doublePositive(int value) {\n if (value <= 0) {\n return new Failure(\"positive-required\");\n }\n return new Success(Math.multiplyExact(value, 2));\n }" + } + } }, { "id": "java21", "title": "Java 21: 패턴 분기와 가상 스레드", - "description": "타입별 처리를 명확하게 표현하고 동시성 도구의 적용 조건을 검토합니다.", + "description": "Job의 종류와 attempts 값을 함께 보고 이름을 붙입니다. 이 분기 코드와 가상 스레드 실행기의 역할을 나누어 읽습니다.", "track": "advanced", "level": "심화", "minVersion": 21, @@ -986,29 +1344,29 @@ export const lessons = [ "duration": 50, "category": "버전 전환", "goals": [ - "record pattern·when 가드·null 분기를 설명한다.", - "패턴의 순서와 포괄성의 의미를 이해한다.", - "가상 스레드를 적용할 작업과 자원 한계를 구분한다." + "Job 패턴에서 name·attempts를 꺼내고 when 조건으로 분기를 고르는 과정을 설명한다.", + "0회·null·다른 타입의 입력이 어느 분기에 도달하는지 예측한다.", + "가상 스레드가 바꾸는 실행 방식과 바꾸지 않는 CPU·외부 자원 한계를 구분한다." ], "theory": [ { "heading": "타입과 상태를 함께 드러내는 패턴", - "text": "Java 21에서 record pattern과 pattern switch가 정식 기능이 되었습니다. record의 구성 요소를 패턴으로 꺼내고 when 가드로 추가 조건을 표현할 수 있습니다. 넓은 패턴이 앞에 오면 뒤의 구체 패턴을 가릴 수 있으므로 지배 관계와 포괄성을 검토하세요. switch가 null을 자동으로 정상 처리한다고 가정하지 말고 의도한 case null을 명시합니다." + "text": "label은 입력이 Job이고 attempts가 양수이면 name:attempts를 만듭니다. case Job(String name, int attempts)가 구성 요소를 꺼내는 record pattern이고, when attempts > 0은 추가로 만족해야 할 가드 조건입니다. 나머지 Job은 다음 case로, null은 case null로 갑니다. 앞의 넓은 패턴이 뒤의 구체 패턴을 모두 포함해 버리면 그 분기는 가려질 수 있습니다. 모든 입력을 처리할 분기가 있는지도 확인해야 합니다. 이 record pattern과 pattern switch는 Java 21의 정식 기능입니다." }, { "heading": "가상 스레드는 더 빠른 CPU 스레드가 아니다", - "text": "가상 스레드는 많은 대기 중심 작업을 다루는 데 도움이 될 수 있지만 CPU 계산 자체를 빠르게 만들지 않습니다. 스레드당 작업 방식과 자원 수명을 이해하고 도입하세요. DB 연결 수나 외부 서비스 허용량은 스레드가 가볍다는 이유로 늘어나지 않으므로 별도 동시성 제한과 시간 제한이 필요합니다." + "text": "main은 label 계산을 newVirtualThreadPerTaskExecutor에 제출합니다. 가상 스레드는 JVM이 관리하며, 많은 작업이 I/O를 기다리는 상황을 감당하는 데 도움이 될 수 있습니다. 하지만 이 예제의 문자열 계산 한 건이 더 빨라졌음을 뜻하지 않습니다. CPU 계산량은 그대로이고 DB 연결 10개만 허용되는 서비스가 가상 스레드를 많이 만든다고 연결도 늘어나지는 않습니다. 동시 요청 수·시간 제한·작업 종료를 외부 자원 한계에 맞춰 따로 관리하세요." }, { "heading": "대상 버전의 실행 특성으로 판단한다", - "text": "Java 21의 가상 스레드에서는 synchronized 블록 안의 특정 블로킹 작업 등으로 carrier에 고정되는 상황을 고려해야 합니다. 이후 JDK에서 동작이 개선되었다는 사실을 Java 21에 소급하지 않습니다. preview 기능을 섞지 않고 기존 테스트·관측 지표·부하 특성으로 이행 효과를 검증합니다. 아래 예제는 기능 사용을 보여 줄 뿐 성능 벤치마크가 아닙니다." + "text": "Java 21에서는 가상 스레드가 synchronized 블록·메서드 안에서 블로킹할 때 이를 실행하는 플랫폼 스레드인 carrier를 놓지 못하는 pinning을 고려해야 합니다. 빈번하고 오래 걸리는 경우 확장성을 떨어뜨릴 수 있으므로 실제 대기와 관측 결과를 봐야 합니다. 이후 JDK의 개선을 Java 21에도 있다고 가정하지 마세요. 이 수업은 preview 기능 없이 패턴 분기와 가상 스레드 사용을 보여 줍니다. 성능 개선 여부는 기존 테스트·지표·부하 비교로 별도 확인해야 합니다." } ], "example": { "title": "패턴 분기 작업을 실행하고 기다리기 · Main.java", "code": "class Main {\n record Job(String name, int attempts) {}\n\n static String label(Object value) {\n return switch (value) {\n case Job(String name, int attempts) when attempts > 0 ->\n name + \":\" + attempts;\n case Job job -> \"not-started\";\n case null -> \"missing\";\n default -> \"unknown\";\n };\n }\n\n public static void main(String[] args) throws Exception {\n try (java.util.concurrent.ExecutorService executor =\n java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {\n java.util.concurrent.Future result =\n executor.submit(() -> label(new Job(\"sync\", 2)));\n System.out.println(result.get());\n System.out.println(label(null));\n }\n }\n}", "output": "sync:2\nmissing", - "explanation": "패턴과 가드로 Job의 상태를 분류합니다. 가상 스레드 실행기에 작업을 제출한 뒤 Future로 결과를 기다리고 try-with-resources로 실행기 수명을 관리합니다. 모든 문법·API는 Java 21 정식 기능입니다." + "explanation": "제출한 작업은 label에 Job(\"sync\", 2)를 넘깁니다. Job 패턴에 맞고 attempts > 0도 참이므로 sync:2를 반환합니다. main은 result.get으로 작업 결과를 기다린 다음 출력합니다. label(null)은 명시한 case null에서 missing을 돌려줍니다. try-with-resources는 실행기를 닫는 시점을 정하며, 이 짧은 실행은 성능 비교 실험이 아닙니다." }, "quiz": [ { @@ -1021,7 +1379,7 @@ export const lessons = [ "동기화와 취소 처리를 모두 생략할 수 있다." ], "correctIndex": 1, - "explanation": "가상 스레드는 많은 대기 작업의 처리 규모를 지원하는 도구입니다. 작업 자체의 CPU 속도나 DB·외부 시스템의 수용량을 자동으로 높이지 않습니다." + "explanation": "가상 스레드는 대기하는 작업을 많이 두는 비용을 줄이는 데 도움이 될 수 있습니다. CPU 계산을 자동으로 빠르게 하거나 DB 연결 풀·외부 API 허용량을 늘리는 기능은 아닙니다. 그 자원들의 동시 사용 제한은 따로 필요합니다." }, { "id": "java21-q2", @@ -1033,18 +1391,18 @@ export const lessons = [ "Java 8에서 같은 문법을 사용할 수 있다." ], "correctIndex": 2, - "explanation": "패턴 일치와 가드의 참 조건을 모두 만족해야 합니다. record pattern과 pattern switch는 이 수업의 Java 21 정식 문법입니다." + "explanation": "입력이 Order 패턴에 맞아 구성 요소를 꺼낼 수 있고, 꺼낸 quantity가 0보다 커야 이 case를 선택합니다. 같은 Order라도 quantity가 조건에 맞지 않으면 이 가드가 붙은 분기에 일치하지 않습니다." } ], "practice": { "title": "버전 상향의 효과와 위험 설명", - "scenario": "Java 17 서비스의 타입 분기를 단순화하고 대기 중심 호출의 실행 모델을 검토하려 합니다.", + "scenario": "Java 17 서비스에서 타입별 if 분기가 길어졌습니다. 한편 외부 호출을 기다리는 작업도 많아, 분기 문법을 바꾸는 효과와 실행 방식을 바꾸는 효과를 각각 확인하려 합니다.", "tasks": [ "패턴 분기에서 null·알 수 없는 타입·경계 상태를 테스트하세요.", "가상 스레드 적용 전후 비교에 필요한 대기·CPU·외부 자원 지표를 정하세요.", "외부 연결 수 제한과 취소·종료 정책을 설명하세요." ], - "takeaway": "새 기능을 사용했다는 사실과 서비스가 개선되었다는 증거를 구분합니다. 버전별 계약과 실제 작업 특성으로 판단합니다." + "takeaway": "분기 코드가 읽기 쉬워진 것과 서비스 처리량이 좋아진 것은 다른 결과입니다. 입력 반례로 문법 변경을 검증하고 실제 부하·외부 자원 지표로 실행 모델 변경을 검증하세요." }, "sources": [ { @@ -1070,12 +1428,50 @@ export const lessons = [ "Java", "Java 21" ], - "exerciseId": "java21-lab" + "exerciseId": "java21-lab", + "learningGuide": { + "scenario": "작업 Job에는 name과 attempts가 있습니다. 시도 횟수가 양수이면 sync:2처럼 표시하고, 아직 시도하지 않은 Job이나 값이 없는 경우는 다른 이름을 붙입니다.", + "question": "Job이라는 타입 확인만으로 첫 번째 case를 선택할 수 있을까요?", + "terms": [ + { + "term": "record pattern", + "meaning": "record 타입이 맞는지 보면서 구성 요소를 변수로 꺼내는 패턴." + }, + { + "term": "가드 when", + "meaning": "타입 패턴에 맞은 뒤에도 추가로 참이어야 하는 조건." + }, + { + "term": "분기의 포괄성", + "meaning": "가능한 입력을 처리할 분기가 빠지지 않았는지 보는 성질." + }, + { + "term": "가상 스레드", + "meaning": "JVM이 관리하는 스레드. 분기 결과의 의미를 바꾸지 않으며 CPU 계산을 자동 가속하지 않습니다." + } + ], + "walkthrough": [ + "label에 Job(\"sync\", 2)가 들어오면 첫 패턴에서 name은 sync, attempts는 2가 됩니다.", + "when attempts > 0이 참이므로 첫 분기가 sync:2를 반환합니다.", + "Job이지만 가드가 거짓이면 다음 case Job job에서 not-started를 반환합니다.", + "입력이 null이면 case null에서 missing을, 나머지 타입이면 default에서 unknown을 반환합니다. 각 case의 범위와 순서가 결과를 정합니다." + ], + "prediction": { + "question": "Job의 attempts가 0인 경우와 입력이 null인 경우에 각각 무엇을 반환할까요?", + "answer": "각각 not-started와 missing입니다.", + "explanation": "0은 양수 가드를 통과하지 못하지만 Job 타입에는 맞습니다. null은 일반 Job 분기가 아니라 명시한 case null에서 처리합니다." + }, + "takeaway": "타입을 확인한 뒤 필드를 다시 꺼내는 코드를 읽을 때, 패턴·추가 조건·null 처리를 한곳에 모으면 어떤 입력이 어느 분기로 가는지 확인하기 쉬워집니다.", + "code": { + "file": "examples/java21/java21/Main.java", + "source": " static String label(Object value) {\n return switch (value) {\n case Job(String name, int attempts) when attempts > 0 ->\n name + \":\" + attempts;\n case Job job -> \"not-started\";\n case null -> \"missing\";\n default -> \"unknown\";\n };\n }" + } + } }, { "id": "testing-design", "title": "테스트 설계: 계약과 경계 결함 구별", - "description": "테스트가 통과한다는 사실보다 잘못된 구현을 실제로 구별하는지 확인합니다.", + "description": "무료 배송 조건의 >=를 >로 잘못 바꿨을 때 어떤 검사가 실패해야 할까요? 실제 경계값과 기대값으로 테스트의 역할을 확인합니다.", "track": "both", "level": "심화", "minVersion": 8, @@ -1084,29 +1480,29 @@ export const lessons = [ "duration": 45, "category": "실패와 검증", "goals": [ - "사례를 정상·경계·실패로 나눠 계약을 검증한다.", - "결정적 계산과 외부 환경 의존을 분리한다.", - "잘못된 구현을 통해 테스트의 판별력을 점검한다." + "29999·30000·30001의 기대 배송비를 요구사항에서 먼저 정한다.", + "음수 입력이 정상 반환하면 테스트 자체가 실패하도록 만드는 이유를 설명한다.", + "테스트가 찾을 수 있는 결함과 다루지 않은 범위를 구분한다." ], "theory": [ { "heading": "테스트는 구현을 복사하지 않는다", - "text": "메서드 내부의 조건문을 그대로 기대값 계산에 복사하면 같은 결함을 테스트에도 넣을 수 있습니다. 요구사항에서 기대 행동을 먼저 정하고 경계 바로 아래·경계·경계 위를 선택하세요. 대표값 검사가 전체 입력 공간에 대한 수학적 증명은 아니라는 점도 구분합니다." + "text": "요구사항이 ‘30000 이상은 무료 배송’이라면 29999는 3000, 30000과 30001은 0이어야 합니다. 테스트의 기대값은 이 문장에서 정합니다. shippingFee의 조건문을 테스트 안에 그대로 복사해 기대값을 계산하면, 두 곳에 같은 실수가 들어가도 통과할 수 있습니다. 동작이 바뀌는 값 바로 아래·그 값·바로 위를 경계값이라고 부릅니다. 이 값들을 고르면 >=를 >로 잘못 바꾸는 결함을 드러낼 수 있지만 모든 입력에 대한 증명이 되는 것은 아닙니다." }, { "heading": "테스트 가능성은 의존 관계와 연결된다", - "text": "현재 시간, 임의 값, 네트워크와 DB를 계산 로직에 직접 결합하면 재현하기 어렵습니다. 순수 계산과 외부 의존을 분리하고 필요한 경계에 협력 객체를 주입하세요. 대역을 사용한 단위 검사는 실제 통합 환경의 계약 검사를 대체하지 않습니다. 테스트마다 같은 입력과 조건으로 실행할 수 있도록 설계합니다." + "text": "shippingFee는 subtotal만 받아 결과를 계산하므로 같은 입력으로 반복 확인하기 쉽습니다. 계산 안에서 현재 시간·무작위 값·네트워크·DB를 직접 읽으면 같은 테스트도 조건에 따라 달라질 수 있습니다. 필요한 외부 값을 계산과 분리하거나 협력 객체로 전달하면 조건을 통제할 수 있습니다. 테스트에서 진짜 외부 시스템 대신 쓰는 객체를 대역이라고 합니다. 대역으로 계산을 확인한 단위 테스트와 실제 DB·네트워크 연결을 확인하는 통합 테스트는 서로 다른 범위를 봅니다." }, { "heading": "좋은 테스트는 잘못된 구현을 거절한다", - "text": "정상 구현만 통과하는지 보는 데서 멈추지 않고 >=를 >로 바꾸거나 음수 검증을 누락한 구현을 넣어 보세요. 실제 실무에서는 JUnit 등의 테스트 도구가 실패 위치·메시지·실행을 지원합니다. 이 수업은 외부 의존성 없는 Java로 판별력의 원리를 연습하며 JUnit을 실행했다고 표시하지 않습니다." + "text": "check(0, shippingFee(30000))는 무료 배송 경계가 잘못되면 AssertionError를 던집니다. 음수 검사에서는 shippingFee(-1)이 예외 없이 돌아오면 오히려 테스트 쪽에서 AssertionError를 던집니다. 기대한 실패가 없다는 것도 결함이기 때문입니다. 이런 검사가 잘못된 구현을 걸러내는 힘을 테스트의 판별력이라고 부릅니다. 실무에서는 JUnit이 실패 위치와 실행 관리를 돕지만, 이 예제는 의존성 없는 Java 메서드로 원리를 보여 줍니다." } ], "example": { "title": "기대값을 명시하는 작은 계약 검사 · Main.java", "code": "class Main {\n static int shippingFee(int subtotal) {\n if (subtotal < 0) {\n throw new IllegalArgumentException(\"negative subtotal\");\n }\n return subtotal >= 30000 ? 0 : 3000;\n }\n\n static void check(int expected, int actual) {\n if (expected != actual) {\n throw new AssertionError(\"expected=\" + expected + \", actual=\" + actual);\n }\n }\n\n public static void main(String[] args) {\n check(3000, shippingFee(29999));\n check(0, shippingFee(30000));\n check(0, shippingFee(30001));\n try {\n shippingFee(-1);\n throw new AssertionError(\"negative input must fail\");\n } catch (IllegalArgumentException expected) {\n // Expected contract failure.\n }\n System.out.println(\"4 contract checks passed\");\n }\n}", "output": "4 contract checks passed", - "explanation": "무료 배송 경계의 양옆과 음수 실패 계약을 직접 검사합니다. -ea 옵션에 의존하는 assert 문 대신 실패 시 AssertionError를 던지므로 검사가 항상 실행됩니다. 실제 테스트 도구를 대체하려는 구현은 아닙니다." + "explanation": "앞의 세 check는 경계 아래·경계·경계 위의 배송비를 직접 비교합니다. 다음 try는 음수 입력이 IllegalArgumentException을 던지는지 확인합니다. 정상 반환한다면 테스트가 AssertionError를 던져 실패합니다. 네 검사를 모두 지나야 마지막 문장이 출력됩니다. check의 if와 throw는 -ea 옵션이 필요한 assert 문을 사용하지 않으며, 이 예제가 JUnit의 전체 기능을 대체하는 것은 아닙니다." }, "quiz": [ { @@ -1119,7 +1515,7 @@ export const lessons = [ "항상 100000만" ], "correctIndex": 2, - "explanation": "경계 바로 아래·경계·경계 위를 검사하면 >와 >=의 차이 등을 드러낼 수 있습니다. 다른 입력·실패 조건도 별도 요구에 따라 추가합니다." + "explanation": "29999는 아직 유료, 30000부터는 무료라는 차이를 직접 관찰하는 입력입니다. >=를 >로 잘못 바꾸면 30000 검사가 실패합니다. 같은 쪽에만 있는 1000·2000·3000은 이 결함을 드러내지 못합니다." }, { "id": "testing-design-q2", @@ -1131,18 +1527,18 @@ export const lessons = [ "테스트 실행 횟수만 늘리면 모든 설계 문제가 해결된다." ], "correctIndex": 1, - "explanation": "테스트는 중요한 결함을 드러내야 합니다. 입력 선정, 기대값, 실제 assertion의 실행 여부를 확인하고 그 결함을 재현하는 검사를 보완합니다." + "explanation": "틀린 구현도 통과한다면 그 차이가 드러나는 입력이나 기대값을 검사하지 않았을 수 있습니다. 실제 check가 실행되는지도 확인해야 합니다. 같은 검사를 많이 반복하는 것보다 해당 오답이 실패하는 사례를 추가하는 것이 먼저입니다." } ], "practice": { "title": "배송비 정책을 검증하는 테스트 작성", - "scenario": "무료 배송 경계를 잘못 바꾼 구현이 기존 테스트를 통과했습니다.", + "scenario": "무료 배송 조건이 >=에서 >로 바뀌었는데 기존 테스트는 계속 통과했습니다. 테스트가 1000원과 100000원 주문만 확인해 정확히 30000원인 경우를 놓쳤기 때문입니다.", "tasks": [ "정상·경계·실패 대표값을 기대 결과와 함께 정하세요.", "정상 구현은 수용하고 경계가 틀린 구현은 거절하는 검사 함수를 만드세요.", "실무 도구로 옮길 때 필요한 실패 메시지와 단위·통합 검사 범위를 설명하세요." ], - "takeaway": "실행 성공과 계약 검증, 설계 설명은 서로 다른 결과입니다. 테스트가 어떤 결함을 찾는지 근거를 남깁니다." + "takeaway": "테스트 이름이나 실행 횟수만 보지 말고, 어떤 잘못된 구현이 어느 입력에서 실패하는지 설명하세요. 실행 성공·계약 확인·설계 설명은 각각 확인해야 합니다." }, "sources": [ { @@ -1168,6 +1564,40 @@ export const lessons = [ "Java", "JUnit" ], - "exerciseId": "testing-design-lab" + "exerciseId": "testing-design-lab", + "learningGuide": { + "scenario": "배송비 정책은 30000 이상 무료, 그 미만 3000이며 음수 금액은 거절합니다. 정상 코드를 통과시키는 것뿐 아니라 잘못 바뀐 경계도 잡아야 합니다.", + "question": "어떤 줄이 >=를 >로 바꾼 실수를 잡고, 어떤 줄이 음수 검증 누락을 잡을까요?", + "terms": [ + { + "term": "계약 검사", + "meaning": "구현 방식과 별개로, 약속한 입력·결과·실패를 실제 호출로 확인하는 검사." + }, + { + "term": "경계값", + "meaning": "동작이 달라지는 값과 그 바로 주변의 값. 여기서는 30000과 양옆 금액입니다." + }, + { + "term": "테스트의 판별력", + "meaning": "정상 구현은 통과시키고 중요한 잘못된 구현은 실패시키는 능력." + } + ], + "walkthrough": [ + "첫 check는 29999의 배송비가 3000인지, 다음 두 check는 30000과 30001의 배송비가 0인지 비교합니다.", + "check는 expected와 actual이 다르면 AssertionError를 던집니다. 조건을 >로 잘못 바꾸면 30000 검사에서 멈춥니다.", + "음수 입력은 IllegalArgumentException이 나야 합니다. shippingFee(-1)이 정상 반환하면 다음 줄이 AssertionError를 던집니다.", + "기대한 IllegalArgumentException만 catch한 뒤 네 검사를 모두 통과해야 마지막 출력에 도달합니다." + ], + "prediction": { + "question": "shippingFee가 음수에서도 3000을 반환하도록 잘못 바뀌면 마지막 성공 문구가 출력될까요?", + "answer": "출력되지 않습니다.", + "explanation": "shippingFee(-1)이 정상 반환하면 바로 다음 줄의 AssertionError가 발생합니다. catch는 IllegalArgumentException만 받으므로 테스트 실패가 숨겨지지 않습니다." + }, + "takeaway": "실패해야 하는 입력의 테스트에는 ‘예외가 안 나면 테스트가 실패하는가’까지 확인하세요. 기대값은 구현의 조건문을 복사하지 말고 업무 규칙에서 정합니다.", + "code": { + "file": "examples/java8/testing-design/Main.java", + "source": " check(3000, shippingFee(29999));\n check(0, shippingFee(30000));\n check(0, shippingFee(30001));\n try {\n shippingFee(-1);\n throw new AssertionError(\"negative input must fail\");\n } catch (IllegalArgumentException expected) {\n // Expected contract failure.\n }\n System.out.println(\"4 contract checks passed\");" + } + } } ]; diff --git a/dist/index.html b/dist/index.html index acd632c..c20fed1 100644 --- a/dist/index.html +++ b/dist/index.html @@ -29,17 +29,18 @@
-

THE JAVA LEARNING WORKBENCH

이해에서 한 걸음 더,
설계하는 Java로.

다른 언어에서 Java로, 익숙한 코드에서 깊은 설계로.
공식 문서를 읽고 직접 구현하며 판단의 근거를 쌓습니다.

2 학습 트랙4 기준 버전34 전체 수업
-
MY LEARNING브라우저 기록
나의 학습 여정0 / 12

객관식 성취 + 코드 제출 통과 시 수업 완료

+

Java 학습실

0 / 34 완료
+
+ + 이 브라우저에 저장
-
WORKSPACE 01Java 학습실
Java 공식 문서 기반
학습 콘텐츠를 불러오고 있습니다…
javaedu.

공식 문서로 이해하고, 코드로 확인하는 시간.

diff --git a/dist/styles.css b/dist/styles.css index 37aa09e..0e06bac 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -39,7 +39,7 @@ ul, ol { padding-left: 1.35rem; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } .skip-link { position: fixed; z-index: 200; top: -6rem; left: 1rem; padding: .7rem 1rem; background: var(--green); color: white; border-radius: 8px; } .skip-link:focus { top: 1rem; } -.site-header { min-height: 85px; max-width: 1600px; padding: 1.1rem 3rem; margin: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 1rem 3.5rem; border-bottom: 1px solid var(--line); } +.site-header { min-height: 68px; max-width: 1920px; padding: .65rem clamp(16px, 2vw, 32px); margin: auto; display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.5rem; border-bottom: 1px solid var(--line); } .brand { display: flex; align-items: center; gap: .65rem; color: var(--green); font-size: 1.65rem; font-weight: 800; letter-spacing: -.09rem; text-decoration: none; line-height: 1.2; } .brand-light { font-weight: 400; } .brand-mark { display: inline-flex; flex-shrink: 0; width: 2.25rem; height: 2.375rem; align-items: center; justify-content: center; padding-bottom: .25rem; border-radius: .5625rem; background: var(--green); color: white; font-family: Georgia, serif; font-weight: 600; font-style: italic; font-size: 1.9rem; line-height: 1; } @@ -55,12 +55,13 @@ ul, ol { padding-left: 1.35rem; } .icon-button { display: inline-flex; align-items: center; justify-content: center; gap: .4rem; min-width: 42px; min-height: 42px; padding: .55rem; border-radius: 8px; } .icon-button:hover { background: var(--green-soft); } .mobile-menu-toggle, .mobile-menu-close { display: none; } -.page-shell { width: min(100%, 1600px); margin: auto; padding: 0 3rem; } -.intro { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 365px); align-items: center; gap: 4rem; padding: 3.4rem 0 2.8rem; } +.page-shell { width: min(100%, 1920px); margin: auto; padding: 0 clamp(16px, 2vw, 32px); } +.intro { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.1rem; padding: .75rem 0; border-bottom: 1px solid var(--line); } +.learning-status-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem .8rem; } .eyebrow, .small-label { font-size: .6875rem; font-weight: 700; line-height: 1.5; letter-spacing: .13em; } .eyebrow { color: var(--rust); display: flex; align-items: center; gap: .6rem; } .eyebrow-line { width: 21px; height: 1px; background: var(--rust); } -.intro h1 { font-size: clamp(2rem, 3.4vw, 3.05rem); font-weight: 760; line-height: 1.35; letter-spacing: -.06em; margin-top: 1rem; } +.intro h1 { font-size: 1rem; font-weight: 750; line-height: 1.5; letter-spacing: -.025em; margin: 0; } .intro h1 span { color: var(--green); } .intro-description { color: var(--muted); margin-top: 1rem; font-size: .875rem; line-height: 1.85; letter-spacing: -.01em; } .intro-facts { display: flex; flex-wrap: wrap; gap: 1.4rem; color: var(--muted); margin-top: 1.45rem; font-size: .75rem; } @@ -84,12 +85,19 @@ ul, ol { padding-left: 1.35rem; } .continue-button:hover .icon { transform: translateX(3px); } .continue-label { display: block; font-size: .6875rem; color: #68795f; margin-bottom: .2rem; } .continue-button strong { display: block; font-size: .8125rem; font-weight: 650; line-height: 1.5; overflow-wrap: anywhere; } +.intro .journey-count strong { font-size: inherit; font-weight: 750; } +.intro .progress-track { flex: 0 1 112px; width: 112px; min-width: 64px; } +.intro .continue-button { flex: 1 1 260px; width: auto; min-width: 0; min-height: 40px; padding: 0; margin: 0; border: 0; gap: .65rem; justify-content: flex-start; } +.intro .continue-button > span { display: flex; flex-wrap: wrap; align-items: baseline; gap: .15rem .55rem; } +.intro .continue-label { margin: 0; } +.intro .continue-button .icon { width: 16px; height: 16px; padding: 0; background: transparent; color: var(--green); } +.intro .storage-indicator { display: inline-flex; font-size: .6875rem; } .workspace-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--line); font-size: .6875rem; } .workspace-heading > div { display: flex; align-items: center; gap: .75rem; } .workspace-kicker { font: .625rem/1.5 var(--mono); letter-spacing: .03em; color: var(--muted); } .workspace-divider { display: block; width: 1px; height: 12px; background: #bec6b9; } .official-note { display: flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .625rem; } -.workspace-grid { display: grid; grid-template-columns: 228px minmax(0, 1fr) 240px; align-items: start; gap: 1.5rem; padding-top: 1.5rem; } +.workspace-grid { display: grid; grid-template-columns: 220px minmax(0, 1fr); align-items: start; gap: 20px; padding-top: 16px; } .curriculum-sidebar { min-width: 0; position: sticky; top: 1.5rem; max-height: calc(100dvh - 3rem); overflow-y: auto; scrollbar-width: thin; scrollbar-color: #c7d0bd transparent; } .sidebar-heading { display: flex; align-items: center; justify-content: space-between; gap: .7rem; margin-bottom: 1.3rem; } .sidebar-heading .small-label { font-size: .6875rem; letter-spacing: .12em; color: var(--muted); margin-bottom: .35rem; } @@ -128,29 +136,43 @@ ul, ol { padding-left: 1.35rem; } .text-button:hover { color: var(--green); } .reset-records { margin-top: .7rem; font-size: .6875rem; } .lesson-card { min-width: 0; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: clip; box-shadow: 0 6px 18px #37493103; } -.lesson-header { padding: 1.9rem 2rem 1.6rem; } +.lesson-header { padding: 20px 24px 8px; } +.lesson-heading-row { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem 1rem; } +.lesson-heading-row h2 { min-width: 0; flex: 1; } +.lesson-status-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem 1rem; margin-top: .75rem; } +.lesson-status-row > .lesson-position { margin-left: auto; } .lesson-topline { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } .lesson-topline .eyebrow { font-size: .6875rem; letter-spacing: .08em; } .lesson-position { font: .5625rem/1.5 var(--mono); color: #8a9385; flex-shrink: 0; } -.lesson-header h2 { font-size: clamp(1.5rem, 2.1vw, 1.8rem); font-weight: 750; line-height: 1.45; letter-spacing: -.04em; margin-top: .6rem; overflow-wrap: anywhere; } +.lesson-header h2 { font-size: clamp(1.5rem, 2.1vw, 1.8rem); font-weight: 750; line-height: 1.45; letter-spacing: -.04em; margin: 0; word-break: keep-all; overflow-wrap: anywhere; } .lesson-description { font-size: .875rem; line-height: 1.8; color: var(--muted); margin-top: .8rem; overflow-wrap: anywhere; } -.lesson-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .55rem; margin-top: 1.15rem; } +.lesson-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .55rem; } .meta-chip { display: inline-flex; align-items: center; gap: .3rem; padding: .18rem .5rem; border: 1px solid #dde3d5; border-radius: 5px; color: #5d6f51; background: #f1f4ed; font-size: .6875rem; line-height: 1.65; } .meta-chip .icon { width: 12px; height: 12px; flex-basis: 12px; } .meta-chip.is-neutral { background: transparent; border-color: var(--line); color: var(--muted); } .meta-chip.is-preview { background: var(--rust-soft); border-color: #e8cbb8; color: var(--rust); } -.achievement-row { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .95rem; } +.achievement-row { display: flex; flex-wrap: wrap; gap: .4rem .75rem; } .achievement-badge { display: inline-flex; align-items: center; gap: .25rem; font-size: .6875rem; color: #677361; } .achievement-badge::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: #cbd1c5; } .achievement-badge.is-complete { color: #3b694b; font-weight: 650; } .achievement-badge.is-complete::before { background: #52775a; } .achievement-badge.is-past { color: var(--rust); } .achievement-badge.is-past::before { background: #ba7654; } +.lesson-support { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0 20px; padding: 0 24px 10px; } +.lesson-details { flex: 0 1 auto; min-width: 0; max-width: 100%; } +.lesson-details[open] { flex: 1 1 100%; } +.lesson-details > summary { min-height: 44px; padding: .55rem 0; color: var(--green); font-size: .8125rem; cursor: pointer; touch-action: manipulation; overflow-wrap: anywhere; } +.lesson-details > summary:hover { text-decoration: underline; text-underline-offset: 4px; } +.lesson-information { padding: 8px 0 16px; max-width: 60rem; overflow-wrap: anywhere; } +.lesson-detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .9rem; margin-top: .75rem; color: var(--muted); font-size: .75rem; } +.lesson-detail-meta .meta-chip { border: 0; background: transparent; padding: 0; font-size: inherit; } +.lesson-information .learning-goals { padding: 0; margin: 1rem 0 0; background: transparent; border: 0; border-radius: 0; } +.lesson-information h3 { font-size: .875rem; margin-bottom: .5rem; } .lesson-tabs { display: flex; align-items: stretch; gap: .2rem; padding: 0 1.6rem; border-top: 1px solid #e8eadf; border-bottom: 1px solid #e0e3d8; background: #fafbf5; overflow-x: auto; scrollbar-width: thin; scrollbar-color: #bcc8b5 transparent; } .lesson-tabs button { position: relative; flex: 1 0 auto; font-size: .8125rem; color: #67765d; padding: .95rem .5rem; border-bottom: 2px solid transparent; white-space: nowrap; } .lesson-tabs button[aria-selected="true"] { color: var(--green); border-bottom-color: var(--green); font-weight: 750; } .tab-number { font: .5625rem/1.5 var(--mono); margin-right: .25rem; opacity: .65; } -.lesson-panel { min-height: 330px; padding: 1.9rem 2rem 2rem; overflow-wrap: anywhere; } +.lesson-panel { min-height: 330px; padding: 24px; overflow-wrap: anywhere; } .lesson-panel h3 { font-size: 1.0625rem; letter-spacing: -.025em; line-height: 1.6; } .section-heading { display: flex; align-items: center; gap: .65rem; margin-bottom: .9rem; } .section-heading .section-number { flex-shrink: 0; white-space: nowrap; font: .6875rem/1.5 var(--mono); color: #8da078; } @@ -162,6 +184,55 @@ ul, ol { padding-left: 1.35rem; } .learning-goals .icon { width: 14px; height: 14px; flex-basis: 14px; color: #628055; margin-top: .2rem; } .theory-section + .theory-section { margin-top: 1.7rem; padding-top: 1.45rem; border-top: 1px solid #e8eadf; } .theory-section p { color: #536152; font-size: .9375rem; line-height: 1.95; margin-top: .7rem; white-space: pre-line; } +.learning-guide { container-type: inline-size; container-name: learning-guide; min-width: 0; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--line); color: #425447; font-size: .9375rem; line-height: 1.9; } +.guide-scenario { padding-left: 1rem; border-left: 3px solid #789263; } +.guide-scenario .small-label { color: #546b48; margin-bottom: .4rem; } +.guide-scenario h3 { color: var(--green); } +.guide-scenario-text, .guide-question { margin-top: .75rem; white-space: pre-line; } +.guide-question { color: var(--green); font-weight: 700; } +.guide-section { min-width: 0; margin-top: 24px; } +.guide-section h3 { color: var(--ink); } +.guide-terms-list { margin: .6rem 0 0; max-width: 60rem; } +.guide-term { display: grid; grid-template-columns: minmax(0, 12rem) minmax(0, 1fr); gap: .3rem 1.15rem; padding: .75rem 0; border-bottom: 1px solid #e8eadf; } +.guide-term dt { color: var(--green); font-weight: 700; } +.guide-term dd { margin: 0; white-space: pre-line; } +.guide-code-scope, .guide-code-hint { color: #66716a; font-size: .8125rem; line-height: 1.8; margin-top: .55rem; } +.guide-code-hint { margin-top: -.3rem; } +.guide-code .code-heading { flex-wrap: wrap; } +.guide-code .example-filename { flex: 1 1 13rem; flex-wrap: wrap; } +.guide-code-filename { min-width: 0; } +.guide-code .copy-button { min-height: 44px; } +.guide-code .editor-theme-select { min-height: 44px; } +.guide-code-tools { display: flex; flex-wrap: wrap; align-items: center; gap: .25rem 1rem; padding: .55rem min(.95rem, 16px); background: var(--code-toolbar); border-bottom: 1px solid var(--code-border); color: var(--code-muted); } +.guide-code-tools .code-settings { padding: 0; } +.code-legend-disclosure { min-width: 0; max-width: 100%; } +.code-legend-disclosure[open] { flex: 1 1 100%; } +.code-legend-disclosure > summary { min-height: 44px; padding: .65rem 0; font-size: .75rem; cursor: pointer; touch-action: manipulation; } +.code-legend-disclosure .syntax-legend { padding: .35rem 0; border: 0; } +.code-legend-disclosure > summary:focus-visible { outline-color: var(--code-focus); outline-offset: 2px; } +.guide-code .code-block:focus-visible { outline: 3px solid var(--code-focus); outline-offset: -4px; } +.guide-code .copy-button:focus-visible, .guide-code .editor-theme-select:focus-visible { outline-color: var(--code-focus); outline-offset: 2px; } +.guide-walkthrough ol { margin-top: .7rem; padding-left: 1.65rem; } +.guide-walkthrough li { padding-left: .3rem; white-space: pre-line; } +.guide-walkthrough li + li { margin-top: .75rem; } +.guide-walkthrough li::marker { color: var(--green); font-weight: 700; font-variant-numeric: tabular-nums; } +.guide-prediction-question { margin-top: .6rem; white-space: pre-line; } +.guide-prediction { margin-top: .85rem; border-top: 1px solid #cdd8c4; border-bottom: 1px solid #cdd8c4; } +.guide-prediction summary { padding: .8rem .35rem; color: var(--green); font-weight: 700; cursor: pointer; touch-action: manipulation; } +.guide-prediction summary::marker { color: #546b48; } +.guide-prediction summary:hover { background: #f1f4ed; } +.guide-prediction-answer { padding: .3rem .35rem 1rem; } +.guide-answer-label { color: var(--green); font-size: .8125rem; font-weight: 700; } +.guide-answer-label:not(:first-child) { margin-top: .85rem; } +.guide-answer-text, .guide-answer-explanation { margin-top: .25rem; white-space: pre-line; } +.guide-takeaway { padding: 1rem 0 0; margin-top: 1.6rem; } +.guide-takeaway .small-label { color: #546b48; margin-bottom: .35rem; } +.guide-takeaway > p:last-child { color: var(--green); font-weight: 650; white-space: pre-line; } +.guide-scenario-text, .guide-question, .guide-walkthrough ol, .guide-prediction-question, .guide-answer-text, .guide-answer-explanation, .guide-takeaway, .theory-section p { max-width: 60rem; } +.theory-reading-title { margin: 0 0 1.2rem; color: #53664c; } +@container learning-guide (max-width: 28rem) { + .guide-term { grid-template-columns: minmax(0, 1fr); gap: .25rem; } +} .theory-visual { container-type: inline-size; container-name: theory-visual; min-width: 0; margin: 1.65rem 0 1.8rem; padding: 1.05rem; border: 1px solid #cfdcc7; border-radius: 12px; background: #f3f6ee; } .theory-visual + .theory-section { margin-top: 1.7rem; } .visual-caption { margin-bottom: .85rem; } @@ -440,11 +511,12 @@ ul, ol { padding-left: 1.35rem; } .lesson-footer button { font-size: .625rem; min-height: 36px; padding: .35rem 0; color: #6c7e5b; text-align: left; } .lesson-footer button:last-child { margin-left: auto; text-align: right; } .lesson-footer button strong { display: block; font-size: .6875rem; color: #425e36; font-weight: 650; } -.reference-column { display: flex; flex-direction: column; gap: 1rem; min-width: 0; } +.reference-column { display: block; min-width: 0; } +.reference-content { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; padding: 8px 0 16px; } .reference-card { border: 1px solid #dce1d4; background: #f7f8f1; border-radius: 11px; padding: 1.2rem 1.05rem; min-width: 0; } .reference-card-heading { display: flex; align-items: center; gap: .5rem; } .reference-card-heading .icon { width: 16px; height: 16px; flex-basis: 16px; color: #527146; } -.reference-card h2 { font-size: .875rem; letter-spacing: -.025em; line-height: 1.55; } +.reference-card h3 { margin: 0; font-size: .875rem; letter-spacing: -.025em; line-height: 1.55; } .reference-description { color: var(--muted); font-size: .75rem; margin: .45rem 0 .7rem; line-height: 1.8; } .source-list { display: flex; flex-direction: column; } .source-link { display: block; position: relative; border-top: 1px solid #e1e6d6; padding: .85rem 1.2rem .7rem 0; text-decoration: none; overflow-wrap: anywhere; } @@ -454,7 +526,7 @@ ul, ol { padding-left: 1.35rem; } .source-link span { display: block; color: #667859; font-size: .625rem; margin-top: .25rem; } .evidence-card { background: #f5f0e6; border-color: #e4dccb; } .evidence-card > .small-label { color: #a1774e; font-size: .5rem; margin-bottom: .4rem; } -.evidence-card h2 { color: #6f553a; } +.evidence-card h3 { color: #6f553a; } .evidence-card .reference-description { color: #8b7961; } .evidence-item { padding: .9rem 0; border-top: 1px solid #e5dccc; } .evidence-company { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; margin-bottom: .35rem; font-size: .6875rem; font-weight: 650; color: #785b3e; } @@ -530,25 +602,22 @@ ul, ol { padding-left: 1.35rem; } .noscript-note { margin: 2rem; padding: 1rem; border: 1px solid #cba58c; background: #faf0e5; } @media (min-width: 1600px) { - .workspace-grid { grid-template-columns: 248px minmax(0, 1fr) 252px; gap: 2rem; } + .workspace-grid { grid-template-columns: 228px minmax(0, 1fr); gap: 24px; } } @media (max-width: 1250px) { - .site-header { padding-right: 2rem; padding-left: 2rem; } - .page-shell { padding: 0 2rem; } - .workspace-grid { grid-template-columns: 215px minmax(0, 1fr); gap: 1.4rem; } - .curriculum-sidebar { grid-row: 1 / span 2; } - .reference-column { grid-column: 2; display: grid; grid-template-columns: 1fr 1fr; align-items: start; } + .site-header { padding-right: clamp(16px, 2vw, 32px); padding-left: clamp(16px, 2vw, 32px); } + .page-shell { padding: 0 clamp(16px, 2vw, 32px); } .next-chapter { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: .7rem 1.2rem; } .next-chapter h3, .next-chapter p, .next-chapter .planned-badge { margin: 0; } - .intro { gap: 2rem; } } @media (max-width: 899px) { - .site-header { min-height: 75px; padding: .9rem 1.5rem; gap: 2rem; } - .header-navigation { gap: 1.3rem; } + .site-header { min-height: 64px; padding: min(.65rem, 11px) clamp(12px, 2vw, 24px); gap: min(.6rem, 10px) min(1rem, 16px); } + .header-navigation { gap: min(.4rem, 8px) min(1.3rem, 20px); } + .header-link { display: inline-flex; align-items: center; min-height: 44px; padding: min(.5rem, 8px) 0; } .storage-indicator { font-size: .625rem; } - .page-shell { padding: 0 1.5rem; } - .intro { grid-template-columns: minmax(0, 1fr) 290px; padding: 2.5rem 0 2rem; gap: 1.5rem; } - .intro h1 { font-size: 2.1rem; } + .page-shell { padding: 0 clamp(12px, 2vw, 24px); } + .intro { padding: .65rem 0; gap: .5rem 1rem; } + .intro h1 { font-size: 1rem; } .intro-description { font-size: .8125rem; } .intro-facts { gap: .8rem; font-size: .625rem; } .intro-facts > span + span { padding-left: .8rem; } @@ -557,8 +626,7 @@ ul, ol { padding-left: 1.35rem; } .curriculum-sidebar { display: none; position: fixed; top: 0; bottom: 0; left: 0; width: min(340px, calc(100% - 2rem)); max-height: none; padding: 1.25rem 1.4rem 2rem; background: var(--paper); overflow-y: auto; overscroll-behavior: contain; z-index: 100; } .curriculum-sidebar.is-open { display: block; } .mobile-menu-toggle, .mobile-menu-close { display: inline-flex; } - .mobile-menu-toggle { font-size: .75rem; padding-right: 0; } - .reference-column { grid-column: 1; } + .mobile-menu-toggle { font-size: .75rem; min-height: 44px; padding: min(.55rem, 8px); padding-right: 0; } .lesson-link { min-height: 58px; } .lesson-link-title { font-size: .8125rem; } .sidebar-heading h2 { font-size: 1.25rem; } @@ -572,7 +640,7 @@ ul, ol { padding-left: 1.35rem; } body.menu-open { overflow: hidden; } } @media (max-width: 650px) { - .site-header { padding: .7rem 1.1rem; flex-wrap: wrap; gap: .5rem 1rem; min-height: 68px; } + .site-header { padding: min(.7rem, 12px) min(1.1rem, 18px); flex-wrap: wrap; gap: min(.5rem, 8px) min(1rem, 16px); min-height: 68px; } .brand { font-size: 1.4rem; gap: .5rem; } .brand-mark { width: 1.9375rem; height: 2.0625rem; font-size: 1.65rem; border-radius: .4375rem; } .header-navigation { margin-left: auto; } @@ -580,10 +648,10 @@ ul, ol { padding-left: 1.35rem; } .header-link { font-size: .75rem; } .header-tools { margin-left: 0; } .storage-indicator { display: none; } - .page-shell { padding: 0 1.1rem; } - .intro { grid-template-columns: minmax(0, 1fr); padding-top: 2rem; padding-bottom: 1.6rem; gap: 1.5rem; } + .page-shell { padding: 0 12px; } + .intro { padding: 10px 0; gap: 8px 12px; } .intro .eyebrow { font-size: .5625rem; } - .intro h1 { font-size: 2.15rem; margin-top: .75rem; } + .intro h1 { font-size: 1rem; margin: 0; } .intro-description { font-size: .8125rem; margin-top: .8rem; } .intro-facts { margin-top: 1rem; font-size: .6875rem; } .journey-card { padding: 1rem 1.15rem; } @@ -598,25 +666,33 @@ ul, ol { padding-left: 1.35rem; } .workspace-kicker { font-size: .5rem; } .official-note { font-size: .5rem; gap: .3rem; } .workspace-heading > div { gap: .5rem; } - .workspace-grid { padding-top: 1rem; gap: 1rem; } - .lesson-header { padding: 1.45rem 1.25rem 1.2rem; } + .workspace-grid { padding-top: 12px; gap: 12px; } + .lesson-header { padding: 16px 12px 6px; } + .lesson-support { gap: 0 12px; padding: 0 12px 8px; } + .lesson-details > summary { padding: 10px 0; } .lesson-header h2 { font-size: 1.5rem; } .lesson-description { font-size: .875rem; } - .lesson-meta { margin-top: .9rem; } .lesson-tabs { flex-wrap: wrap; padding: 0 .8rem; gap: .15rem; overflow-x: visible; } .lesson-tabs button { padding: .9rem .5rem; font-size: .75rem; } .tab-number { display: none; } - .lesson-panel { padding: 1.35rem 1.25rem 1.5rem; } + .lesson-panel { padding: 20px 12px; } .lesson-panel h3 { font-size: 1rem; } - .learning-goals { padding: .9rem; margin-bottom: 1.4rem; } + .learning-goals { padding: .9rem min(.9rem, 14px); margin-bottom: 1.4rem; } .learning-goals li { font-size: .875rem; } .theory-section p { font-size: .9375rem; } + .guide-scenario { padding-left: 8px; } + .guide-walkthrough ol { padding-left: 24px; } + .guide-walkthrough li { padding-left: 0; } + .guide-code .code-heading { padding-right: min(.95rem, 12px); padding-left: min(.95rem, 12px); } + .guide-code-tools { padding-right: 12px; padding-left: 12px; } + .guide-walkthrough li + li { margin-top: 16px; } + .theory-section + .theory-section { margin-top: 24px; padding-top: 20px; } .theory-visual { margin-right: -.5rem; margin-left: -.5rem; padding: min(.5rem, 10px); border-radius: 9px; } - .code-block { padding: 1rem; font-size: .875rem; } - .lesson-footer { padding: .75rem 1.25rem; } - .reference-column { display: flex; } - .reference-card { padding: 1.2rem 1.25rem; } - .reference-card h2 { font-size: .9375rem; } + .code-block { padding: 1rem min(1rem, 12px); font-size: .875rem; } + .lesson-footer { padding: .75rem 12px; } + .reference-content { display: flex; flex-direction: column; align-items: stretch; } + .reference-card { padding: 16px 12px; } + .reference-card h3 { font-size: .9375rem; } .reference-description { font-size: .75rem; } .source-link strong, .evidence-title { font-size: .8125rem; } .source-link span, .evidence-disclaimer, .evidence-item .evidence-date { font-size: .625rem; } @@ -637,7 +713,7 @@ ul, ol { padding-left: 1.35rem; } .editor-tools button { font-size: .75rem; } .editor-filename { font-size: .75rem; } .editor-status { font-size: .6875rem; padding: .45rem .65rem; } - .code-settings, .syntax-legend { padding-right: .75rem; padding-left: .75rem; } + .code-settings, .syntax-legend { padding-right: min(.75rem, 12px); padding-left: min(.75rem, 12px); } .code-editor .cm-line { padding-left: .65rem; } .execution-disclosure { font-size: .625rem; } .lab-actions .button { flex: 1 1 auto; padding: .65rem .7rem; font-size: .6875rem; } @@ -650,24 +726,24 @@ ul, ol { padding-left: 1.35rem; } .dialog-actions .button { flex: 1; } } @media (max-width: 359px) { - .page-shell { padding: 0 .75rem; } - .site-header { padding-right: .75rem; padding-left: .75rem; gap: .7rem; } - .intro h1 { font-size: 1.9rem; } + .page-shell { padding: 0 min(.75rem, 12px); } + .site-header { padding-right: min(.75rem, 12px); padding-left: min(.75rem, 12px); gap: min(.7rem, 12px); } .intro-description { font-size: .75rem; } .intro-facts { gap: .65rem; } .intro-facts > span + span { padding-left: .65rem; } .official-note { display: none; } - .lesson-header { padding: 1.2rem 1rem 1rem; } - .lesson-panel { padding: 1.2rem 1rem; } + .lesson-header { padding: 16px 12px 6px; } + .lesson-panel { padding: 20px 12px; } .lesson-tabs { padding: 0 .55rem; } .lesson-tabs button { padding-right: .5rem; padding-left: .5rem; } .lesson-header h2 { font-size: 1.35rem; } .button { padding-right: .8rem; padding-left: .8rem; } .quiz-option { padding: .65rem .7rem; gap: .5rem; } .option-letter { display: none; } - .lesson-footer { padding-right: 1rem; padding-left: 1rem; } + .lesson-footer { padding-right: 12px; padding-left: 12px; } } @media (max-height: 600px) { + .intro { padding-top: 6px; padding-bottom: 6px; gap: 4px 12px; } .curriculum-sidebar { top: .75rem; } .code-editor .cm-editor, .code-editor-fallback { min-height: 9rem; max-height: 70vh; } .app-dialog { max-height: calc(100dvh - 1rem); } diff --git a/docs/assets/learning-lab.png b/docs/assets/learning-lab.png index 244eea96466ca8b810a4768008003625680a9d87..602d965d84349867ffa3ad0c527cf955497d15c4 100644 GIT binary patch literal 109654 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 DUF)0) literal 126078 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
+macOS 설치 파일 직접 빌드 + +macOS 13 이상, Node.js 22.12 이상에서 해당 CPU의 Mac으로 빌드합니다. Mac 패키징 변경이 포함된 소스를 사용합니다. + +```sh +npm ci +npm run prepare:mac +npm run check:bundle +npm run build:desktop:mac -- --arm64 +# Intel Mac에서는 --arm64 대신 --x64를 사용합니다. +``` + +공식 macOS JDK·Maven 아카이브의 고정 SHA256을 확인하고 전체 배포본을 포함합니다. `desktop-release/`에 `Java-Edu-0.4.0-mac-arm64.dmg` 또는 `Java-Edu-0.4.0-mac-x64.dmg`와 해당 ZIP을 생성합니다. 기존 스테이징 결과를 덮어쓰지 않습니다. 최종 패키지 자동 검사는 개인 학습 기록을 보호하기 위해 일회용 macOS CI에서 수행합니다. [빌드·실행 경계](docs/macos-installation.md) · [검증 기록](docs/macos-verification.md) + +
+ ### 실행 방식과 버전 | 구분 | Windows 앱 | 웹 교재 | @@ -134,11 +163,15 @@ npm run build:desktop | `npm run test:browser` | 실행 중인 로컬 서버의 모의 API 브라우저 검사 | | `npm run test:theory` | 이론 도식·원문 보존·키보드·반응형 검사 | | `npm run build` / `npm run test:worker` | 웹 Worker 빌드·산출물 검사 | +| `npm run prepare:mac` / `npm run build:desktop:mac` | Mac 공식 포함 도구 준비·네이티브 DMG/ZIP 빌드 | +| `node scripts/verify-macos-packages.mjs --arch arm64` 또는 `x64` | 일회용 Mac CI에서 최종 패키지·실제 앱 실행·Java/Spring 제출·재시작 저장 검사 | 실무 검사는 `JAVA_HOME`·`MAVEN_HOME` 설정이 필요합니다. 기본 검사는 Redis/Kafka를 제외합니다. 브라우저 검사는 실행 제공자의 실제 동작 검증과 구분합니다. 2026-10-13 기준, v0.3.1 앱 소스 `6ce0428`의 [CI 12작업](https://github.com/jbsim999/java-edu/actions/runs/38338837092)이 모두 성공했습니다. 독립 검수에서 읽기 안내 **24/24**, 기존 기능 **49/49**, 도식 **19/19**, 정적 검사 **5/5**가 통과했고 새 EXE·ZIP의 전체 파일과 포함 도구 604개를 대조했습니다. 사용 중인 앱과 학습 기록을 보존하기 위해 **신버전의 실제 설치·기동 검사는 미실행**입니다. 이전 v0.3.0 설치·제거 검증과 구분합니다. [이번 검증 범위](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/readability-release-qa-report.md) · [검증 기록](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/verification.md) +Mac 패키징 변경의 로컬 검사에서는 콘텐츠40 PASS/실제 원격 제공자1 SKIP, Windows 실행24 PASS/Mac·Unix4 SKIP와 독립 코드 검토가 통과했습니다. **실제 Mac의 DMG·ZIP 실행 결과는 아직 없습니다.** 두 CPU의 실제 패키지에서 포함 도구·Java8/21 대상 제출·Spring/H2 정답/미완성 판정·재시작 저장을 확인한 뒤 실행 결과와 설치 파일 링크를 갱신합니다. [Mac 검증 기록](docs/macos-verification.md) + ## 문서와 로드맵 | 찾는 내용 | 문서 | @@ -151,6 +184,7 @@ npm run build:desktop | 구조와 실행 경계 | [아키텍처](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/architecture.md) | | 통합 ZIP·설치 앱 검증 | [번들 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-desktop-qa-report.md) · [이전 0.2 설치 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installed-desktop-qa-report.md) | | 현재 작업·출시 상태 | [마일스톤](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/milestones.md) · [개발 인계](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/development-handoff.md) | +| macOS 설치·빌드·검증 | [Mac 안내](docs/macos-installation.md) · [Mac 검증 상태](docs/macos-verification.md) | - [x] Java 코어 12수업과 직접 코딩 과제 - [x] Java 문법 강조·3테마·다중 파일 편집 @@ -162,6 +196,7 @@ npm run build:desktop - [x] Java·Maven 포함 Windows EXE와 전체 ZIP 패키징 - [x] [Windows v0.3.1 통합 프리뷰 릴리스](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) - [ ] 선택 과정의 Redis/Kafka 실제 연동 검증 +- [ ] macOS Intel·Apple Silicon 최종 DMG/ZIP 빌드와 실제 앱 실행 검증 ## 기여와 문의 diff --git a/desktop/build-config.cjs b/desktop/build-config.cjs index c6b90d0..181e0a0 100644 --- a/desktop/build-config.cjs +++ b/desktop/build-config.cjs @@ -1,5 +1,8 @@ const path = require('node:path'); const { loadBundledRuntime } = require('./bundled-runtime.cjs'); +const architectures = ['ia32', 'x64', 'armv7l', 'arm64', 'universal']; +const targetOptions = (context) => ({ expectedPlatform: context.electronPlatformName, expectedArch: architectures[context.arch] }); +const packagedBundle = (context) => path.join(context.appOutDir, ...(context.electronPlatformName === 'darwin' ? ['Java Edu.app', 'Contents', 'Resources'] : ['resources']), 'toolchains'); module.exports = { appId: 'kr.javaedu.desktop', @@ -7,11 +10,24 @@ module.exports = { asar: true, asarUnpack: ['desktop/job-runner.ps1'], extraResources: [{ from: 'work/desktop-bundle', to: 'toolchains', filter: ['**/*'] }], - beforePack: async () => { await loadBundledRuntime(path.join(__dirname, '..', 'work', 'desktop-bundle')); }, - afterPack: async ({ appOutDir }) => { await loadBundledRuntime(path.join(appOutDir, 'resources', 'toolchains')); }, + beforePack: async (context) => { await loadBundledRuntime(path.join(__dirname, '..', 'work', 'desktop-bundle'), targetOptions(context)); }, + afterPack: async (context) => { await loadBundledRuntime(packagedBundle(context), targetOptions(context)); }, + afterSign: async (context) => { if (context.electronPlatformName === 'darwin') await loadBundledRuntime(packagedBundle(context), targetOptions(context)); }, directories: { output: 'desktop-release' }, files: ['desktop/**', 'server/**', 'dist/**', '!dist/server/**', '!dist/.openai/**', 'curriculum/professional/**', 'package.json', '!**/target/**', '!**/node_modules/**'], win: { target: [{ target: 'nsis', arch: ['x64'] }, { target: 'zip', arch: ['x64'] }], artifactName: 'Java-Edu-${version}-${arch}.${ext}', signAndEditExecutable: false }, + mac: { + target: ['dmg', 'zip'], + artifactName: 'Java-Edu-${version}-mac-${arch}.${ext}', + category: 'public.app-category.education', + minimumSystemVersion: '13.0.0', + identity: '-', + hardenedRuntime: false, + notarize: false, + // Preserve every vendor JDK byte and its original signature. + signIgnore: ['Contents/Resources/toolchains(?:/|$)'], + }, + dmg: { sign: false, title: 'Java Edu ${version}', contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: 'link', path: '/Applications' }] }, // Preserve vendor ARM64 files: the bundled NSIS 7z decoder predates the ARM64 codec. nsis: { artifactName: 'Java-Edu-Setup-${version}-${arch}.${ext}', differentialPackage: false, useZip: true, oneClick: false, perMachine: false, allowElevation: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true, createStartMenuShortcut: true, runAfterFinish: false, deleteAppDataOnUninstall: false }, publish: null, diff --git a/desktop/bundled-runtime.cjs b/desktop/bundled-runtime.cjs index bde7e94..ad74e67 100644 --- a/desktop/bundled-runtime.cjs +++ b/desktop/bundled-runtime.cjs @@ -1,6 +1,6 @@ const { createHash } = require('node:crypto'); const { createReadStream } = require('node:fs'); -const { lstat, readFile, readdir, realpath } = require('node:fs/promises'); +const { lstat, readFile, readlink, readdir, realpath } = require('node:fs/promises'); const path = require('node:path'); const JAVA_VERSIONS = [8, 11, 17, 21]; @@ -51,8 +51,14 @@ async function sha256File(file) { return hash.digest('hex'); } -/** No links or other special files may redirect packaged toolchains outside their root. */ -async function inventoryTree(root, { excludeManifest = false } = {}) { +function inside(root, child) { + const relative = path.relative(root, child); + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +/** Preserve vendor links verbatim, but never follow links while enumerating a tree. */ +async function inventoryTree(root, { excludeManifest = false, allowSymlinks = false, includeExecutable = false, linkRoots = [root] } = {}) { + const canonicalRoot = await realpath(root); const files = []; async function visit(directory, prefix = '') { for (const item of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en'))) { @@ -60,11 +66,21 @@ async function inventoryTree(root, { excludeManifest = false } = {}) { if (excludeManifest && relative === 'manifest.json') continue; const file = path.join(directory, item.name); const info = await lstat(file); - if (info.isSymbolicLink()) throw failure('linked distribution file'); - if (info.isDirectory()) await visit(file, relative); + if (info.isSymbolicLink()) { + if (!allowSymlinks) throw failure('linked distribution file'); + const target = await readlink(file); + if (!target || path.isAbsolute(target) || path.win32.isAbsolute(target) || /[\\\x00-\x1f]/.test(target)) throw failure('invalid distribution link'); + const resolved = await realpath(file).catch(() => { throw failure('missing or cyclic distribution link'); }); + const owner = linkRoots.find((candidate) => inside(candidate, file)); + if (!owner || !inside(canonicalRoot, resolved) || !inside(owner, resolved)) throw failure('escaping distribution link'); + const targetInfo = await lstat(resolved); + if (!targetInfo.isFile() && !targetInfo.isDirectory()) throw failure('unsupported distribution link'); + if (files.length >= MAX_FILES) throw failure('too many distribution files'); + files.push({ path: relative, type: 'symlink', target, size: Buffer.byteLength(target), sha256: createHash('sha256').update(target).digest('hex') }); + } else if (info.isDirectory()) await visit(file, relative); else if (info.isFile()) { if (files.length >= MAX_FILES) throw failure('too many distribution files'); - files.push({ path: relative, size: info.size, sha256: await sha256File(file) }); + files.push({ path: relative, size: info.size, sha256: await sha256File(file), ...(includeExecutable ? { executable: (info.mode & 0o111) !== 0 } : {}) }); } else throw failure('unsupported distribution file'); } } @@ -73,7 +89,7 @@ async function inventoryTree(root, { excludeManifest = false } = {}) { } /** Check all packaged bytes before using metadata. The manifest is integrity data, not a signature. */ -async function inspectBundledRuntime(directory, { required = true } = {}) { +async function inspectBundledRuntime(directory, { required = true, expectedPlatform = process.platform, expectedArch = process.arch } = {}) { if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw failure('absolute bundle root required'); const rootInfo = await lstat(directory).catch((error) => { if (!required && error.code === 'ENOENT') return null; throw error; }); if (!rootInfo) return null; @@ -84,14 +100,18 @@ async function inspectBundledRuntime(directory, { required = true } = {}) { if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_MANIFEST_BYTES) throw failure('invalid manifest file'); let manifest; try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); } catch { throw failure('invalid manifest JSON'); } - if (manifest.schemaVersion !== 1 || manifest.platform !== 'win32' || manifest.arch !== 'x64' + const windows = manifest.schemaVersion === 1 && manifest.platform === 'win32' && manifest.arch === 'x64'; + const mac = manifest.schemaVersion === 2 && manifest.platform === 'darwin' && ['x64', 'arm64'].includes(manifest.arch); + if ((!windows && !mac) || manifest.platform !== expectedPlatform || manifest.arch !== expectedArch || !Array.isArray(manifest.jdks) || !manifest.jdks.length || manifest.jdks.length > JAVA_VERSIONS.length || !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > MAX_FILES || !SHA256.test(manifest.id) || manifest.id !== manifestIdentity(manifest)) throw failure('unsupported manifest'); const versions = new Set(); for (const jdk of manifest.jdks) { validateProvenance(jdk); - if (!JAVA_VERSIONS.includes(jdk.major) || versions.has(jdk.major) || jdk.home !== `jdks/${jdk.major}`) throw failure('invalid JDK layout'); + if (!JAVA_VERSIONS.includes(jdk.major) || versions.has(jdk.major) + || jdk.home !== `jdks/${jdk.major}${mac ? '/Contents/Home' : ''}` + || (mac ? jdk.distributionRoot !== `jdks/${jdk.major}` : jdk.distributionRoot !== undefined)) throw failure('invalid JDK layout'); versions.add(jdk.major); } if (!versions.has(21)) throw failure('default Java 21 missing'); @@ -102,20 +122,28 @@ async function inspectBundledRuntime(directory, { required = true } = {}) { for (const file of manifest.files) { const name = safeRelative(file.path); if (!SHA256.test(file.sha256) || !Number.isSafeInteger(file.size) || file.size < 0 || expected.has(name) || insensitive.has(name.toLowerCase()) - || !['THIRD-PARTY-NOTICES.txt', ...manifest.jdks.map((jdk) => `${jdk.home}/`), 'maven/'].some((prefix) => prefix.endsWith('/') ? name.startsWith(prefix) : name === prefix)) throw failure('invalid file inventory'); + || !['THIRD-PARTY-NOTICES.txt', ...manifest.jdks.map((jdk) => `${jdk.distributionRoot || jdk.home}/`), 'maven/'].some((prefix) => prefix.endsWith('/') ? name.startsWith(prefix) : name === prefix)) throw failure('invalid file inventory'); + if (mac && (file.type === 'symlink' ? typeof file.target !== 'string' || file.executable !== undefined : file.type !== undefined || typeof file.executable !== 'boolean')) throw failure('invalid Unix file inventory'); expected.set(name, file); insensitive.add(name.toLowerCase()); } for (const jdk of manifest.jdks) { - for (const name of ['bin/java.exe', 'bin/javac.exe', 'release', ...jdk.licenseFiles]) if (!expected.has(`${jdk.home}/${name}`)) throw failure('missing JDK or license file'); + for (const name of [mac ? 'bin/java' : 'bin/java.exe', mac ? 'bin/javac' : 'bin/javac.exe', 'release', ...jdk.licenseFiles]) if (!expected.has(`${jdk.home}/${name}`)) throw failure('missing JDK or license file'); + if (mac) for (const name of ['bin/java', 'bin/javac']) if (expected.get(`${jdk.home}/${name}`).executable !== true) throw failure('JDK executable permission missing'); } for (const name of ['bin/m2.conf', ...manifest.maven.licenseFiles]) if (!expected.has(`maven/${name}`)) throw failure('missing Maven or license file'); if (!expected.has('THIRD-PARTY-NOTICES.txt') || !manifest.files.some((file) => /^maven\/boot\/plexus-classworlds-[A-Za-z0-9.-]+\.jar$/.test(file.path)) || !expected.has(`maven/lib/maven-core-${manifest.maven.version}.jar`)) throw failure('incomplete Maven distribution'); - const actual = await inventoryTree(root, { excludeManifest: true }); - if (actual.length !== expected.size || actual.some((file) => expected.get(file.path)?.sha256 !== file.sha256 || expected.get(file.path)?.size !== file.size)) throw failure('distribution integrity mismatch'); + const actual = await inventoryTree(root, { excludeManifest: true, allowSymlinks: mac, includeExecutable: mac, + linkRoots: [...manifest.jdks.map((jdk) => path.join(root, jdk.distributionRoot || jdk.home)), path.join(root, 'maven')] }); + if (actual.length !== expected.size || actual.some((file) => { + const listed = expected.get(file.path); + return listed?.sha256 !== file.sha256 || listed?.size !== file.size || (mac && (listed.type !== file.type || listed.target !== file.target || listed.executable !== file.executable)); + })) throw failure('distribution integrity mismatch'); return { id: manifest.id, + platform: manifest.platform, + arch: manifest.arch, root, jdks: manifest.jdks.map((jdk) => ({ version: jdk.major, home: path.join(root, ...jdk.home.split('/')), description: `${jdk.distribution} ${jdk.version} · 앱 포함`, distribution: jdk.distribution, distributionVersion: jdk.version })), maven: { home: path.join(root, 'maven'), version: manifest.maven.version }, diff --git a/desktop/toolchain-provenance.darwin-arm64.json b/desktop/toolchain-provenance.darwin-arm64.json new file mode 100644 index 0000000..cea873f --- /dev/null +++ b/desktop/toolchain-provenance.darwin-arm64.json @@ -0,0 +1,48 @@ +{ + "checkedAt": "2026-10-13", + "platform": "darwin", + "arch": "arm64", + "jdks": [ + { + "major": 21, + "distribution": "Eclipse Temurin", + "version": "21.0.12.1+1", + "sourceUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.12.1_1.tar.gz", + "archiveSha256": "3623232f33a9c3baadf304480b2535f9a3cba8a58d42ecbb438ba267315d9998", + "license": "GPL-2.0-with-classpath-exception", + "licenseFiles": [ + "legal/java.base/LICENSE", + "legal/java.base/ASSEMBLY_EXCEPTION", + "NOTICE" + ], + "sourceCodeUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", + "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", + "redistributionSourceFiles": [ + "OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", + "Third-Party-Sources-0.3.0.zip/temurin-build-e6ba7dec3d07654074559310376a3ae89da5f4ac.tar.gz" + ] + } + ], + "maven": { + "distribution": "Apache Maven", + "version": "3.10.0", + "sourceUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/binaries/apache-maven-3.10.0-bin.zip", + "archiveSha256": "1f6d9909266510f039f59aa0e13dcd2c66da85f043e56276e41f2918f8bddaff", + "license": "Apache-2.0 and bundled third-party licenses", + "licenseFiles": [ + "LICENSE", + "NOTICE", + "lib/org.eclipse.sisu.inject.license", + "lib/org.eclipse.sisu.plexus.license", + "lib/javax.annotation-api.license" + ], + "sourceCodeUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/source/apache-maven-3.10.0-src.zip", + "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", + "redistributionSourceFiles": [ + "Third-Party-Sources-0.3.0.zip/apache-maven-3.10.0-src.zip", + "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.inject-1.1.0-sources.jar", + "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.plexus-1.1.0-sources.jar", + "Third-Party-Sources-0.3.0.zip/javax.annotation-api-1.3.2-sources.jar" + ] + } +} diff --git a/desktop/toolchain-provenance.darwin-x64.json b/desktop/toolchain-provenance.darwin-x64.json new file mode 100644 index 0000000..55bb41b --- /dev/null +++ b/desktop/toolchain-provenance.darwin-x64.json @@ -0,0 +1,48 @@ +{ + "checkedAt": "2026-10-13", + "platform": "darwin", + "arch": "x64", + "jdks": [ + { + "major": 21, + "distribution": "Eclipse Temurin", + "version": "21.0.12.1+1", + "sourceUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_mac_hotspot_21.0.12.1_1.tar.gz", + "archiveSha256": "44db0f08196daf19a47f90d13388b0c943b67663cb537f998fe29e836fa842ce", + "license": "GPL-2.0-with-classpath-exception", + "licenseFiles": [ + "legal/java.base/LICENSE", + "legal/java.base/ASSEMBLY_EXCEPTION", + "NOTICE" + ], + "sourceCodeUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", + "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", + "redistributionSourceFiles": [ + "OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", + "Third-Party-Sources-0.3.0.zip/temurin-build-e6ba7dec3d07654074559310376a3ae89da5f4ac.tar.gz" + ] + } + ], + "maven": { + "distribution": "Apache Maven", + "version": "3.10.0", + "sourceUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/binaries/apache-maven-3.10.0-bin.zip", + "archiveSha256": "1f6d9909266510f039f59aa0e13dcd2c66da85f043e56276e41f2918f8bddaff", + "license": "Apache-2.0 and bundled third-party licenses", + "licenseFiles": [ + "LICENSE", + "NOTICE", + "lib/org.eclipse.sisu.inject.license", + "lib/org.eclipse.sisu.plexus.license", + "lib/javax.annotation-api.license" + ], + "sourceCodeUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/source/apache-maven-3.10.0-src.zip", + "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", + "redistributionSourceFiles": [ + "Third-Party-Sources-0.3.0.zip/apache-maven-3.10.0-src.zip", + "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.inject-1.1.0-sources.jar", + "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.plexus-1.1.0-sources.jar", + "Third-Party-Sources-0.3.0.zip/javax.annotation-api-1.3.2-sources.jar" + ] + } +} diff --git a/docs/macos-installation.md b/docs/macos-installation.md new file mode 100644 index 0000000..67595c2 --- /dev/null +++ b/docs/macos-installation.md @@ -0,0 +1,42 @@ +# macOS 설치와 패키징 + +## 지원 환경 + +macOS 13 Ventura 이상, Intel x64 또는 Apple Silicon arm64. Electron44는 macOS12를 지원하지 않는다. Apple 메뉴 → 이 Mac에 관하여에서 **칩**이 Apple M 계열이면 arm64, **프로세서**가 Intel이면 x64를 선택한다. 두 CPU의 JDK를 섞거나 Windows EXE를 Mac에서 실행하지 않는다. + +패키지는 `Java-Edu-0.4.0-mac-arm64.dmg` / `Java-Edu-0.4.0-mac-x64.dmg`와 같은 CPU별 DMG·ZIP이다. 현재 실제 출시 여부와 검사 결과는 [Mac 검증 기록](macos-verification.md)을 따른다. 빌드 명령이 존재하는 것만으로 다운로드 가능·설치 성공을 뜻하지 않는다. + +## 설치와 첫 실행 + +1. 자신의 CPU에 맞는 DMG를 열고 **Java Edu**를 **Applications**로 끌어 놓는다. 복사가 끝나면 디스크 이미지를 추출한다. +2. 응용 프로그램에서 **Java Edu**를 연다. ZIP을 선택했다면 전체 `Java Edu.app`을 해제해 응용 프로그램으로 옮긴다. `.app` 내부의 실행 파일·JDK만 분리하지 않는다. +3. 처음 코드 실습에서 실행 동의 내용을 읽고 동의한다. Java21·Maven3.10.0은 앱에 포함되어 있어 별도로 설치하거나 시스템 PATH를 바꾸지 않는다. Spring 실습은 처음 **환경 준비**할 때 인터넷이 필요하다. + +프리뷰는 ad-hoc 서명이며 Apple Developer ID 서명·공증은 제공하지 않는다. 인터넷에서 내려받은 앱의 첫 실행이 차단되면 출처와 체크섬을 확인한 후 **시스템 설정 → 개인정보 보호 및 보안 → 그래도 열기**로 해당 앱을 허용할 수 있다. macOS 보안 기능 전체를 끄거나 quarantine을 일괄 제거하지 않는다. 조직 관리 정책에 따라 허용되지 않을 수 있다. CI의 실행 검증과 사용자의 실제 다운로드/Gatekeeper 흐름은 별개다. + +학습 기록은 해당 Mac의 `~/Library/Application Support/java-edu`에 저장한다. Windows·웹과 자동 동기화하지 않는다. 앱을 지워도 사용자 기록을 자동 삭제하는 제거 스크립트는 제공하지 않는다. 기존 앱 교체 전에는 앱을 정상 종료한다. + +## Mac에서 직접 빌드 + +Node22.12 이상에서 저장소 의존성을 설치한 후 해당 CPU의 Mac에서 아래 순서로 실행한다. 원본 아카이브·스테이징이 이미 존재하면 덮어쓰지 않고 중단한다. 재빌드는 기존 결과를 먼저 별도로 보존한다. + +```sh +npm ci +npm run prepare:mac +npm run check:bundle +npm run build:desktop:mac -- --arm64 +# Intel에서는 마지막 인수를 --x64로 선택한다. +``` + +최종 패키지 자동 검사는 개인 Mac의 기본 학습 기록을 보호하기 위해 **GitHub Actions의 일회용 macOS 러너에서만** 실행한다. 로컬 빌드 뒤 기존 개인 데이터로 검사 스크립트를 실행하거나 CI 전용 보호 조건을 우회하지 않는다. CI에서는 `node scripts/verify-macos-packages.mjs --arch arm64` 또는 `--arch x64`를 사용한다. + +`prepare:mac`은 공식 Temurin21.0.12.1+1 macOS 아카이브와 Maven3.10.0 ZIP의 고정 SHA256을 대조한다. `.jdk/Contents` 전체와 원본 공급자 서명을 보존한다. 런타임 경로는 앱 내부 `Contents/Resources/toolchains/jdks/21/Contents/Home`이다. 공급자 고지·대응 소스 위치는 포함된 `THIRD-PARTY-NOTICES.txt`와 기존 [번들 계약](bundled-runtime.md)을 따른다. + +macOS 프로세스 취소는 프로세스 그룹·시간·출력 한도와 JVM heap 제한을 사용한다. Windows Job Object의 전체 메모리·프로세스 수 제한 또는 강제 앱 종료 뒤 정리를 macOS에도 보장하지 않는다. 개인 학습 IDE이며 OS 보안 샌드박스가 아니다. + +## 공식 근거 + +- [Electron44의 macOS 최소 버전](https://www.electronjs.org/blog/electron-44-0) +- [electron-builder26 macOS 빌드](https://www.electron.build/v26/docs/mac/) +- [GitHub의 Intel·Apple Silicon macOS 러너](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) +- [Apple의 미공증 앱 실행 안내](https://support.apple.com/102445) diff --git a/docs/macos-packaging-plan.md b/docs/macos-packaging-plan.md new file mode 100644 index 0000000..f476b78 --- /dev/null +++ b/docs/macos-packaging-plan.md @@ -0,0 +1,22 @@ +# macOS 통합 패키징 계획 — 2026-10-13 + +사용자 요청: 기존 Windows 학습 앱을 macOS에서도 설치하고 실행하도록 추가 패키징한다. 기준 소스는 `f9d1a51f862f29c4350dd2bc8768da80064d0acd`이며 기존 교재·Windows 패키징·학습 기록 계약을 보존한다. + +## 구현 기준 + +- Intel x64와 Apple Silicon arm64를 각각 해당 CPU의 macOS CI에서 빌드한다. universal/Rosetta 의존 패키지는 만들지 않는다. +- 각 DMG와 ZIP에 Electron, 정확히 고정한 Temurin JDK21 macOS 배포본 전체, Maven3.10.0을 포함한다. 학습자에게 별도 JDK/Maven 설치를 요구하지 않는다. +- macOS JDK의 전체 `.jdk/Contents` 구조와 원본 서명·라이선스·상대 링크를 보존한다. Java home은 `Contents/Home`이다. 링크는 같은 배포본 내부에서만 해석되며 파일 SHA256·링크 문자열·실행 비트를 검증한다. +- 기존 Windows schema1 번들은 계속 지원한다. Mac schema2는 OS·CPU 불일치를 거절한다. 빌드 도구만 명시적으로 대상 플랫폼을 전달한다. +- 기존 로컬 엔진의 동의·loopback 인증·고정 Maven 호출·파일 편집 제한·학습 저장을 유지한다. macOS는 프로세스 그룹 취소·시간·출력 제한 및 JVM heap 제한을 사용한다. Windows Job Object의 전체 메모리/프로세스 수 제한이나 강제 앱 종료 후 자손 종료를 macOS에서도 보장한다고 주장하지 않는다. +- 앱에는 ad-hoc 서명을 적용하며 포함된 공급자 JDK 서명을 변경하지 않는다. Apple Developer ID 인증서와 공증 자격증명이 없는 상태에서 공증 완료라고 표시하지 않는다. Gatekeeper 최초 다운로드 흐름은 CI 실행과 별도 확인 범위다. + +## 완료 조건과 근거 + +1. Windows 회귀와 Mac 번들/경로/링크/CPU 계약 검사 성공. +2. 두 네이티브 Mac CI에서 공식 아카이브 SHA256 확인 → 스테이징 → DMG/ZIP 빌드. +3. 최종 DMG를 읽기 전용으로 마운트하여 격리된 Applications 폴더에 복사, 최종 ZIP은 별도 한글·공백·특수문자 경로에 해제. 각 실제 앱의 전체 파일/링크·번들 무결성과 소스 ASAR 대조. +4. 호스트 도구를 등록하지 않은 실제 패키지 실행, 명시적 동의, Java8/21 컴파일 대상 제출, Spring/H2 준비·정답 제출·미완성 실패, 재시작 후 저장 복원, 실제 화면 캡처. +5. 구현과 다른 읽기 전용 문맥에서 독립 검수. CI·패키지 해시·미검증을 출시 기록에 구분. + +빌드/실행/패키징은 이번 요청 범위다. 기존 앱과 개인 학습 데이터·저장소 history는 보존한다. 실제 검증이 실패하면 해결하거나 미완료로 기록한다. diff --git a/docs/macos-verification.md b/docs/macos-verification.md new file mode 100644 index 0000000..9dd5f3a --- /dev/null +++ b/docs/macos-verification.md @@ -0,0 +1,37 @@ +# macOS 패키지 검증 — v0.4.0 + +2026-10-13. 기준 소스 `f9d1a51`에 macOS Intel x64·Apple Silicon arm64 패키징을 추가한다. 기존 Windows v0.3.1 릴리스는 변경하지 않는다. + +현재 구현과 실제 실행의 상태를 분리한다. + +| 검사 | 현재 근거 | +| --- | --- | +| Windows 로컬 계약 | 최종 Node24.19 검사 24 PASS / macOS·Unix 4 SKIP; `work/macos-local-review/windows-local.log`, 실패 없음 | +| 콘텐츠·API·편집기 정합성 | Node40 PASS / 실제 원격 제공자1 SKIP | +| 웹 Worker | 빌드 및 CSP·인증·정적 파일 검사 PASS | +| electron-builder26 설정 | 실제 설치된26.15.3의 설정 schema 검사 PASS | +| macOS 네이티브 모드·링크·실행 | CI에서 확인 예정 | +| 최종 DMG·ZIP 앱 실행 | CI에서 확인 예정 | +| 독립 코드 검토 | 최종 소스 PASS, 남은 정적 차단 없음. 별도 검수 문맥에서 loader8 PASS/3 SKIP·Windows 감독1 PASS/1 SKIP·서명 제외 helper 정적 확인 | +| Apple Developer ID·공증 | 자격증명 미확인, 적용하지 않음 | +| 실제 사용자 다운로드/Gatekeeper | 미검증 | + +`.github/workflows/package-macos.yml`은 네이티브 macos-15-intel / macos-15에서 공식 아카이브 확인, 포함 도구 실행, DMG·ZIP 빌드, 실제 최종 앱 검사와 화면 캡처를 수행한다. 정답 제출과 미완성 실패를 분리하고, 실제 실행하지 않은 검사를 성공으로 표시하지 않는다. ephemeral CI의 기본 사용자 데이터만 새로 생성하고 소유권을 확인한 검사 데이터는 증거 폴더로 보존한다. 기존 사용자 데이터가 있으면 실행을 중단한다. + +실제 CI ID·소스 SHA·패키지 SHA256·독립 검토 결과는 실행 후 이 문서에 추가한다. 소스/설정이 존재하는 것과 설치 파일 제공·실제 Mac 실행 성공은 별개다. + +독립 검수에서 Unix 정상 종료 후 자손이 stdout/stderr를 상속하면 `close`가 늦어져 timeout으로 오인될 수 있음을 발견했다. `exit` 시점에 남은 프로세스 그룹을 정리하고 상속 파이프 회귀 검사를 추가했다. Windows 실제 감독 회귀와 최종 로컬 검사는 PASS이며 Unix 실제 실행은 Mac CI에서 확인한다. 개발자 빌드 안내의 CI 전용 QA 명령도 별도 단락으로 정정했다. + +현재 사용자는 검증용 `codex/macos-packaging` 브랜치의 선행 커밋·푸시와 GitHub Actions의 Intel·Apple Silicon 검증을 명시적으로 승인했다. 이전 선행 푸시 보류는 해제됐다. 두 환경의 실제 최종 패키지 실행 검증을 확인한 뒤 README에 결과와 근거를 반영한다. README 설치 위치·기존 Windows 다운로드 안내 회귀 검사는 1 PASS, 문서 diff 검사 PASS다. 병합·릴리스 게시는 이 승인에 포함되지 않는다. + +## JDK 서명과 매니페스트 불일치 점검 + +사용자는 별도 Mac이 없음을 확인하고 `distribution integrity mismatch` 사례의 발생 가능성을 점검하도록 요청했다. 첨부 문구의 570개 중 72개 파일 불일치 수치는 실제 설치 파일·매니페스트·검사 로그를 제공받지 않았으므로 확인된 재현 결과가 아니다. JDK 네이티브 실행 파일과 dylib만 크기가 달라지고 Maven이 정상인 패턴은 재서명 가설과 부합하지만 원인 확정 근거는 아니다. [Apple 코드 서명 문서](https://developer.apple.com/library/archive/technotes/tn2206/)에 따르면 서명은 실행 파일을 변경한다. + +현재 설정은 전체 `Contents/Resources/toolchains`를 `mac.signIgnore`로 제외한다. 실제 고정된 electron-builder26.15.3의 `MacTargetHelper.buildSignOptions()`와 설치된 `@electron/osx-sign.signApp()`를 실행한 회귀 검사에서 JDK Java·javac·dylib 및 Maven 경로는 서명 명령에서 제외되고 Electron과 앱 본체는 서명 대상으로 유지됐다. 제외 설정을 제거한 대조 조건에서는 JDK도 서명 대상이었다. 서명 명령에는 `--deep`이 없으며 `--deep`은 검증 명령에만 있다. 이 검사는 네이티브 파일 탐색과 외부 명령을 대체한 **명령 선택 검사**이며 실제 Mac 서명 성공 근거가 아니다. + +빌드 도구의 실제 호출 순서는 리소스 복사 → `afterPack` → 서명 → `afterSign` → DMG/ZIP 생성이다. 두 훅에서 포함 도구의 전체 크기·SHA256·모드·링크를 검사한다. 변경을 발견하면 빌드를 중단하며 매니페스트를 다시 작성하지 않는다. signer의 디렉터리 탐색은 제외 적용 전에 `.cstemp` 임시 파일을 지울 수 있으므로 `signIgnore`만으로 모든 파일 변경이 불가능하다고 주장하지 않는다. 이런 변경도 후속 무결성 검사 대상이다. 최종 패키지 QA는 DMG·ZIP에서 추출한 도구 전체를 원본 스테이징과 다시 비교한다. + +Node24.19 Windows의 관련 검사 결과는 **9 PASS / 4 SKIP / 0 FAIL**이다. `tests/local/mac-signing.test.mjs`, `tests/local/mac-bundled-runtime.test.mjs`, `tests/local/bundled-runtime.test.mjs`를 실행했다. 파일 크기가 달라지는 변조뿐 아니라 크기가 같은 변조도 공유 로더에서 거부함을 확인했다. Mac 스키마의 `afterSign` 훅에 대한 실제 파일 변조 회귀 검사는 추가했지만 Windows에는 Unix 실행 권한이 없어 SKIP이며 native CI에서 실행해야 한다. 실제 macOS 코드 서명·설치·실행은 여전히 미검증이다. + +추가 검사에 대한 별도 읽기 전용 검토에서도 수정이 필요한 결함은 없었고 독립 실행 결과는 9 PASS / 4 SKIP / 0 FAIL이었다. 명령 선택 검사에서 provisioning·자동 entitlement 처리는 비활성화했으므로 그 단계까지 실제 검증한 것으로 해석하지 않는다. diff --git a/package-lock.json b/package-lock.json index 5e17703..39775ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "java-edu", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "java-edu", - "version": "0.3.1", + "version": "0.4.0", "dependencies": { "@codemirror/commands": "6.11.1", "@codemirror/lang-java": "6.0.2", diff --git a/package.json b/package.json index f9ee455..bd16001 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "java-edu", - "version": "0.3.1", + "version": "0.4.0", "main": "desktop/main.cjs", "private": true, "type": "module", @@ -23,7 +23,10 @@ "test:browser": "node tests/browser.mjs", "test:theory": "node tests/theory-visuals.mjs", "test:readability": "node tests/readability.mjs", - "check": "node --check dist/app.js && node --check dist/data/curriculum.js && node --check dist/data/job-evidence.js && node --check dist/data/lesson-visuals.js && npm test" + "check": "node --check dist/app.js && node --check dist/data/curriculum.js && node --check dist/data/job-evidence.js && node --check dist/data/lesson-visuals.js && npm test", + "build:desktop:mac": "node scripts/build-editor.mjs && node scripts/build-professional.mjs && electron-builder --config desktop/build-config.cjs --mac --publish never", + "prepare:mac": "node scripts/prepare-macos-runtime.mjs", + "test:mac:packages": "node scripts/verify-macos-packages.mjs" }, "engines": { "node": ">=22" diff --git a/scripts/bundle-runtime.mjs b/scripts/bundle-runtime.mjs index 0b50252..a917968 100644 --- a/scripts/bundle-runtime.mjs +++ b/scripts/bundle-runtime.mjs @@ -12,8 +12,13 @@ const { values } = parseArgs({ options: { check: { type: 'boolean', default: false }, metadata: { type: 'string' }, maven: { type: 'string' }, + platform: { type: 'string', default: process.platform }, arch: { type: 'string', default: process.arch }, jdk8: { type: 'string' }, jdk11: { type: 'string' }, jdk17: { type: 'string' }, jdk21: { type: 'string' }, }, strict: true }); +const mac = values.platform === 'darwin'; +if (!((values.platform === 'win32' && values.arch === 'x64') || (mac && ['x64', 'arm64'].includes(values.arch)))) throw new Error('지원 대상은 Windows x64 또는 macOS x64/arm64입니다.'); +const inventoryOptions = { allowSymlinks: mac, includeExecutable: mac }; +const targetOptions = { expectedPlatform: values.platform, expectedArch: values.arch }; function inside(root, child) { const relative = path.relative(root, child); @@ -36,15 +41,15 @@ async function sourceDirectory(value) { } async function copyDistribution(source, target) { - const originals = await inventoryTree(source); // Reject symlinks before copying a vendor distribution. - await cp(source, target, { recursive: true, dereference: false, force: false, errorOnExist: true }); - const copied = await inventoryTree(target); + const originals = await inventoryTree(source, inventoryOptions); + await cp(source, target, { recursive: true, dereference: false, verbatimSymlinks: true, force: false, errorOnExist: true }); + const copied = await inventoryTree(target, inventoryOptions); if (JSON.stringify(originals) !== JSON.stringify(copied)) throw new Error('복사 전후 배포본 파일이 일치하지 않습니다.'); } if (values.check) { - if (Object.entries(values).some(([key, value]) => key !== 'check' && value !== undefined)) throw new Error('--check는 다른 입력과 함께 사용할 수 없습니다.'); - const bundle = await loadBundledRuntime(destination); + if (Object.entries(values).some(([key, value]) => !['check', 'platform', 'arch'].includes(key) && value !== undefined)) throw new Error('--check에는 대상 --platform/--arch만 함께 사용할 수 있습니다.'); + const bundle = await loadBundledRuntime(destination, targetOptions); console.log(JSON.stringify({ status: 'passed', directory: destination, id: bundle.id, javaVersions: bundle.jdks.map((jdk) => jdk.version), mavenVersion: bundle.maven.version, files: bundle.fileCount, bytes: bundle.bytes }, null, 2)); } else { if (!values.metadata || !values.jdk21 || !values.maven) throw new Error('사용법: node scripts/bundle-runtime.mjs --jdk21 <폴더> --maven <폴더> --metadata <배포 출처 JSON>'); @@ -63,13 +68,17 @@ if (values.check) { const details = record(entries[0]); if (!details.sourceCodeUrl) throw new Error('JDK 재배포의 정확한 소스 코드 위치를 기록하세요.'); const root = await sourceDirectory(source); - const release = await readFile(path.join(root, 'release'), 'utf8'); + const javaHome = mac ? path.join(root, 'Contents', 'Home') : root; + const release = await readFile(path.join(javaHome, 'release'), 'utf8'); const installedVersion = /^JAVA_VERSION="([^"]+)"/m.exec(release)?.[1]; const installedMajor = Number(installedVersion?.startsWith('1.') ? installedVersion.split('.')[1] : installedVersion?.split(/[.+_-]/)[0]); - if (installedMajor !== major || !/^OS_ARCH="(?:x86_64|amd64)"/m.test(release) || !/^OS_NAME="Windows"/m.test(release)) throw new Error('Windows x64용 요청 JDK 버전인지 확인하세요.'); + const releaseArch = /^OS_ARCH="([^"]+)"/m.exec(release)?.[1]; + const releaseOs = /^OS_NAME="([^"]+)"/m.exec(release)?.[1]; + if (installedMajor !== major || !(values.arch === 'arm64' ? ['aarch64', 'arm64'] : ['x86_64', 'amd64']).includes(releaseArch) + || !(mac ? ['Darwin', 'Mac OS X'] : ['Windows']).includes(releaseOs)) throw new Error('요청한 OS·아키텍처·JDK 버전과 release 파일이 일치하지 않습니다.'); const exactVersions = [installedVersion, /^SEMANTIC_VERSION="([^"]+)"/m.exec(release)?.[1], /^JAVA_RUNTIME_VERSION="([^"]+)"/m.exec(release)?.[1]?.replace(/-LTS$/, '')]; if (!exactVersions.includes(details.version)) throw new Error('기록한 JDK 배포 버전과 실제 release 파일이 일치하지 않습니다.'); - jdks.push({ major, home: `jdks/${major}`, ...details, source: root }); + jdks.push({ major, home: `jdks/${major}${mac ? '/Contents/Home' : ''}`, ...(mac ? { distributionRoot: `jdks/${major}` } : {}), ...details, source: root }); } if (metadata.jdks.length !== jdks.length) throw new Error('지원하지 않는 JDK 배포 출처가 있습니다.'); const mavenSource = await sourceDirectory(values.maven); @@ -78,7 +87,7 @@ if (values.check) { const staging = path.join(path.dirname(destination), `desktop-bundle.partial-${randomUUID()}`); await mkdir(staging); try { - for (const jdk of jdks) await copyDistribution(jdk.source, path.join(staging, ...jdk.home.split('/'))); + for (const jdk of jdks) await copyDistribution(jdk.source, path.join(staging, ...(jdk.distributionRoot || jdk.home).split('/'))); await copyDistribution(mavenSource, path.join(staging, maven.home)); const notices = ['Java Edu bundled third-party distributions', '', 'These complete, unmodified vendor distributions retain their original licenses and notices.', @@ -92,10 +101,10 @@ if (values.check) { ]), ].join('\n'); await writeFile(path.join(staging, 'THIRD-PARTY-NOTICES.txt'), notices, 'utf8'); - const manifest = { schemaVersion: 1, platform: 'win32', arch: 'x64', jdks: jdks.map(({ source: _source, ...jdk }) => jdk), maven, files: await inventoryTree(staging) }; + const manifest = { schemaVersion: mac ? 2 : 1, platform: values.platform, arch: values.arch, jdks: jdks.map(({ source: _source, ...jdk }) => jdk), maven, files: await inventoryTree(staging, inventoryOptions) }; manifest.id = manifestIdentity(manifest); await writeFile(path.join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); - const bundle = await loadBundledRuntime(staging); + const bundle = await loadBundledRuntime(staging, targetOptions); await rename(staging, destination); console.log(JSON.stringify({ status: 'passed', directory: destination, id: bundle.id, javaVersions: bundle.jdks.map((jdk) => jdk.version), mavenVersion: bundle.maven.version, files: bundle.fileCount, bytes: bundle.bytes }, null, 2)); } catch (error) { diff --git a/scripts/prepare-macos-runtime.mjs b/scripts/prepare-macos-runtime.mjs new file mode 100644 index 0000000..881169b --- /dev/null +++ b/scripts/prepare-macos-runtime.mjs @@ -0,0 +1,45 @@ +// Build-time only: pinned vendor downloads; never installs a system toolchain. +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const run = promisify(execFile); +const root = fileURLToPath(new URL('../', import.meta.url)); +if (process.platform !== 'darwin' || !['x64', 'arm64'].includes(process.arch)) throw new Error('Run on a native Intel or Apple Silicon Mac.'); +const metadataPath = path.join(root, 'desktop', `toolchain-provenance.darwin-${process.arch}.json`); +const metadata = JSON.parse(await readFile(metadataPath, 'utf8')); +const input = path.join(root, 'work', `macos-vendor-${process.arch}`); +await mkdir(path.dirname(input), { recursive: true }); +await mkdir(input); // Existing archives/staging are never silently reused or replaced. + +async function download(tool, destination) { + const response = await fetch(tool.sourceUrl, { signal: AbortSignal.timeout(300000) }); + if (!response.ok || !response.body) throw new Error(`Vendor download failed: ${response.status} ${tool.sourceUrl}`); + const hash = createHash('sha256'); + const hashing = new Transform({ transform(chunk, _encoding, callback) { hash.update(chunk); callback(null, chunk); } }); + await pipeline(Readable.fromWeb(response.body), hashing, createWriteStream(destination, { flags: 'wx' })); + if (hash.digest('hex') !== tool.archiveSha256) throw new Error(`Vendor checksum mismatch: ${destination}`); +} + +const jdkArchive = path.join(input, 'jdk.tar.gz'); +const mavenArchive = path.join(input, 'maven.zip'); +await download(metadata.jdks[0], jdkArchive); +await download(metadata.maven, mavenArchive); +const jdkDirectory = path.join(input, 'jdk'); +const mavenDirectory = path.join(input, 'maven'); +await mkdir(jdkDirectory); +await mkdir(mavenDirectory); +await run('/usr/bin/tar', ['-xzf', jdkArchive, '-C', jdkDirectory]); +await run('/usr/bin/ditto', ['-x', '-k', mavenArchive, mavenDirectory]); +const jdkNames = await readdir(jdkDirectory); +const mavenNames = await readdir(mavenDirectory); +if (jdkNames.length !== 1 || !jdkNames[0].endsWith('.jdk') || mavenNames.length !== 1 || mavenNames[0] !== `apache-maven-${metadata.maven.version}`) throw new Error('Unexpected vendor archive layout.'); +const result = await run(process.execPath, [path.join(root, 'scripts/bundle-runtime.mjs'), '--platform', 'darwin', '--arch', process.arch, '--jdk21', path.join(jdkDirectory, jdkNames[0]), '--maven', path.join(mavenDirectory, mavenNames[0]), '--metadata', metadataPath], { cwd: root, maxBuffer: 4 * 1024 * 1024 }); +console.log(result.stdout); +await writeFile(path.join(input, 'download-verification.json'), JSON.stringify({ platform: process.platform, arch: process.arch, verifiedAt: new Date().toISOString(), metadata }, null, 2)); diff --git a/scripts/verify-macos-packages.mjs b/scripts/verify-macos-packages.mjs new file mode 100644 index 0000000..213e103 --- /dev/null +++ b/scripts/verify-macos-packages.mjs @@ -0,0 +1,325 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { access, open, mkdir, lstat, readFile, readlink, readdir, realpath, rename, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { loadBundledRuntime } from '../desktop/bundled-runtime.cjs'; + +const require = createRequire(import.meta.url); +const { listPackage, statFile, extractFile } = require('@electron/asar'); +const root = path.resolve(fileURLToPath(new URL('../', import.meta.url))); +function option(name, fallback) { + const index = process.argv.indexOf(name); + if (index === -1) return fallback; + assert.ok(process.argv[index + 1] && !process.argv[index + 1].startsWith('--'), name + ' needs a value'); + return process.argv[index + 1]; +} +const arch = option('--arch'); +const release = path.resolve(root, option('--release', 'desktop-release')); +const artifactsRoot = path.resolve(root, option('--artifacts', 'work/macos-qa')); +const artifacts = path.join(artifactsRoot, arch || 'invalid'); +const runRoot = path.join(artifacts, 'run-' + randomUUID()); +const ownershipToken = randomUUID(); +const marker = '.java-edu-macos-qa-owner'; +const evidence = { startedAt: new Date().toISOString(), platform: process.platform, hostArch: process.arch, arch, + sourceRoot: root, release, artifacts: runRoot, commands: [], packageChecks: [], runtimeChecks: [], preflight: [], + notExecuted: ['Downloaded-file Gatekeeper flow', 'Developer ID notarization', 'Physical user playtesting'] }; +const exists = file => access(file).then(() => true, () => false); +const inside = (child, parent) => path.relative(parent, child) !== '' && !path.relative(parent, child).startsWith('..') && !path.isAbsolute(path.relative(parent, child)); +const hashBytes = bytes => createHash('sha256').update(bytes).digest('hex'); +let expectedUserData; +let ownedData = false; +let mountPoint; +let mountAttached = false; +let currentKind; + +async function command(executable, args, { timeout = 120000, log = null, allowedExitCodes = [0] } = {}) { + const record = { executable, args, startedAt: new Date().toISOString() }; + evidence.commands.push(record); + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { cwd: root, env: process.env, shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, timeout); + const forceTimer = setTimeout(() => { child.kill('SIGKILL'); }, timeout + 10000); + child.stdout.on('data', data => { stdout += data.toString(); }); + child.stderr.on('data', data => { stderr += data.toString(); }); + child.on('error', error => { clearTimeout(timer); clearTimeout(forceTimer); record.error = error.message; reject(error); }); + child.on('close', async (code, signal) => { + clearTimeout(timer); clearTimeout(forceTimer); + Object.assign(record, { exitCode: code, signal, timedOut, stdout, stderr, finishedAt: new Date().toISOString() }); + try { + if (log) await writeFile(log, stdout + '\nSTDERR\n' + stderr); + assert.ok(!timedOut && allowedExitCodes.includes(code), executable + ' failed (' + code + ', ' + signal + '): ' + stderr.slice(-3000)); + resolve({ stdout, stderr, exitCode: code }); + } catch (error) { reject(error); } + }); + }); +} +async function fileDigest(file) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +} +async function inventory(base) { + const entries = []; + async function visit(directory, prefix = '') { + for (const name of (await readdir(directory)).sort()) { + const file = path.join(directory, name); + const relative = prefix ? prefix + '/' + name : name; + const info = await lstat(file); + const entry = { path: relative, mode: info.mode & 0o777 }; + if (info.isSymbolicLink()) { + entry.type = 'symlink'; + entry.target = await readlink(file); + assert.ok(inside(path.resolve(path.dirname(file), entry.target), base), 'symlink escapes extracted app: ' + relative); + // Reject dangling/redirected links, including intermediate symlinks. + assert.ok(inside(await realpath(file), base), 'symlink resolves outside extracted app: ' + relative); + } else if (info.isDirectory()) entry.type = 'directory'; + else { + assert.ok(info.isFile(), 'unsupported extracted item: ' + relative); + Object.assign(entry, { type: 'file', bytes: info.size, sha256: await fileDigest(file) }); + const handle = await open(file, 'r'); + try { + const header = Buffer.alloc(4); + const { bytesRead } = await handle.read(header, 0, 4, 0); + entry.nativeCandidate = bytesRead === 4 && ['feedface', 'cefaedfe', 'feedfacf', 'cffaedfe', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca'].includes(header.toString('hex')); + } finally { await handle.close(); } + } + entries.push(entry); + if (entry.type === 'directory') await visit(file, relative); + } + } + await visit(base); + return entries; +} +async function packagedSourceFiles() { + const files = []; + async function visit(directory, relative) { + for (const name of (await readdir(directory)).sort()) { + const next = relative + '/' + name; + // Match desktop/build-config.cjs source glob exclusions. + if (name === 'node_modules' || name === 'target' || next === 'dist/server' || next === 'dist/.openai') continue; + const info = await lstat(path.join(directory, name)); + if (info.isDirectory()) await visit(path.join(directory, name), next); + else { + assert.ok(info.isFile(), 'source package contains unexpected symlink/special file: ' + next); + files.push(next); + } + } + } + for (const relative of ['desktop', 'server', 'dist', 'curriculum/professional']) await visit(path.join(root, relative), relative); + return files.sort(); +} +async function sourceAsarParity(appPath) { + const asarPath = path.join(appPath, 'Contents/Resources/app.asar'); + const result = []; + for (const listed of listPackage(asarPath)) { + const relative = listed.replace(/^[/\\]+/, '').replaceAll('\\', '/'); + const info = statFile(asarPath, relative); + if (!Object.hasOwn(info, 'size')) continue; + if (!['desktop/', 'server/', 'dist/', 'curriculum/professional/'].some(prefix => relative.startsWith(prefix))) continue; + assert.ok(inside(path.resolve(root, relative), root), 'ASAR source path escapes'); + const source = await readFile(path.join(root, relative)); + const actual = info.unpacked + ? await readFile(path.join(appPath, 'Contents/Resources/app.asar.unpacked', relative)) + : extractFile(asarPath, relative); + const comparison = { path: relative, unpacked: info.unpacked === true, sha256: hashBytes(actual), sourceSha256: hashBytes(source), bytes: actual.length }; + result.push(comparison); + assert.equal(comparison.sha256, comparison.sourceSha256, 'source/ASAR byte mismatch: ' + relative); + } + assert.deepEqual(result.map(item => item.path).sort(), await packagedSourceFiles(), + 'ASAR selected source inventory must be complete: no missing or additional source files'); + for (const required of ['desktop/main.cjs', 'desktop/preload.cjs', 'server/local/process.mjs', 'server/local/engine.mjs', 'dist/app.js', 'dist/styles.css', 'dist/data/professional.js']) assert.ok(result.some(item => item.path === required), 'ASAR missing required source: ' + required); + const packaged = JSON.parse(extractFile(asarPath, 'package.json').toString('utf8')); + const sourcePackage = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); + for (const key of ['name', 'version', 'main']) assert.equal(packaged[key], sourcePackage[key], 'packaged metadata ' + key); + return { asarSha256: await fileDigest(asarPath), packaged, files: result }; +} +async function nativeFacts(appPath, entries) { + const facts = []; + const wanted = arch === 'x64' ? 'x86_64' : 'arm64'; + for (const entry of entries.filter(item => item.nativeCandidate)) { + const file = path.join(appPath, entry.path); + const identified = await command('/usr/bin/file', ['-b', file]); + if (!identified.stdout.includes('Mach-O')) continue; // A Java class can have the fat Mach-O magic. + const result = await command('/usr/bin/lipo', ['-archs', file]); + const architectures = result.stdout.trim().split(/\s+/); + assert.ok(architectures.includes(wanted), 'wrong native arch: ' + entry.path + ' = ' + result.stdout.trim()); + facts.push({ path: entry.path, architectures, file: identified.stdout.trim() }); + } + for (const required of ['Contents/MacOS/Java Edu', 'Contents/Resources/toolchains/jdks/21/Contents/Home/bin/java', + 'Contents/Resources/toolchains/jdks/21/Contents/Home/bin/javac']) { + const fact = facts.find(item => item.path === required); + assert.ok(fact, 'required native binary absent: ' + required); + assert.deepEqual(fact.architectures, [wanted], 'entry point/tool must be native requested architecture: ' + required); + assert.ok(entries.find(item => item.path === required).mode & 0o111, 'required executable lost mode: ' + required); + } + return facts; +} +async function verifyApp(kind, appPath, stagingInventory, sourceBundleInventory) { + const record = { kind, appPath }; + evidence.packageChecks.push(record); + const actual = await inventory(appPath); + const inventoryFile = path.join(runRoot, kind + '-inventory.json'); + await writeFile(inventoryFile, JSON.stringify(actual, null, 2)); + record.inventoryFile = inventoryFile; + assert.deepEqual(actual, stagingInventory, kind + ' full bundle differs from builder staging (bytes, modes, symlinks, entries)'); + const toolchains = path.join(appPath, 'Contents/Resources/toolchains'); + const packagedTools = await inventory(toolchains); + assert.deepEqual(packagedTools, sourceBundleInventory, kind + ' toolchain bytes/signatures/modes/symlinks differ from vendor staging'); + const bundle = await loadBundledRuntime(toolchains); + assert.equal(bundle.platform, 'darwin'); + assert.equal(bundle.arch, arch); + record.bundle = { id: bundle.id, platform: bundle.platform, arch: bundle.arch, bytes: bundle.bytes, fileCount: bundle.fileCount }; + record.sourceAsar = await sourceAsarParity(appPath); + record.native = await nativeFacts(appPath, actual); + record.appSignature = await command('/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=4', appPath]); + const signature = await command('/usr/bin/codesign', ['--display', '--verbose=4', appPath]); + assert.match(signature.stderr + signature.stdout, /Signature=adhoc/, 'final app must have verifiable ad hoc signature'); + record.appSignatureDisplay = signature; + const vendorRoot = path.join(toolchains, 'jdks/21'); + record.vendorSignature = await command('/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=4', vendorRoot]); + record.vendorSignatureDisplay = await command('/usr/bin/codesign', ['--display', '--verbose=4', vendorRoot]); + record.status = 'PASS'; +} +async function archiveOwnedData(kind) { + if (!ownedData) return; + assert.ok(expectedUserData && path.dirname(expectedUserData) === path.join(os.homedir(), 'Library/Application Support')); + assert.ok((await lstat(expectedUserData)).isDirectory() && !(await lstat(expectedUserData)).isSymbolicLink()); + assert.equal((await readFile(path.join(expectedUserData, marker), 'utf8')).trim(), ownershipToken, 'production-data ownership lost'); + const destination = path.join(runRoot, kind + '-production-user-data'); + assert.equal(await exists(destination), false); + await rename(expectedUserData, destination); + ownedData = false; + evidence.archivedUserData ??= []; + evidence.archivedUserData.push({ kind, from: expectedUserData, destination }); +} +async function runtime(kind, appPath) { + assert.equal(await exists(expectedUserData), false, 'never overwrite existing user data: ' + expectedUserData); + // Marker gives a positive ownership proof even if Electron fails before + // firstWindow. No app setting/state exists before its real first launch. + await mkdir(expectedUserData, { recursive: false }); + ownedData = true; + await writeFile(path.join(expectedUserData, marker), ownershipToken + '\n', { flag: 'wx', mode: 0o600 }); + const runtimeArtifacts = path.join(runRoot, kind + '-runtime'); + await mkdir(runtimeArtifacts); + const record = { kind, artifacts: runtimeArtifacts, startedAt: new Date().toISOString() }; + evidence.runtimeChecks.push(record); + let finishedChild = false; + try { + await command(process.execPath, [path.join(root, 'tests/macos-packaged.mjs'), '--kind', kind, '--arch', arch, + '--app', appPath, '--artifacts', runtimeArtifacts, '--user-data', expectedUserData, '--ownership-token', ownershipToken], + { timeout: 1200000, log: path.join(runtimeArtifacts, 'runtime.log') }); + finishedChild = true; + record.result = JSON.parse(await readFile(path.join(runtimeArtifacts, 'runtime-results.json'), 'utf8')); + assert.equal(record.result.status, 'PASS'); + assert.equal(record.result.closed, true); + record.status = 'PASS'; + } catch (error) { + record.status = 'FAIL'; + record.error = error.stack; + record.result = JSON.parse(await readFile(path.join(runtimeArtifacts, 'runtime-results.json'), 'utf8').catch(() => '{}')); + finishedChild = record.result.closed === true; + throw error; + } finally { + // Never move data while a failed/timed-out app may still be alive. + if (finishedChild) await archiveOwnedData(kind); + else record.retainedProductionUserData = expectedUserData; + record.finishedAt = new Date().toISOString(); + } +} +async function detach() { + if (!mountAttached) return; + assert.ok(mountPoint && inside(mountPoint, runRoot), 'detach only this run mount'); + await command('/usr/bin/hdiutil', ['detach', mountPoint]); + mountAttached = false; + evidence.detached = true; +} +async function main() { + assert.equal(process.platform, 'darwin', 'execute package/runtime QA on native macOS only'); + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'default-data QA requires ephemeral GitHub runner home'); + assert.ok(['x64', 'arm64'].includes(arch), 'pass --arch x64|arm64'); + assert.equal(process.arch, arch, 'native runner architecture must match package; Rosetta is not this gate'); + assert.ok(inside(runRoot, path.join(root, 'work')), 'keep extraction/evidence within workspace work/'); + await mkdir(runRoot, { recursive: true }); + const git = await command('git', ['rev-parse', 'HEAD']); + evidence.sourceSha = git.stdout.trim(); + evidence.sourceStatus = (await command('git', ['status', '--porcelain'])).stdout; + const staging = path.join(release, arch === 'arm64' ? 'mac-arm64' : 'mac', 'Java Edu.app'); + assert.equal((await lstat(staging)).isDirectory(), true); + evidence.staging = staging; + const stagingInventory = await inventory(staging); + await writeFile(path.join(runRoot, 'staging-inventory.json'), JSON.stringify(stagingInventory, null, 2)); + const sourceBundleInventory = await inventory(path.join(root, 'work/desktop-bundle')); + await writeFile(path.join(runRoot, 'source-toolchains-inventory.json'), JSON.stringify(sourceBundleInventory, null, 2)); + const stagingSource = await sourceAsarParity(staging); + // Electron initializes its default app name from productName || name in + // packaged package.json. Main does not override packaged userData. + const appName = stagingSource.packaged.productName || stagingSource.packaged.name; + assert.ok(appName && appName !== '.' && appName !== '..' && !/[\\/\0]/.test(appName)); + expectedUserData = path.join(os.homedir(), 'Library/Application Support', appName); + const candidates = [...new Set([appName, 'java-edu', 'Java Edu'])].map(name => path.join(os.homedir(), 'Library/Application Support', name)); + for (const candidate of candidates) { + const info = await lstat(candidate).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + evidence.preflight.push({ path: candidate, exists: info !== null }); + assert.equal(info, null, 'STOP: preserve pre-existing user data: ' + candidate); + } + evidence.expectedUserData = expectedUserData; + await mkdir(path.dirname(expectedUserData), { recursive: true }); + const files = await readdir(release); + for (const kind of ['dmg', 'zip']) { + currentKind = kind; + const matches = files.filter(name => name.endsWith('.' + kind) && name.includes('-' + arch + '.')); + assert.equal(matches.length, 1, 'expected one actual ' + arch + ' ' + kind + ': ' + JSON.stringify(matches)); + const packageFile = path.join(release, matches[0]); + evidence.packages ??= []; + evidence.packages.push({ kind, path: packageFile, bytes: (await lstat(packageFile)).size, sha256: await fileDigest(packageFile) }); + const extraction = path.join(runRoot, kind + '-한글 설치 & %JAVA_HOME%', 'Applications'); + await mkdir(extraction, { recursive: true }); + const extractedApp = path.join(extraction, 'Java Edu.app'); + if (kind === 'dmg') { + mountPoint = path.join(runRoot, 'owned-dmg-mount'); + await mkdir(mountPoint); + try { + // Explicit owned mount avoids unrelated /Volumes or /Applications. + mountAttached = true; // detach attempted even after a partial attach failure + const mounted = await command('/usr/bin/hdiutil', ['attach', packageFile, '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mountPoint, '-plist']); + evidence.dmgAttach = mounted; + assert.ok((await lstat(path.join(mountPoint, 'Java Edu.app'))).isDirectory()); + await command('/usr/bin/ditto', ['--rsrc', '--extattr', path.join(mountPoint, 'Java Edu.app'), extractedApp]); + } finally { await detach(); } + } else { + await command('/usr/bin/ditto', ['-x', '-k', '--rsrc', '--extattr', packageFile, extraction]); + } + assert.ok((await lstat(extractedApp)).isDirectory() && !(await lstat(extractedApp)).isSymbolicLink(), 'actual package contains regular app'); + await verifyApp(kind, extractedApp, stagingInventory, sourceBundleInventory); + await runtime(kind, extractedApp); + } + evidence.status = 'PASS'; +} +try { await main(); } +catch (error) { + evidence.status = 'FAIL'; + evidence.error = error.stack; + console.error(error.stack); + process.exitCode = 1; +} finally { + if (mountAttached) { + try { await detach(); } + catch (error) { evidence.detachError = error.stack; evidence.status = 'FAIL'; process.exitCode = 1; } + } + if (ownedData) evidence.retainedProductionUserData = { path: expectedUserData, kind: currentKind, reason: 'Child app close was not proved; preserved data in place.' }; + evidence.finishedAt = new Date().toISOString(); + // Failures, including non-macOS attempts, always leave a result. + await mkdir(runRoot, { recursive: true }); + const resultFile = path.join(runRoot, 'package-qa-results.json'); + await writeFile(resultFile, JSON.stringify(evidence, null, 2)); + console.log(JSON.stringify({ status: evidence.status, arch, resultFile, packageChecks: evidence.packageChecks.map(item => ({ kind: item.kind, status: item.status })), runtimeChecks: evidence.runtimeChecks.map(item => ({ kind: item.kind, status: item.status })) }, null, 2)); +} + diff --git a/server/local/process.mjs b/server/local/process.mjs index 2e0a9f8..e9be36f 100644 --- a/server/local/process.mjs +++ b/server/local/process.mjs @@ -17,6 +17,7 @@ export function processEnvironment(javaHome, temporaryDirectory) { } env.TEMP = temporaryDirectory; env.TMP = temporaryDirectory; + if (process.platform !== 'win32') env.TMPDIR = temporaryDirectory; env.HOME = temporaryDirectory; env.USERPROFILE = temporaryDirectory; if (process.platform === 'win32') { @@ -73,14 +74,17 @@ export async function runProcess(executable, args, { } }; const onAbort = () => stop('cancelled'); + const stopUnixDescendants = () => { + if (process.platform !== 'win32' && child?.pid) { + try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already reaped */ } + } + }; const finish = async (exitCode, spawnError) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort); - if (process.platform !== 'win32' && child?.pid) { - try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already reaped */ } - } + stopUnixDescendants(); let report; if (process.platform === 'win32') { try { report = JSON.parse(await readFile(resultPath, 'utf8')); } catch { /* Helper can be killed before reporting. */ } @@ -106,6 +110,10 @@ export async function runProcess(executable, args, { }); } child.once('error', (error) => void finish(-1, error)); + // A descendant may inherit stdout/stderr and keep them open after the + // command exits. Kill the remaining Unix group on exit, before waiting + // for close to drain pipes, without changing the command's exit reason. + child.once('exit', stopUnixDescendants); child.once('close', (code) => void finish(code)); if (signal?.aborted) stop('cancelled'); }); diff --git a/tests/local/bundled-runtime-smoke.mjs b/tests/local/bundled-runtime-smoke.mjs index 0e8e73e..f1efd0f 100644 --- a/tests/local/bundled-runtime-smoke.mjs +++ b/tests/local/bundled-runtime-smoke.mjs @@ -1,4 +1,4 @@ -// Opt-in actual Windows toolchain check: no chooseJdk/chooseMaven call, no external tool install. +// Opt-in actual native bundled toolchain check: no folder selection or external tool install. import assert from 'node:assert/strict'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; @@ -9,12 +9,12 @@ import { createSettingsStore } from '../../server/local/settings.mjs'; import { createLocalEngine } from '../../server/local/engine.mjs'; import { exerciseDefinitions } from '../../server/exercises.mjs'; -if (process.platform !== 'win32') throw new Error('The bundled distribution is Windows x64 only.'); +if (!['win32', 'darwin'].includes(process.platform)) throw new Error('Bundled runtime smoke supports Windows and macOS.'); const root = fileURLToPath(new URL('../../', import.meta.url)); const bundleRoot = path.join(root, 'work/desktop-bundle'); const dataRoot = path.join(root, 'work', `bundled-runtime-smoke-${randomUUID()}`); await mkdir(dataRoot); -const report = { startedAt: new Date().toISOString(), bundleRoot, dataRoot, results: [] }; +const report = { startedAt: new Date().toISOString(), platform: process.platform, arch: process.arch, bundleRoot, dataRoot, results: [] }; let engine; try { const bundle = await loadBundledRuntime(bundleRoot); diff --git a/tests/local/bundled-runtime.test.mjs b/tests/local/bundled-runtime.test.mjs index 7ebbcb9..203d2e4 100644 --- a/tests/local/bundled-runtime.test.mjs +++ b/tests/local/bundled-runtime.test.mjs @@ -3,10 +3,13 @@ import assert from 'node:assert/strict'; import { mkdtemp, mkdir, writeFile, readFile, rm, realpath, rename, symlink, cp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { inventoryTree, loadBundledRuntime, manifestIdentity } from '../../desktop/bundled-runtime.cjs'; +import { inventoryTree, loadBundledRuntime as nativeLoadBundledRuntime, manifestIdentity } from '../../desktop/bundled-runtime.cjs'; import { createSettingsStore } from '../../server/local/settings.mjs'; import { createLocalEngine } from '../../server/local/engine.mjs'; +// These fixtures deliberately exercise schema1 Windows bytes on every CI host. +const loadBundledRuntime = (directory, options) => nativeLoadBundledRuntime(directory, { expectedPlatform: 'win32', expectedArch: 'x64', ...options }); + async function scratch(t) { const parent = await realpath(tmpdir()); const root = await mkdtemp(path.join(parent, 'java-edu-bundle-test-')); @@ -64,9 +67,13 @@ test('bundle metadata is resolved only from the verified current resources root' test('changed, missing and unlisted toolchain files fail integrity before registration', async (t) => { const root = await scratch(t); - for (const scenario of ['changed', 'missing', 'extra', 'manifest']) { + for (const scenario of ['changed', 'same-size-change', 'missing', 'extra', 'manifest']) { const { directory } = await fixture(root, scenario); if (scenario === 'changed') await writeFile(path.join(directory, 'jdks/21/bin/java.exe'), 'changed'); + if (scenario === 'same-size-change') { + const java = path.join(directory, 'jdks/21/bin/java.exe'); + await writeFile(java, Buffer.alloc((await readFile(java)).length, 0x78)); + } if (scenario === 'missing') await rm(path.join(directory, 'maven/LICENSE')); if (scenario === 'extra') await writeFile(path.join(directory, 'maven/extra.jar'), 'unlisted'); if (scenario === 'manifest') { diff --git a/tests/local/mac-bundled-runtime.test.mjs b/tests/local/mac-bundled-runtime.test.mjs new file mode 100644 index 0000000..8418f9d --- /dev/null +++ b/tests/local/mac-bundled-runtime.test.mjs @@ -0,0 +1,120 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, rm, realpath, chmod, symlink, readlink, cp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { inventoryTree, loadBundledRuntime, manifestIdentity } from '../../desktop/bundled-runtime.cjs'; +import buildConfig from '../../desktop/build-config.cjs'; + +const invalidBundle = { code: 'invalid_bundled_runtime' }; +const nativeUnix = { skip: process.platform === 'win32' }; +async function scratch(t) { + const parent = await realpath(tmpdir()); + const root = await mkdtemp(path.join(parent, 'java-edu-mac-bundle-test-')); + t.after(async () => { const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-mac-bundle-test-')); await rm(resolved, { recursive: true, force: true }); }); + return root; +} +async function saveManifest(directory, manifest) { + manifest.id = manifestIdentity(manifest); + await writeFile(path.join(directory, 'manifest.json'), JSON.stringify(manifest)); +} +async function fixture(root, arch = 'arm64') { + const directory = path.join(root, 'toolchains'); + const files = { + 'jdks/21/Contents/Home/bin/java': 'fixture java', 'jdks/21/Contents/Home/bin/javac': 'fixture javac', + 'jdks/21/Contents/Home/release': `JAVA_VERSION="21.0.12.1"\nOS_ARCH="${arch === 'arm64' ? 'aarch64' : 'x86_64'}"\nOS_NAME="Darwin"\n`, + 'jdks/21/Contents/Home/legal/java.base/LICENSE': 'original license', + 'jdks/21/Contents/Info.plist': 'original vendor bundle metadata', + 'maven/bin/m2.conf': 'fixture config', 'maven/LICENSE': 'original Maven license', + 'maven/boot/plexus-classworlds-2.9.0.jar': 'fixture launcher', 'maven/lib/maven-core-3.10.0.jar': 'fixture Maven', + 'THIRD-PARTY-NOTICES.txt': 'fixture provenance', + }; + for (const [relative, content] of Object.entries(files)) { const file = path.join(directory, relative); await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, content); } + for (const name of ['java', 'javac']) await chmod(path.join(directory, `jdks/21/Contents/Home/bin/${name}`), 0o755); + const provenance = (distribution) => ({ distribution, version: distribution === 'JDK' ? '21.0.12.1' : '3.10.0', sourceUrl: 'https://example.org/archive.tar.gz', archiveSha256: 'a'.repeat(64), license: 'fixture-only', licenseFiles: distribution === 'JDK' ? ['legal/java.base/LICENSE'] : ['LICENSE'], sourceCodeUrl: 'https://example.org/source' }); + const manifest = { schemaVersion: 2, platform: 'darwin', arch, jdks: [{ major: 21, distributionRoot: 'jdks/21', home: 'jdks/21/Contents/Home', ...provenance('JDK') }], maven: { home: 'maven', ...provenance('Maven') }, files: await inventoryTree(directory, { allowSymlinks: true, includeExecutable: true }) }; + await saveManifest(directory, manifest); + return { directory, manifest }; +} + +test('bundle loader refuses platform and architecture mismatches before tool registration', async (t) => { + const root = await scratch(t); + const { directory } = await fixture(root); + const mismatch = (error) => error.code === 'invalid_bundled_runtime' && /unsupported manifest/.test(error.message); + await assert.rejects(loadBundledRuntime(directory, { expectedPlatform: 'darwin', expectedArch: 'x64' }), mismatch); + await assert.rejects(loadBundledRuntime(directory, { expectedPlatform: 'win32', expectedArch: 'arm64' }), mismatch); + if (process.platform !== 'darwin' || process.arch !== 'arm64') await assert.rejects(loadBundledRuntime(directory), mismatch); + else assert.equal((await loadBundledRuntime(directory)).arch, 'arm64'); +}); + +test('Mac post-sign hook rejects changed JDK bytes without rewriting the manifest', nativeUnix, async (t) => { + const root = await scratch(t); + const resources = path.join(root, 'Java Edu.app', 'Contents', 'Resources'); + await mkdir(resources, { recursive: true }); + const { directory } = await fixture(resources); + const context = { appOutDir: root, electronPlatformName: 'darwin', arch: 3 }; + const originalManifest = await readFile(path.join(directory, 'manifest.json')); + const java = path.join(directory, 'jdks/21/Contents/Home/bin/java'); + const originalJava = await readFile(java); + await buildConfig.afterPack(context); + await buildConfig.afterSign(context); + for (const changed of [Buffer.concat([originalJava, Buffer.from('simulated signature bytes')]), + Buffer.alloc(originalJava.length, 0x78)]) { + await writeFile(java, changed); + await assert.rejects(buildConfig.afterSign(context), (error) => + error.code === 'invalid_bundled_runtime' && /distribution integrity mismatch/.test(error.message)); + assert.deepEqual(await readFile(path.join(directory, 'manifest.json')), originalManifest); + } +}); + +test('mac JDK preserves whole vendor bundle, modes and contained license links', nativeUnix, async (t) => { + const root = await scratch(t); + const { directory, manifest } = await fixture(root); + const legal = path.join(directory, 'jdks/21/Contents/Home/legal'); + await mkdir(path.join(legal, 'java.logging')); + await symlink('../java.base/LICENSE', path.join(legal, 'java.logging/LICENSE')); + manifest.files = await inventoryTree(directory, { excludeManifest: true, allowSymlinks: true, includeExecutable: true }); await saveManifest(directory, manifest); + const bundle = await loadBundledRuntime(directory, { expectedPlatform: 'darwin', expectedArch: 'arm64' }); + assert.equal(bundle.jdks[0].home, path.join(directory, 'jdks/21/Contents/Home')); + assert.equal(bundle.platform, 'darwin'); assert.equal(bundle.arch, 'arm64'); + assert.ok(manifest.files.some((file) => file.path.endsWith('Contents/Info.plist'))); + const copied = path.join(root, 'copied'); await cp(directory, copied, { recursive: true, dereference: false, verbatimSymlinks: true }); + assert.equal(await readlink(path.join(copied, 'jdks/21/Contents/Home/legal/java.logging/LICENSE')), '../java.base/LICENSE'); + await loadBundledRuntime(copied, { expectedPlatform: 'darwin', expectedArch: 'arm64' }); + await chmod(path.join(directory, 'jdks/21/Contents/Home/bin/java'), 0o644); + await assert.rejects(loadBundledRuntime(directory, { expectedPlatform: 'darwin', expectedArch: 'arm64' }), invalidBundle); +}); + +test('mac links cannot escape vendor roots, be absolute, cycle or change text', nativeUnix, async (t) => { + const root = await scratch(t); + for (const kind of ['escape', 'absolute', 'cycle', 'cross-vendor', 'changed']) { + const parent = path.join(root, kind); await mkdir(parent); + const { directory, manifest } = await fixture(parent); + const link = path.join(directory, 'jdks/21/Contents/Home/legal/link'); + if (kind === 'changed') { + await symlink('java.base/LICENSE', link); manifest.files = await inventoryTree(directory, { excludeManifest: true, allowSymlinks: true, includeExecutable: true }); await saveManifest(directory, manifest); + await rm(link); await symlink('./java.base/LICENSE', link); + } else { + await writeFile(path.join(parent, 'outside'), 'outside'); + const target = kind === 'escape' ? '../../../../../../outside' : kind === 'absolute' ? path.join(parent, 'outside') : kind === 'cycle' ? 'link' : '../../../../../maven/LICENSE'; + await symlink(target, link); + } + await assert.rejects(loadBundledRuntime(directory, { expectedPlatform: 'darwin', expectedArch: 'arm64' }), invalidBundle, kind); + } +}); + +test('mac staging checks release target and copies a complete vendor .jdk without mutation', nativeUnix, async (t) => { + const root = await scratch(t); + const { directory, manifest } = await fixture(root, 'x64'); + const project = path.join(root, 'project'); await mkdir(path.join(project, 'scripts'), { recursive: true }); await mkdir(path.join(project, 'desktop')); + await cp(new URL('../../scripts/bundle-runtime.mjs', import.meta.url), path.join(project, 'scripts/bundle-runtime.mjs')); + await cp(new URL('../../desktop/bundled-runtime.cjs', import.meta.url), path.join(project, 'desktop/bundled-runtime.cjs')); + const metadata = path.join(root, 'metadata.json'); await writeFile(metadata, JSON.stringify({ jdks: manifest.jdks, maven: manifest.maven })); + const args = [path.join(project, 'scripts/bundle-runtime.mjs'), '--platform', 'darwin', '--arch', 'x64', '--jdk21', path.join(directory, 'jdks/21'), '--maven', path.join(directory, 'maven'), '--metadata', metadata]; + const wrong = spawnSync(process.execPath, args.map((arg) => arg === 'x64' ? 'arm64' : arg), { encoding: 'utf8' }); assert.notEqual(wrong.status, 0); assert.match(wrong.stderr, /release/); + const staged = spawnSync(process.execPath, args, { encoding: 'utf8' }); assert.equal(staged.status, 0, staged.stderr); + const bundle = await loadBundledRuntime(path.join(project, 'work/desktop-bundle'), { expectedPlatform: 'darwin', expectedArch: 'x64' }); assert.equal(bundle.arch, 'x64'); + assert.equal(await readFile(path.join(bundle.jdks[0].home, '../Info.plist'), 'utf8'), 'original vendor bundle metadata'); + const checked = spawnSync(process.execPath, [args[0], '--check', '--platform', 'darwin', '--arch', 'x64'], { encoding: 'utf8' }); assert.equal(checked.status, 0, checked.stderr); +}); diff --git a/tests/local/mac-signing.test.mjs b/tests/local/mac-signing.test.mjs new file mode 100644 index 0000000..966bf14 --- /dev/null +++ b/tests/local/mac-signing.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const buildConfig = require('../../desktop/build-config.cjs'); +const { MacTargetHelper } = require('app-builder-lib/out/mac/MacTargetHelper.js'); +const signingUtil = require('@electron/osx-sign/dist/cjs/util.js'); +const { signApp } = require('@electron/osx-sign/dist/cjs/sign.js'); + +// Run the installed builder and signer, replacing only native discovery/commands. +// These command-selection checks are not real macOS signing or app execution. +test('installed Mac signer excludes vendor toolchains but still signs the app', async (t) => { + const app = '/tmp/한글 경로/Java Edu.app'; + const toolchains = `${app}/Contents/Resources/toolchains`; + const vendor = [toolchains, `${toolchains}/jdks/21`, + `${toolchains}/jdks/21/Contents/Home/bin/java`, + `${toolchains}/jdks/21/Contents/Home/bin/javac`, + `${toolchains}/jdks/21/Contents/Home/lib/libjvm.dylib`, + `${toolchains}/maven/bin/mvn`]; + const electron = [`${app}/Contents/MacOS/Java Edu`, + `${app}/Contents/Frameworks/Electron Framework.framework`, + `${app}/Contents/Resources/toolchains-neighbor/native.dylib`]; + const calls = []; + t.mock.method(signingUtil, 'validateOptsApp', async () => {}); + t.mock.method(signingUtil, 'validateOptsPlatform', async () => 'darwin'); + t.mock.method(signingUtil, 'walkAsync', async () => [...vendor, ...electron]); + t.mock.method(signingUtil, 'execFileAsync', async (command, args) => { calls.push({ command, args }); return ''; }); + + const packager = { config: { electronVersion: require('electron/package.json').version }, + resourceList: Promise.resolve([]), platformSpecificBuildOptions: buildConfig.mac }; + const helper = new MacTargetHelper(packager); + for (const protectedVendor of [true, false]) { + calls.length = 0; + const config = { ...buildConfig.mac, ...(protectedVendor ? {} : { signIgnore: [] }) }; + const options = await helper.buildSignOptions(app, { name: '-' }, 'distribution', false, config, null, 3); + await signApp({ ...options, preEmbedProvisioningProfile: false, preAutoEntitlements: false }); + const signed = calls.filter(({ args }) => args.includes('--sign')); + const signedPaths = signed.map(({ args }) => args.at(-1)); + assert.deepEqual(new Set(signedPaths), new Set([...electron, app, ...(protectedVendor ? [] : vendor)])); + assert.ok(signed.every(({ command, args }) => command === 'codesign' && !args.includes('--deep'))); + assert.ok(calls.some(({ args }) => args.includes('--verify') && args.includes('--deep'))); + } +}); diff --git a/tests/local/process-fixture.mjs b/tests/local/process-fixture.mjs index e022bac..a9d6578 100644 --- a/tests/local/process-fixture.mjs +++ b/tests/local/process-fixture.mjs @@ -1,6 +1,6 @@ // Fixed maintenance fixtures, never learner-supplied commands. import { spawn } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; +import { writeFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; if (process.argv[2] === 'echo') process.stdout.write(JSON.stringify(process.argv.slice(3))); @@ -15,3 +15,7 @@ else if (process.argv[2] === 'supervisor-parent') { const { runProcess, processEnvironment } = await import('../../server/local/process.mjs'); await runProcess(process.execPath, [fileURLToPath(import.meta.url), 'tree', process.argv[4]], { cwd: process.argv[3], env: processEnvironment(undefined, process.argv[3]), timeoutMs: 20_000 }); } +else if (['tree-exit', 'tree-exit-inherited'].includes(process.argv[2])) { + spawn(process.execPath, [fileURLToPath(import.meta.url), 'sleep', process.argv[3]], { stdio: process.argv[2] === 'tree-exit-inherited' ? 'inherit' : 'ignore', shell: false }); + setInterval(() => { if (existsSync(process.argv[3])) process.exit(0); }, 20); +} diff --git a/tests/local/process.test.mjs b/tests/local/process.test.mjs index c7ef69f..77b894d 100644 --- a/tests/local/process.test.mjs +++ b/tests/local/process.test.mjs @@ -10,6 +10,48 @@ import { runProcess, processEnvironment } from '../../server/local/process.mjs'; const fixedFixture = fileURLToPath(new URL('./process-fixture.mjs', import.meta.url)); +test('Unix native argv, private temp, timeout, cancellation, output and normal-exit descendant cleanup', { skip: process.platform === 'win32' }, async (t) => { + const parent = await realpath(tmpdir()); + const root = await mkdtemp(path.join(parent, 'java-edu-local-unix-공백 & % 🚀-')); + const descendantPids = []; + t.after(async () => { + for (const pid of descendantPids) { try { process.kill(pid, 'SIGKILL'); } catch { /* already stopped */ } } + const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-local-unix-')); await rm(resolved, { recursive: true, force: true }); + }); + const env = processEnvironment(undefined, root); + assert.equal(env.TMPDIR, root); + for (const name of ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', 'MAVEN_OPTS', 'NODE_OPTIONS', 'CLASSPATH']) assert.equal(Object.hasOwn(env, name), false); + const argv = ['공백 포함', 'a&b%PATH%', 'a"quoted"', 'trailing\\', '']; + const echo = await runProcess(process.execPath, [fixedFixture, 'echo', ...argv], { cwd: root, env }); + assert.equal(echo.exitCode, 0, JSON.stringify(echo)); assert.deepEqual(JSON.parse(echo.stdout), argv); + const temporary = await runProcess(process.execPath, ['-p', 'JSON.stringify([process.env.TMPDIR,process.env.HOME,process.cwd()])'], { cwd: root, env }); + assert.deepEqual(JSON.parse(temporary.stdout), [root, root, root]); + async function readPid(file) { + let pid; + for (let retry = 0; retry < 100 && !pid; retry++) { await delay(50); pid = Number(await readFile(file, 'utf8').catch(() => '')); } + assert.ok(pid, 'Fixed descendant must start'); descendantPids.push(pid); return pid; + } + async function stopped(pid) { + for (let retry = 0; retry < 100; retry++) { try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') return; throw error; } await delay(50); } + assert.fail(`Descendant ${pid} survived process-group termination`); + } + const timeoutFile = path.join(root, 'timeout.pid'); + const timeout = await runProcess(process.execPath, [fixedFixture, 'tree', timeoutFile], { cwd: root, env, timeoutMs: 1200 }); + assert.equal(timeout.reason, 'timeout'); await stopped(await readPid(timeoutFile)); + const cancelledFile = path.join(root, 'cancelled.pid'); const controller = new AbortController(); + const running = runProcess(process.execPath, [fixedFixture, 'tree', cancelledFile], { cwd: root, env, signal: controller.signal }); + const cancelledPid = await readPid(cancelledFile); controller.abort(); const cancelled = await running; + assert.equal(cancelled.reason, 'cancelled'); await stopped(cancelledPid); + const flood = await runProcess(process.execPath, [fixedFixture, 'overflow'], { cwd: root, env, outputLimit: 1024 }); + assert.equal(flood.reason, 'output_limit'); assert.ok(Buffer.byteLength(flood.stdout) <= 1024); + for (const fixture of ['tree-exit', 'tree-exit-inherited']) { + const exitedFile = path.join(root, `${fixture}.pid`); + const exited = await runProcess(process.execPath, [fixedFixture, fixture, exitedFile], { cwd: root, env, timeoutMs: 2000 }); + assert.equal(exited.exitCode, 0, JSON.stringify(exited)); assert.equal(exited.reason, 'exited', `${fixture} must retain natural exit rather than wait for inherited pipes until timeout`); + await stopped(await readPid(exitedFile)); + } +}); + test('Windows Job supervisor preserves Unicode/metacharacter argv and stops timeout process trees', { skip: process.platform !== 'win32' }, async (t) => { const parent = await realpath(tmpdir()); const root = await mkdtemp(path.join(parent, 'java-edu-local-test-공백 & % 🚀-')); diff --git a/tests/macos-packaged.mjs b/tests/macos-packaged.mjs new file mode 100644 index 0000000..52e590a --- /dev/null +++ b/tests/macos-packaged.mjs @@ -0,0 +1,333 @@ +import assert from 'node:assert/strict'; +import { access, lstat, readFile, mkdir, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { _electron as electron } from 'playwright'; +import { lessons } from '../dist/data/curriculum.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; + +// Production packaged app only. This harness observes real APIs and real tools; +// it never overrides dialogs, network, clipboard, JVM/Maven, HOME or userData. +const root = path.resolve(fileURLToPath(new URL('../', import.meta.url))); +const option = name => process.argv[process.argv.indexOf(name) + 1]; +const appPath = path.resolve(option('--app')); +const artifacts = path.resolve(option('--artifacts')); +const expectedUserData = path.resolve(option('--user-data')); +const ownershipToken = option('--ownership-token'); +const kind = option('--kind'); +const arch = option('--arch'); +const marker = '.java-edu-macos-qa-owner'; +const expectedVersion = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).version; +const evidence = { startedAt: new Date().toISOString(), kind, arch, appPath, expectedUserData, + scope: 'Actual DMG/ZIP production packaged app; real bundled JVM/Maven and loopback APIs; no mocks or home/data overrides.', + notExecuted: ['Gatekeeper downloaded-file/quarantine approval', 'Developer ID notarization', 'Physical Mac user playtest', 'Optional execution cancellation'] }; +const results = []; +const pageErrors = []; +const apiRequests = []; +const coreCases = [8, 21].map(version => lessons.find(lesson => lesson.minVersion === version)); +const project = professionalLessons.find(lesson => lesson.id === 'tx-proxy'); +const projectExercise = professionalExercises.find(exercise => exercise.id === project.exerciseId); +const progressKey = 'java-edu:progress:v1'; +const preferencesKey = 'java-edu:editor-preferences:v1'; +const inside = (child, parent) => path.relative(parent, child) !== '' && !path.relative(parent, child).startsWith('..') && !path.isAbsolute(path.relative(parent, child)); +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); +const exists = file => access(file).then(() => true, () => false); +let app; +let page; +let runtime; +let launchAttempted = false; +let childClosed = true; + +async function eventually(read, expected, message, timeout = 15000) { + const until = Date.now() + timeout; + let value; + while (Date.now() < until) { + value = await read(); + if (typeof expected === 'function' ? expected(value) : value === expected) return value; + await pause(100); + } + assert.fail(message + ': ' + JSON.stringify(value)); +} +const editor = () => page.locator('#code-editor [role="textbox"]'); +const badge = type => page.locator('#lesson-achievements [data-achievement="' + type + '"]').getAttribute('data-complete'); +const stored = () => page.evaluate(keys => Object.fromEntries(keys.map(key => [key, window.javaEduDesktop.storage.getItem(key)])), [progressKey, preferencesKey]); +const progress = async () => JSON.parse((await stored())[progressKey] || '{}'); + +async function capture(name) { + await page.locator('#toast.is-visible').waitFor({ state: 'hidden', timeout: 10000 }); + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + // Do not hide/move UI or overwrite styles for screenshots. + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const file = path.join(artifacts, name + '.png'); + await page.screenshot({ path: file, fullPage: false }); + evidence.captures ??= []; + evidence.captures.push({ name, file, viewport: await page.evaluate(() => ({ width: innerWidth, height: innerHeight, scrollX, scrollY, hash: location.hash })) }); +} + +async function check(name, action) { + const start = Date.now(); + console.log('START ' + kind + ' ' + name); + try { + await action(); + results.push({ name, status: 'PASS', durationMs: Date.now() - start }); + console.log('PASS ' + kind + ' ' + name); + } catch (error) { + results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - start }); + if (page) await capture('failure-' + name).catch(() => {}); + throw error; + } +} + +async function launch() { + const env = { ...process.env }; + const forbidden = new Set(['PATH', 'JAVA_HOME', 'JDK_HOME', 'MAVEN_HOME', 'M2_HOME', 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', + 'JDK_JAVA_OPTIONS', 'MAVEN_OPTS', 'JAVA_EDU_QA_DATA', 'ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS', 'CLASSPATH']); + for (const key of Object.keys(env)) if (forbidden.has(key.toUpperCase()) || key.startsWith('DYLD_')) delete env[key]; + env.PATH = ''; + launchAttempted = true; + childClosed = false; + app = await electron.launch({ executablePath: path.join(appPath, 'Contents/MacOS/Java Edu'), args: [], env, timeout: 90000 }); + app.context().on('request', request => { + const url = new URL(request.url()); + if (url.pathname.startsWith('/api/')) apiRequests.push({ origin: url.origin, path: url.pathname, method: request.method() }); + }); + page = await app.firstWindow(); + page.setDefaultTimeout(20000); + page.on('pageerror', error => pageErrors.push(error.message)); + await page.locator('#workspace-grid').waitFor({ state: 'visible', timeout: 60000 }); + const facts = await app.evaluate(({ app, BrowserWindow }) => { + const prefs = BrowserWindow.getAllWindows()[0].webContents.getLastWebPreferences(); + return { packaged: app.isPackaged, name: app.getName(), version: app.getVersion(), platform: process.platform, arch: process.arch, + executable: app.getPath('exe'), appPath: app.getAppPath(), userData: app.getPath('userData'), home: app.getPath('home'), + preferences: Object.fromEntries(['contextIsolation', 'nodeIntegration', 'sandbox', 'webSecurity', 'allowRunningInsecureContent'].map(key => [key, prefs[key]])) }; + }); + evidence.applications ??= []; + evidence.applications.push(facts); + assert.equal(facts.packaged, true); + assert.equal(facts.version, expectedVersion); + assert.equal(facts.platform, 'darwin'); + assert.equal(facts.arch, arch, 'app runs native target arch'); + assert.equal(facts.executable, path.join(appPath, 'Contents/MacOS/Java Edu')); + assert.equal(facts.appPath, path.join(appPath, 'Contents/Resources/app.asar')); + assert.equal(facts.userData, expectedUserData, 'uses actual production default user data'); + for (const [key, value] of Object.entries({ contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, allowRunningInsecureContent: false })) assert.equal(facts.preferences[key], value); + const environment = await app.evaluate((_electron, names) => ({ + selected: Object.fromEntries(names.map(name => [name, process.env[name] ?? null])), + dyld: Object.keys(process.env).filter(key => key.startsWith('DYLD_')) + }), [...forbidden]); + assert.equal(environment.selected.PATH, ''); + assert.ok(Object.entries(environment.selected).filter(([key]) => key !== 'PATH').every(([, value]) => value === null)); + assert.deepEqual(environment.dyld, []); + evidence.appEnvironment = environment; + runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); + await verifyRuntime(runtime); +} + +async function verifyRuntime(value) { + assert.equal(value.mode, 'local'); + assert.deepEqual(value.supportedTargets, [8, 11, 17, 21]); + assert.deepEqual(value.runtimes.filter(item => item.available).map(item => item.javaVersion), [21]); + const jdk = value.runtimes.find(item => item.javaVersion === 21); + assert.equal(jdk.source, 'bundled'); + assert.equal(value.maven.source, 'bundled'); + assert.equal(value.maven.available, true); + assert.ok(value.bundledRuntime?.id); + assert.deepEqual(value.bundledRuntime.javaVersions, [21]); + assert.equal(jdk.path, path.join(appPath, 'Contents/Resources/toolchains/jdks/21/Contents/Home')); + assert.ok(inside(value.maven.path, path.join(appPath, 'Contents/Resources/toolchains'))); + for (const file of ['bin/java', 'bin/javac']) { + const info = await lstat(path.join(jdk.path, file)); + assert.ok(info.isFile() && (info.mode & 0o111) !== 0, file + ' is executable'); + } + assert.ok(await exists(path.join(value.maven.path, 'boot'))); +} + +async function openLesson(lesson, step = 'lab') { + await page.evaluate(hash => { location.hash = hash; }, 'lesson=' + lesson.id + '&step=' + step + '&version=21'); + await eventually(() => page.locator('#lesson-title').textContent(), text => text?.includes(lesson.title), 'lesson selected'); + await eventually(() => page.locator('[data-step="' + step + '"]').getAttribute('aria-selected'), 'true', 'stage selected'); +} +async function settings() { + await page.locator('#desktop-settings-open').click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'visible' }); + await eventually(() => page.locator('#execution-consent').isEnabled(), true, 'settings loaded'); +} +async function closeSettings() { + await page.locator('#desktop-settings-dialog [data-close-dialog="desktop-settings-dialog"]').first().click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'hidden' }); +} +async function quiz(lesson) { + await openLesson(lesson, 'quiz'); + for (const question of lesson.quiz) await page.locator('#quiz-form [name="quiz-' + question.id + '"][value="' + question.correctIndex + '"]').check(); + await page.locator('#quiz-submit').click(); + await eventually(() => badge('quiz'), 'true', 'quiz achievement persisted'); + await openLesson(lesson); +} +async function execute(button, expectedStatus = 'passed', timeout = 90000) { + const pathname = button === '#prepare-project' ? '/api/local/prepare' : '/api/execute'; + const pending = page.waitForResponse(response => new URL(response.url()).pathname === pathname && response.request().method() === 'POST', { timeout }); + void pending.catch(() => {}); + await page.locator(button).click(); + const response = await pending; + const result = await response.json(); + const request = response.request().postDataJSON(); + evidence.executions ??= []; + evidence.executions.push({ request, httpStatus: response.status(), result }); + await eventually(() => page.locator('#execution-output').getAttribute('data-status'), status => status !== 'running', 'result rendered'); + assert.equal(response.status(), 200); + assert.equal(result.provider, request.exerciseId === project.exerciseId ? 'Local Maven' : 'Local JDK'); + assert.equal(result.runtimeVersion, 21); + assert.equal(result.status, expectedStatus, result.stderr || result.compilerOutput || result.stdout); + assert.ok(result.total > 0); + if (expectedStatus === 'passed') assert.ok(result.passed === result.total && result.checks.every(check => check.passed)); + else assert.ok(result.passed < result.total && result.checks.some(check => !check.passed), 'actual failing tests returned'); + return { result, request }; +} +async function fillProject(reference) { + const files = {}; + for (const file of project.professionalExercise.files.filter(item => item.editable)) { + files[file.path] = reference ? await readFile(path.join(root, 'curriculum/professional', file.referencePath), 'utf8') : projectExercise.files.find(item => item.path === file.path).source; + await page.locator('#project-file-tabs [data-file-path="' + file.path + '"]').click(); + await editor().fill(files[file.path]); + } + return files; +} + +async function main() { + assert.equal(process.platform, 'darwin'); + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'production-data gate is limited to ephemeral GitHub macOS CI'); + assert.ok(['dmg', 'zip'].includes(kind)); + assert.ok(['x64', 'arm64'].includes(arch)); + assert.equal((await readFile(path.join(expectedUserData, marker), 'utf8')).trim(), ownershipToken, 'orchestrator owns fresh production data'); + await mkdir(artifacts, { recursive: true }); + const solutions = JSON.parse(await readFile(path.join(root, 'examples/solutions.json'), 'utf8')); + await launch(); + const firstOrigin = new URL(page.url()).origin; + await check('first-launch-bundled-tools-before-consent', async () => { + evidence.runtimeBeforeConsent = runtime; + assert.equal(runtime.consent.accepted, false); + assert.equal(runtime.available, false); + await openLesson(coreCases[0]); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + await settings(); + assert.equal(await page.locator('#execution-consent').isChecked(), false); + assert.deepEqual(await page.locator('#desktop-bundle-status .settings-bundle-tools li span').allTextContents(), ['앱에 포함', '앱에 포함']); + assert.equal(apiRequests.filter(item => item.method === 'POST' && ['/api/execute', '/api/local/prepare'].includes(item.path)).length, 0); + await capture('first-launch-settings'); + await page.locator('#execution-consent').check(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, true, 'explicit consent saved'); + await closeSettings(); + await eventually(() => page.locator('#run-code').isEnabled(), true, 'bundled tools enabled'); + }); + for (const lesson of coreCases) await check('core-jvm21-target' + lesson.minVersion, async () => { + await openLesson(lesson); + await editor().fill(solutions[lesson.exerciseId]); + await quiz(lesson); + const { result, request } = await execute('#submit-code'); + assert.equal(result.targetVersion, lesson.minVersion); + assert.equal(request.source, solutions[lesson.exerciseId]); + assert.equal(await badge('lab'), 'true'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await capture('core-target' + lesson.minVersion + '-passed'); + }); + await check('spring-h2-online-prepare-preserves-draft-no-achievement', async () => { + await openLesson(project); + await eventually(() => page.locator('#prepare-project').isEnabled(), true, 'prepare enabled'); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + const first = projectExercise.files.find(file => file.editable); + await page.locator('#project-file-tabs [data-file-path="' + first.path + '"]').click(); + await editor().fill(first.source + '\n// preparation draft sentinel\n'); + await openLesson(project, 'theory'); + await openLesson(project); + const before = (await progress()).fileDrafts; + const { result, request } = await execute('#prepare-project', 'passed', 420000); + assert.equal(result.targetVersion, 17); + assert.equal(result.prepared, true); + assert.equal(request.source, undefined); + assert.equal(request.files, undefined); + assert.deepEqual((await progress()).fileDrafts, before); + assert.equal(await badge('quiz'), 'false'); + assert.equal(await badge('lab'), 'false'); + await eventually(() => page.locator('#project-preparation-state').getAttribute('data-prepared'), 'true', 'preparation stored'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await capture('spring-h2-prepared'); + }); + await check('spring-h2-starter-fails-without-completion', async () => { + const expected = await fillProject(false); + const { result, request } = await execute('#submit-code', 'failed', 240000); + assert.deepEqual(request.files, expected); + assert.equal(result.targetVersion, 17); + assert.equal(await badge('lab'), 'false'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await capture('spring-h2-starter-failed'); + }); + await check('spring-h2-reference-offline-submit', async () => { + const expected = await fillProject(true); + await quiz(project); + const { result, request } = await execute('#submit-code', 'passed', 240000); + assert.deepEqual(request.files, expected); + assert.equal(Object.hasOwn(request.files, 'pom.xml'), false); + assert.equal(result.targetVersion, 17); + assert.equal(await badge('lab'), 'true'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await capture('spring-h2-reference-passed'); + }); + await check('quit-relaunch-progress-theme-core-project-drafts', async () => { + const core = coreCases[0]; + const sentinel = 'macos ' + kind + ' restart ' + randomUUID(); + await openLesson(core); + await editor().fill(solutions[core.exerciseId] + '\n// ' + sentinel + '\n'); + await page.locator('#editor-theme-select').selectOption('contrast'); + await openLesson(project); + const first = projectExercise.files.find(file => file.editable); + await page.locator('#project-file-tabs [data-file-path="' + first.path + '"]').click(); + await editor().fill(first.source + '\n// ' + sentinel + ' project\n'); + await openLesson(coreCases[1], 'theory'); + const before = await stored(); + const registered = await page.evaluate(() => window.javaEduDesktop.getSettings()); + await app.close(); childClosed = true; app = null; + await launch(); + evidence.origins = [firstOrigin, new URL(page.url()).origin]; + // A new ephemeral port normally changes; durability must not depend on it. + const after = await stored(); + assert.equal(after[preferencesKey], before[preferencesKey]); + for (const field of ['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'notes']) assert.deepEqual(JSON.parse(after[progressKey])[field], JSON.parse(before[progressKey])[field], field + ' persists'); + assert.equal(runtime.consent.accepted, true); + assert.deepEqual(runtime.bundledRuntime, registered.bundledRuntime); + evidence.runtimeAfterRestart = runtime; + await openLesson(core); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'contrast'); + await editor().press('Meta+End'); + await eventually(() => editor().innerText(), text => text.includes(sentinel), 'core draft restored in actual editor'); + await openLesson(project); + await page.locator('#project-file-tabs [data-file-path="' + first.path + '"]').click(); + await editor().press('Meta+End'); + await eventually(() => editor().innerText(), text => text.includes(sentinel + ' project'), 'project draft restored in actual editor'); + assert.equal(await page.locator('#prepare-project').getAttribute('data-prepared'), 'false', 'runtime preparation resets across quit'); + await openLesson(coreCases[1]); + assert.equal(await badge('quiz'), 'true'); + assert.equal(await badge('lab'), 'true'); + await capture('restarted-progress'); + }); + assert.deepEqual(pageErrors, []); + assert.ok(apiRequests.every(request => new URL(request.origin).hostname === '127.0.0.1')); +} +try { await main(); } +catch (error) { + if (!results.some(result => result.status === 'FAIL')) results.push({ name: 'preflight-or-launch', status: 'FAIL', message: error.message }); + evidence.error = error.stack; + console.error(error.stack); + process.exitCode = 1; +} finally { + if (app) { + try { await app.close(); childClosed = true; evidence.closed = true; } + catch (error) { evidence.closeError = error.message; process.exitCode = 1; } + } else evidence.closed = !launchAttempted || childClosed; + evidence.finishedAt = new Date().toISOString(); + Object.assign(evidence, { results, pageErrors, apiRequests, status: process.exitCode ? 'FAIL' : 'PASS' }); + await mkdir(artifacts, { recursive: true }); + await writeFile(path.join(artifacts, 'runtime-results.json'), JSON.stringify(evidence, null, 2)); +} + From e6a8479afe1fbb19c2164467e9c61b0852b7fabb Mon Sep 17 00:00:00 2001 From: jbsim999 Date: Tue, 13 Oct 2026 22:33:41 +0900 Subject: [PATCH 25/29] =?UTF-8?q?style(desktop):=20Mac=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=81=9D=20=EA=B3=B5=EB=B0=B1=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/verify-macos-packages.mjs | 1 - tests/macos-packaged.mjs | 1 - 2 files changed, 2 deletions(-) diff --git a/scripts/verify-macos-packages.mjs b/scripts/verify-macos-packages.mjs index 213e103..a139fd3 100644 --- a/scripts/verify-macos-packages.mjs +++ b/scripts/verify-macos-packages.mjs @@ -322,4 +322,3 @@ catch (error) { await writeFile(resultFile, JSON.stringify(evidence, null, 2)); console.log(JSON.stringify({ status: evidence.status, arch, resultFile, packageChecks: evidence.packageChecks.map(item => ({ kind: item.kind, status: item.status })), runtimeChecks: evidence.runtimeChecks.map(item => ({ kind: item.kind, status: item.status })) }, null, 2)); } - diff --git a/tests/macos-packaged.mjs b/tests/macos-packaged.mjs index 52e590a..c1d2e9e 100644 --- a/tests/macos-packaged.mjs +++ b/tests/macos-packaged.mjs @@ -330,4 +330,3 @@ catch (error) { await mkdir(artifacts, { recursive: true }); await writeFile(path.join(artifacts, 'runtime-results.json'), JSON.stringify(evidence, null, 2)); } - From 1f93d9ef3a6f089c59e13778ff2b7ce9c6ea243c Mon Sep 17 00:00:00 2001 From: jbsim999 Date: Tue, 13 Oct 2026 22:41:13 +0900 Subject: [PATCH 26/29] =?UTF-8?q?fix(desktop):=20=EA=B3=B5=EC=8B=9D=20Mac?= =?UTF-8?q?=20JDK=20=EC=95=84=EC=B9=B4=EC=9D=B4=EB=B8=8C=EC=9D=98=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=ED=8F=B4=EB=8D=94=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/package-macos.yml | 1 + docs/macos-verification.md | 2 + scripts/prepare-macos-runtime.mjs | 85 ++++++++++++++++------- tests/local/macos-archive-layout.test.mjs | 44 ++++++++++++ 4 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 tests/local/macos-archive-layout.test.mjs diff --git a/.github/workflows/package-macos.yml b/.github/workflows/package-macos.yml index 2ff6736..3d58011 100644 --- a/.github/workflows/package-macos.yml +++ b/.github/workflows/package-macos.yml @@ -3,6 +3,7 @@ name: Package and verify macOS on: push: branches: ['codex/macos-packaging'] + paths-ignore: ['README.md', 'docs/**', 'AGENTS.md', 'CONTRIBUTING.md'] workflow_dispatch: permissions: diff --git a/docs/macos-verification.md b/docs/macos-verification.md index 9dd5f3a..5695bc8 100644 --- a/docs/macos-verification.md +++ b/docs/macos-verification.md @@ -24,6 +24,8 @@ 현재 사용자는 검증용 `codex/macos-packaging` 브랜치의 선행 커밋·푸시와 GitHub Actions의 Intel·Apple Silicon 검증을 명시적으로 승인했다. 이전 선행 푸시 보류는 해제됐다. 두 환경의 실제 최종 패키지 실행 검증을 확인한 뒤 README에 결과와 근거를 반영한다. README 설치 위치·기존 Windows 다운로드 안내 회귀 검사는 1 PASS, 문서 diff 검사 PASS다. 병합·릴리스 게시는 이 승인에 포함되지 않는다. +소스 `e6a8479afe1fbb19c2164467e9c61b0852b7fabb`의 [첫 Mac CI](https://github.com/jbsim999/java-edu/actions/runs/38405962108)는 Windows 회귀 PASS, Intel·Apple Silicon의 콘텐츠/로컬 검사 PASS 후 공급자 아카이브 구조 확인 단계에서 FAIL이었다. SHA256 다운로드 검사는 통과했으며 패키징·최종 앱 실행은 이 실행에서 미실행이다. 실제 공식 JDK 아카이브의 최상위 폴더는 `.jdk` 확장자 없는 `jdk-21.0.12.1+1`이었다. 세 아카이브를 직접 내려받아 전체 목록·고정 SHA256을 확인한 근거는 `work/archive-layout-inspection/observed-layout.json`이다. 준비 스크립트를 정확한 공급자 폴더명·필수 Contents 구조 검사로 수정했으며 파일 이름·내용은 변경하지 않는다. 구조 회귀 검사는 Windows Node24에서 3/3 PASS다. 다운로드 직후부터 단계별 검증 자료를 보존하여 준비 단계 실패도 CI 산출물에 남긴다. + ## JDK 서명과 매니페스트 불일치 점검 사용자는 별도 Mac이 없음을 확인하고 `distribution integrity mismatch` 사례의 발생 가능성을 점검하도록 요청했다. 첨부 문구의 570개 중 72개 파일 불일치 수치는 실제 설치 파일·매니페스트·검사 로그를 제공받지 않았으므로 확인된 재현 결과가 아니다. JDK 네이티브 실행 파일과 dylib만 크기가 달라지고 Maven이 정상인 패턴은 재서명 가설과 부합하지만 원인 확정 근거는 아니다. [Apple 코드 서명 문서](https://developer.apple.com/library/archive/technotes/tn2206/)에 따르면 서명은 실행 파일을 변경한다. diff --git a/scripts/prepare-macos-runtime.mjs b/scripts/prepare-macos-runtime.mjs index 881169b..0527ca6 100644 --- a/scripts/prepare-macos-runtime.mjs +++ b/scripts/prepare-macos-runtime.mjs @@ -1,7 +1,7 @@ // Build-time only: pinned vendor downloads; never installs a system toolchain. import { createHash } from 'node:crypto'; import { createWriteStream } from 'node:fs'; -import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, readdir, writeFile, lstat } from 'node:fs/promises'; import { Readable, Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { execFile } from 'node:child_process'; @@ -11,12 +11,27 @@ import { fileURLToPath } from 'node:url'; const run = promisify(execFile); const root = fileURLToPath(new URL('../', import.meta.url)); -if (process.platform !== 'darwin' || !['x64', 'arm64'].includes(process.arch)) throw new Error('Run on a native Intel or Apple Silicon Mac.'); -const metadataPath = path.join(root, 'desktop', `toolchain-provenance.darwin-${process.arch}.json`); -const metadata = JSON.parse(await readFile(metadataPath, 'utf8')); -const input = path.join(root, 'work', `macos-vendor-${process.arch}`); -await mkdir(path.dirname(input), { recursive: true }); -await mkdir(input); // Existing archives/staging are never silently reused or replaced. + +// These exact pinned Temurin archives use jdk-/Contents, without a +// .jdk suffix. Do not rename the vendor root or discard unknown archive entries. +export async function inspectVendorLayout(jdkDirectory, mavenDirectory, { jdkVersion, mavenVersion }) { + const jdkNames = (await readdir(jdkDirectory)).sort(); + const mavenNames = (await readdir(mavenDirectory)).sort(); + const expectedJdk = `jdk-${jdkVersion}`; + const expectedMaven = `apache-maven-${mavenVersion}`; + const observed = `JDK entries=${JSON.stringify(jdkNames)}, expected=${JSON.stringify(expectedJdk)}; Maven entries=${JSON.stringify(mavenNames)}, expected=${JSON.stringify(expectedMaven)}`; + if (jdkNames.length !== 1 || jdkNames[0] !== expectedJdk || mavenNames.length !== 1 || mavenNames[0] !== expectedMaven) throw new Error(`Unexpected vendor archive layout: ${observed}`); + const jdkRoot = path.join(jdkDirectory, expectedJdk); + const mavenRoot = path.join(mavenDirectory, expectedMaven); + for (const [entry, directory] of [ + [jdkRoot, true], [path.join(jdkRoot, 'Contents'), true], [path.join(jdkRoot, 'Contents/Home'), true], + [path.join(jdkRoot, 'Contents/Home/release'), false], [path.join(jdkRoot, 'Contents/Info.plist'), false], [mavenRoot, true], + ]) { + const info = await lstat(entry).catch(() => null); + if (!info || info.isSymbolicLink() || !(directory ? info.isDirectory() : info.isFile())) throw new Error(`Unexpected vendor archive layout: invalid ${JSON.stringify(entry)}; ${observed}`); + } + return { jdkRoot, mavenRoot, jdkNames, mavenNames }; +} async function download(tool, destination) { const response = await fetch(tool.sourceUrl, { signal: AbortSignal.timeout(300000) }); @@ -24,22 +39,44 @@ async function download(tool, destination) { const hash = createHash('sha256'); const hashing = new Transform({ transform(chunk, _encoding, callback) { hash.update(chunk); callback(null, chunk); } }); await pipeline(Readable.fromWeb(response.body), hashing, createWriteStream(destination, { flags: 'wx' })); - if (hash.digest('hex') !== tool.archiveSha256) throw new Error(`Vendor checksum mismatch: ${destination}`); + const sha256 = hash.digest('hex'); + if (sha256 !== tool.archiveSha256) throw new Error(`Vendor checksum mismatch: ${destination}`); + return { archive: path.basename(destination), sourceUrl: tool.sourceUrl, sha256, verified: true }; +} + +async function prepareMacosRuntime() { + if (process.platform !== 'darwin' || !['x64', 'arm64'].includes(process.arch)) throw new Error('Run on a native Intel or Apple Silicon Mac.'); + const metadataPath = path.join(root, 'desktop', `toolchain-provenance.darwin-${process.arch}.json`); + const metadata = JSON.parse(await readFile(metadataPath, 'utf8')); + const input = path.join(root, 'work', `macos-vendor-${process.arch}`); + await mkdir(path.dirname(input), { recursive: true }); + await mkdir(input); // Existing archives/staging are never silently reused or replaced. + const evidence = { platform: process.platform, arch: process.arch, startedAt: new Date().toISOString(), metadata, archives: [] }; + const saveEvidence = () => writeFile(path.join(input, 'download-verification.json'), `${JSON.stringify(evidence, null, 2)}\n`); + let stage = 'download_jdk'; + try { + const jdkArchive = path.join(input, 'jdk.tar.gz'); + const mavenArchive = path.join(input, 'maven.zip'); + evidence.archives.push(await download(metadata.jdks[0], jdkArchive)); await saveEvidence(); + stage = 'download_maven'; + evidence.archives.push(await download(metadata.maven, mavenArchive)); + evidence.verifiedAt = new Date().toISOString(); evidence.status = 'downloads_verified'; await saveEvidence(); + const jdkDirectory = path.join(input, 'jdk'); + const mavenDirectory = path.join(input, 'maven'); + await mkdir(jdkDirectory); await mkdir(mavenDirectory); + stage = 'extract_archives'; + await run('/usr/bin/tar', ['-xzf', jdkArchive, '-C', jdkDirectory]); + await run('/usr/bin/ditto', ['-x', '-k', mavenArchive, mavenDirectory]); + evidence.entries = { jdk: (await readdir(jdkDirectory)).sort(), maven: (await readdir(mavenDirectory)).sort() }; await saveEvidence(); + stage = 'validate_archive_layout'; + const layout = await inspectVendorLayout(jdkDirectory, mavenDirectory, { jdkVersion: metadata.jdks[0].version, mavenVersion: metadata.maven.version }); + stage = 'stage_bundle'; + const result = await run(process.execPath, [path.join(root, 'scripts/bundle-runtime.mjs'), '--platform', 'darwin', '--arch', process.arch, '--jdk21', layout.jdkRoot, '--maven', layout.mavenRoot, '--metadata', metadataPath], { cwd: root, maxBuffer: 4 * 1024 * 1024 }); + console.log(result.stdout); + evidence.status = 'bundle_staged'; await saveEvidence(); + } catch (error) { + evidence.status = 'failed'; evidence.failureStage = stage; evidence.error = error.message; await saveEvidence(); throw error; + } } -const jdkArchive = path.join(input, 'jdk.tar.gz'); -const mavenArchive = path.join(input, 'maven.zip'); -await download(metadata.jdks[0], jdkArchive); -await download(metadata.maven, mavenArchive); -const jdkDirectory = path.join(input, 'jdk'); -const mavenDirectory = path.join(input, 'maven'); -await mkdir(jdkDirectory); -await mkdir(mavenDirectory); -await run('/usr/bin/tar', ['-xzf', jdkArchive, '-C', jdkDirectory]); -await run('/usr/bin/ditto', ['-x', '-k', mavenArchive, mavenDirectory]); -const jdkNames = await readdir(jdkDirectory); -const mavenNames = await readdir(mavenDirectory); -if (jdkNames.length !== 1 || !jdkNames[0].endsWith('.jdk') || mavenNames.length !== 1 || mavenNames[0] !== `apache-maven-${metadata.maven.version}`) throw new Error('Unexpected vendor archive layout.'); -const result = await run(process.execPath, [path.join(root, 'scripts/bundle-runtime.mjs'), '--platform', 'darwin', '--arch', process.arch, '--jdk21', path.join(jdkDirectory, jdkNames[0]), '--maven', path.join(mavenDirectory, mavenNames[0]), '--metadata', metadataPath], { cwd: root, maxBuffer: 4 * 1024 * 1024 }); -console.log(result.stdout); -await writeFile(path.join(input, 'download-verification.json'), JSON.stringify({ platform: process.platform, arch: process.arch, verifiedAt: new Date().toISOString(), metadata }, null, 2)); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await prepareMacosRuntime(); diff --git a/tests/local/macos-archive-layout.test.mjs b/tests/local/macos-archive-layout.test.mjs new file mode 100644 index 0000000..7cbff34 --- /dev/null +++ b/tests/local/macos-archive-layout.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, rm, realpath, rename, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { inspectVendorLayout } from '../../scripts/prepare-macos-runtime.mjs'; + +const versions = { jdkVersion: '21.0.12.1+1', mavenVersion: '3.10.0' }; +async function fixture(t) { + const parent = await realpath(tmpdir()); const root = await mkdtemp(path.join(parent, 'java-edu-archive-layout-')); + t.after(async () => { const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-archive-layout-')); await rm(resolved, { recursive: true, force: true }); }); + const jdk = path.join(root, 'jdk'); const maven = path.join(root, 'maven'); + const jdkRoot = path.join(jdk, 'jdk-21.0.12.1+1'); const mavenRoot = path.join(maven, 'apache-maven-3.10.0'); + await mkdir(path.join(jdkRoot, 'Contents/Home'), { recursive: true }); await mkdir(mavenRoot, { recursive: true }); + await writeFile(path.join(jdkRoot, 'Contents/Home/release'), 'original vendor release'); await writeFile(path.join(jdkRoot, 'Contents/Info.plist'), 'original signed bundle metadata'); + return { root, jdk, maven, jdkRoot, mavenRoot }; +} + +test('pinned Temurin archive root without .jdk suffix resolves the complete Contents tree', async (t) => { + const data = await fixture(t); const result = await inspectVendorLayout(data.jdk, data.maven, versions); + assert.equal(result.jdkRoot, data.jdkRoot); assert.equal(result.mavenRoot, data.mavenRoot); + assert.deepEqual(result.jdkNames, ['jdk-21.0.12.1+1']); assert.deepEqual(result.mavenNames, ['apache-maven-3.10.0']); +}); + +test('unexpected vendor roots and extra archive entries reject with observed names', async (t) => { + for (const kind of ['suffix', 'wrong-version', 'extra-jdk', 'extra-maven']) { + const data = await fixture(t); + if (kind === 'suffix') await rename(data.jdkRoot, `${data.jdkRoot}.jdk`); + if (kind === 'wrong-version') await rename(data.jdkRoot, path.join(data.jdk, 'jdk-21.0.11+1')); + if (kind === 'extra-jdk') await writeFile(path.join(data.jdk, 'unexpected.txt'), 'unexpected'); + if (kind === 'extra-maven') await mkdir(path.join(data.maven, '__MACOSX')); + await assert.rejects(inspectVendorLayout(data.jdk, data.maven, versions), (error) => /Unexpected vendor archive layout/.test(error.message) && /JDK entries=/.test(error.message) && /Maven entries=/.test(error.message), kind); + } +}); + +test('archive root contract rejects incomplete Contents trees and redirected roots', async (t) => { + for (const kind of ['missing-release', 'missing-plist', 'linked-root']) { + const data = await fixture(t); + if (kind === 'missing-release') await rm(path.join(data.jdkRoot, 'Contents/Home/release')); + if (kind === 'missing-plist') await rm(path.join(data.jdkRoot, 'Contents/Info.plist')); + if (kind === 'linked-root') { const moved = path.join(data.root, 'outside'); await rename(data.jdkRoot, moved); await symlink(moved, data.jdkRoot, process.platform === 'win32' ? 'junction' : 'dir'); t.after(async () => { await rm(data.jdkRoot, { force: true }); }); } + await assert.rejects(inspectVendorLayout(data.jdk, data.maven, versions), /Unexpected vendor archive layout: invalid/, kind); + } +}); From 1c93c7c11101fcf7ad2fa73382104368d8bf00f4 Mon Sep 17 00:00:00 2001 From: jbsim999 Date: Tue, 13 Oct 2026 22:57:53 +0900 Subject: [PATCH 27/29] =?UTF-8?q?docs(desktop):=20Intel=EA=B3=BC=20Apple?= =?UTF-8?q?=20Silicon=20Mac=20=EC=8B=A4=ED=96=89=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 26 +++++++++++++++--------- docs/macos-installation.md | 6 +++--- docs/macos-verification.md | 41 +++++++++++++++++++++++++++++++------- 3 files changed, 54 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c801eb8..09cf737 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ ## 소개 -Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교재**를 함께 만드는 한국어 교육 프로젝트입니다. 다른 언어 경험자와 현직 Java 개발자가 “메서드에 객체를 넘겼는데 왜 결과가 달라질까?”, “트랜잭션을 붙였는데 왜 데이터가 남을까?” 같은 질문을 실제 코드로 확인하도록 돕습니다. +Java Edu는 **Windows·macOS에서 코드를 직접 실행하는 학습 앱과 웹 교재**를 함께 만드는 한국어 교육 프로젝트입니다. 다른 언어 경험자와 현직 Java 개발자가 “메서드에 객체를 넘겼는데 왜 결과가 달라질까?”, “트랜잭션을 붙였는데 왜 데이터가 남을까?” 같은 질문을 실제 코드로 확인하도록 돕습니다. -**macOS도 확장 중입니다.** macOS 13 Ventura 이상에서 Intel x64·Apple Silicon arm64용 DMG와 ZIP에 Java·Maven을 포함하도록 구성했습니다. 실제 Mac의 최종 패키지 실행 검증은 아직 진행 전이며, 통과 전에는 설치 가능·출시 완료로 표시하지 않습니다. [Mac 설치·빌드 안내](docs/macos-installation.md) · [현재 Mac 검증 상태](docs/macos-verification.md) +**macOS Intel·Apple Silicon 검증을 통과했습니다.** v0.4.0 검증용 DMG·ZIP에는 Java·Maven이 포함됩니다. macOS 13 Ventura 이상을 대상으로 하며, 실제 검증은 GitHub Actions의 macOS 15 Intel x64·Apple Silicon arm64에서 수행했습니다. [Mac 설치·빌드 안내](docs/macos-installation.md) · [실제 검증 결과](https://github.com/jbsim999/java-edu/actions/runs/38406992540) 수업은 **이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩** 순서로 진행합니다. Java 언어·표준 API는 Oracle/OpenJDK 공식 문서를, 프레임워크·라이브러리는 각 프로젝트의 공식 문서를 기준으로 합니다. 실무 주제에는 출처를 기록한 채용 공고의 기술 요건도 연결합니다. @@ -38,9 +38,16 @@ Java·Maven·Node.js가 포함돼 있어 따로 설치하거나 폴더를 등록 [자세한 설치 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) · [릴리스와 체크섬](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) -### macOS 설치 안내 — 검증 대기 +### macOS 설치 안내 — v0.4.0 검증용 빌드 -Mac용 설치 파일은 실제 Intel·Apple Silicon 환경 검증을 통과한 뒤 제공합니다. 아직 다운로드 링크를 게시하지 않았습니다. Apple 메뉴 → 이 Mac에 관하여에서 **칩**이 Apple M 계열이면 `arm64`, **프로세서**가 Intel이면 `x64` 파일을 선택합니다. +Intel·Apple Silicon에서 DMG·ZIP 최종 앱 실행 검증을 모두 통과했습니다. Apple 메뉴 → 이 Mac에 관하여에서 **칩**이 Apple M 계열이면 `arm64`, **프로세서**가 Intel이면 `x64` 파일을 선택합니다. + +| Mac | 검증용 파일 | +| --- | --- | +| Apple Silicon | [arm64 CI 산출물](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11818354876) | +| Intel | [x64 CI 산출물](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11819915823) | + +CI 산출물 ZIP을 먼저 해제하면 `desktop-release/`에 해당 CPU의 DMG와 앱 ZIP이 있습니다. 검증 기록도 함께 포함되며 보존 기간은 생성 후 14일입니다. 정식 GitHub Release로 게시한 파일은 아닙니다. [패키지 SHA256·검증 범위](docs/macos-verification.md) 1. 자신의 CPU에 맞는 **DMG**를 열고 **Java Edu**를 **Applications**로 끌어 놓습니다. 2. 복사가 끝나면 디스크 이미지를 추출하고, 응용 프로그램에서 **Java Edu**를 엽니다. **ZIP**은 전체 `Java Edu.app`을 해제해 응용 프로그램으로 옮깁니다. @@ -101,7 +108,7 @@ npm ci npm run dev ``` -브라우저에서 `http://127.0.0.1:4173`을 엽니다. 실무 코드는 웹에서 편집하고 프로젝트 ZIP을 내려받을 수 있습니다. Spring 프로젝트 실행은 Windows 앱 또는 로컬 IDE에서 진행합니다. +브라우저에서 `http://127.0.0.1:4173`을 엽니다. 실무 코드는 웹에서 편집하고 프로젝트 ZIP을 내려받을 수 있습니다. Spring 프로젝트 실행은 Windows·macOS 앱 또는 로컬 IDE에서 진행합니다. **Windows 학습 앱 실행** @@ -127,9 +134,10 @@ npm run build:desktop
macOS 설치 파일 직접 빌드 -macOS 13 이상, Node.js 22.12 이상에서 해당 CPU의 Mac으로 빌드합니다. Mac 패키징 변경이 포함된 소스를 사용합니다. +macOS 13 이상, Node.js 22.12 이상에서 해당 CPU의 Mac으로 빌드합니다. Mac 구현은 `codex/macos-packaging` 브랜치에 있습니다. 위에서 저장소를 내려받았다면 먼저 브랜치를 전환합니다. ```sh +git switch codex/macos-packaging npm ci npm run prepare:mac npm run check:bundle @@ -143,7 +151,7 @@ npm run build:desktop:mac -- --arm64 ### 실행 방식과 버전 -| 구분 | Windows 앱 | 웹 교재 | +| 구분 | Windows·macOS 앱 | 웹 교재 | | --- | --- | --- | | Java 코어 | 기본 포함 JVM 21, 컴파일 대상 8·11·17·21; 다른 실제 JDK는 선택 등록 | Wandbox의 JVM 21, 과제별 최소 지원 버전으로 컴파일 | | Spring 프로젝트 | 컴파일 대상 17, 포함 JDK 21·Maven 3.10.0 사용 | 교재·파일 편집·ZIP 다운로드 | @@ -170,7 +178,7 @@ npm run build:desktop:mac -- --arm64 2026-10-13 기준, v0.3.1 앱 소스 `6ce0428`의 [CI 12작업](https://github.com/jbsim999/java-edu/actions/runs/38338837092)이 모두 성공했습니다. 독립 검수에서 읽기 안내 **24/24**, 기존 기능 **49/49**, 도식 **19/19**, 정적 검사 **5/5**가 통과했고 새 EXE·ZIP의 전체 파일과 포함 도구 604개를 대조했습니다. 사용 중인 앱과 학습 기록을 보존하기 위해 **신버전의 실제 설치·기동 검사는 미실행**입니다. 이전 v0.3.0 설치·제거 검증과 구분합니다. [이번 검증 범위](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/readability-release-qa-report.md) · [검증 기록](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/verification.md) -Mac 패키징 변경의 로컬 검사에서는 콘텐츠40 PASS/실제 원격 제공자1 SKIP, Windows 실행24 PASS/Mac·Unix4 SKIP와 독립 코드 검토가 통과했습니다. **실제 Mac의 DMG·ZIP 실행 결과는 아직 없습니다.** 두 CPU의 실제 패키지에서 포함 도구·Java8/21 대상 제출·Spring/H2 정답/미완성 판정·재시작 저장을 확인한 뒤 실행 결과와 설치 파일 링크를 갱신합니다. [Mac 검증 기록](docs/macos-verification.md) +2026-10-13, 소스 `1f93d9e`의 [Mac 패키지 CI](https://github.com/jbsim999/java-edu/actions/runs/38406992540)가 **Intel·Apple Silicon·Windows 회귀 3작업 모두 성공**했습니다. 두 Mac에서 콘텐츠40 PASS/원격 제공자1 SKIP, 로컬32 PASS/Windows 전용1 SKIP를 확인했습니다. CPU별 DMG·ZIP 총 4개의 포함 도구 무결성·서명·소스 동일성과 실제 앱의 Java8/21 대상 제출·Spring/H2 환경 준비·정답/미완성 판정·재시작 저장이 통과했습니다. 포함 JVM은 21이며 실제 JDK8 실행 검증과 구분합니다. Apple Developer ID 공증·다운로드 후 Gatekeeper·개인 Mac 사용 검증은 미실행입니다. [Mac 검증 기록과 체크섬](docs/macos-verification.md) ## 문서와 로드맵 @@ -196,7 +204,7 @@ Mac 패키징 변경의 로컬 검사에서는 콘텐츠40 PASS/실제 원격 - [x] Java·Maven 포함 Windows EXE와 전체 ZIP 패키징 - [x] [Windows v0.3.1 통합 프리뷰 릴리스](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) - [ ] 선택 과정의 Redis/Kafka 실제 연동 검증 -- [ ] macOS Intel·Apple Silicon 최종 DMG/ZIP 빌드와 실제 앱 실행 검증 +- [x] macOS Intel·Apple Silicon 최종 DMG/ZIP 빌드와 실제 앱 실행 검증 ## 기여와 문의 diff --git a/docs/macos-installation.md b/docs/macos-installation.md index 67595c2..1e6fdcc 100644 --- a/docs/macos-installation.md +++ b/docs/macos-installation.md @@ -4,7 +4,7 @@ macOS 13 Ventura 이상, Intel x64 또는 Apple Silicon arm64. Electron44는 macOS12를 지원하지 않는다. Apple 메뉴 → 이 Mac에 관하여에서 **칩**이 Apple M 계열이면 arm64, **프로세서**가 Intel이면 x64를 선택한다. 두 CPU의 JDK를 섞거나 Windows EXE를 Mac에서 실행하지 않는다. -패키지는 `Java-Edu-0.4.0-mac-arm64.dmg` / `Java-Edu-0.4.0-mac-x64.dmg`와 같은 CPU별 DMG·ZIP이다. 현재 실제 출시 여부와 검사 결과는 [Mac 검증 기록](macos-verification.md)을 따른다. 빌드 명령이 존재하는 것만으로 다운로드 가능·설치 성공을 뜻하지 않는다. +패키지는 `Java-Edu-0.4.0-mac-arm64.dmg` / `Java-Edu-0.4.0-mac-x64.dmg`와 같은 CPU별 DMG·ZIP이다. [실제 Mac CI](https://github.com/jbsim999/java-edu/actions/runs/38406992540)에서 두 CPU의 최종 DMG·ZIP 앱 실행이 통과했다. 실제 검사 환경은 macOS 15이며 다른 지원 OS 버전을 모두 검사한 것은 아니다. 현재 파일은 14일간 보존되는 CI 산출물이며 정식 릴리스로 게시하지 않았다. [Apple Silicon 파일](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11818354876) · [Intel 파일](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11819915823) · [체크섬·검증 범위](macos-verification.md). 산출물 ZIP을 먼저 풀고 `desktop-release/`에서 자신의 CPU에 맞는 DMG 또는 앱 ZIP을 선택한다. ## 설치와 첫 실행 @@ -18,7 +18,7 @@ macOS 13 Ventura 이상, Intel x64 또는 Apple Silicon arm64. Electron44는 mac ## Mac에서 직접 빌드 -Node22.12 이상에서 저장소 의존성을 설치한 후 해당 CPU의 Mac에서 아래 순서로 실행한다. 원본 아카이브·스테이징이 이미 존재하면 덮어쓰지 않고 중단한다. 재빌드는 기존 결과를 먼저 별도로 보존한다. +Node22.12 이상에서 `codex/macos-packaging` 브랜치의 저장소 의존성을 설치한 후 해당 CPU의 Mac에서 아래 순서로 실행한다. 원본 아카이브·스테이징이 이미 존재하면 덮어쓰지 않고 중단한다. 재빌드는 기존 결과를 먼저 별도로 보존한다. ```sh npm ci @@ -30,7 +30,7 @@ npm run build:desktop:mac -- --arm64 최종 패키지 자동 검사는 개인 Mac의 기본 학습 기록을 보호하기 위해 **GitHub Actions의 일회용 macOS 러너에서만** 실행한다. 로컬 빌드 뒤 기존 개인 데이터로 검사 스크립트를 실행하거나 CI 전용 보호 조건을 우회하지 않는다. CI에서는 `node scripts/verify-macos-packages.mjs --arch arm64` 또는 `--arch x64`를 사용한다. -`prepare:mac`은 공식 Temurin21.0.12.1+1 macOS 아카이브와 Maven3.10.0 ZIP의 고정 SHA256을 대조한다. `.jdk/Contents` 전체와 원본 공급자 서명을 보존한다. 런타임 경로는 앱 내부 `Contents/Resources/toolchains/jdks/21/Contents/Home`이다. 공급자 고지·대응 소스 위치는 포함된 `THIRD-PARTY-NOTICES.txt`와 기존 [번들 계약](bundled-runtime.md)을 따른다. +`prepare:mac`은 공식 Temurin21.0.12.1+1 macOS 아카이브와 Maven3.10.0 ZIP의 고정 SHA256을 대조한다. 공급자의 `jdk-21.0.12.1+1/Contents` 전체와 원본 공급자 서명을 보존한다. 런타임 경로는 앱 내부 `Contents/Resources/toolchains/jdks/21/Contents/Home`이다. 공급자 고지·대응 소스 위치는 포함된 `THIRD-PARTY-NOTICES.txt`와 기존 [번들 계약](bundled-runtime.md)을 따른다. macOS 프로세스 취소는 프로세스 그룹·시간·출력 한도와 JVM heap 제한을 사용한다. Windows Job Object의 전체 메모리·프로세스 수 제한 또는 강제 앱 종료 뒤 정리를 macOS에도 보장하지 않는다. 개인 학습 IDE이며 OS 보안 샌드박스가 아니다. diff --git a/docs/macos-verification.md b/docs/macos-verification.md index 5695bc8..596908d 100644 --- a/docs/macos-verification.md +++ b/docs/macos-verification.md @@ -1,26 +1,27 @@ # macOS 패키지 검증 — v0.4.0 -2026-10-13. 기준 소스 `f9d1a51`에 macOS Intel x64·Apple Silicon arm64 패키징을 추가한다. 기존 Windows v0.3.1 릴리스는 변경하지 않는다. +2026-10-13. 기준 소스 `f9d1a51`에 macOS Intel x64·Apple Silicon arm64 패키징을 추가했다. 실제 검증 소스는 `1f93d9ef3a6f089c59e13778ff2b7ce9c6ea243c`이며 [Mac CI 38406992540](https://github.com/jbsim999/java-edu/actions/runs/38406992540)의 Intel·Apple Silicon·Windows 회귀 3작업이 모두 PASS다. 기존 Windows v0.3.1 릴리스는 변경하지 않는다. 현재 구현과 실제 실행의 상태를 분리한다. | 검사 | 현재 근거 | | --- | --- | -| Windows 로컬 계약 | 최종 Node24.19 검사 24 PASS / macOS·Unix 4 SKIP; `work/macos-local-review/windows-local.log`, 실패 없음 | +| Windows 실행 회귀 | 실제 Windows CI 작업 PASS; 이전 Node24.19 로컬 검사 24 PASS / macOS·Unix 4 SKIP, `work/macos-local-review/windows-local.log` | | 콘텐츠·API·편집기 정합성 | Node40 PASS / 실제 원격 제공자1 SKIP | | 웹 Worker | 빌드 및 CSP·인증·정적 파일 검사 PASS | | electron-builder26 설정 | 실제 설치된26.15.3의 설정 schema 검사 PASS | -| macOS 네이티브 모드·링크·실행 | CI에서 확인 예정 | -| 최종 DMG·ZIP 앱 실행 | CI에서 확인 예정 | +| macOS 네이티브 모드·링크·실행 | macOS 15 Intel·Apple Silicon 각각 로컬32 PASS / Windows 전용1 SKIP; 공급자 아카이브 SHA256·포함 도구 실행 PASS | +| 최종 DMG·ZIP 앱 실행 | CPU별 DMG·ZIP 총4개 PASS, 앱별 실제 실행7검사로 총28 PASS | | 독립 코드 검토 | 최종 소스 PASS, 남은 정적 차단 없음. 별도 검수 문맥에서 loader8 PASS/3 SKIP·Windows 감독1 PASS/1 SKIP·서명 제외 helper 정적 확인 | +| 최종 독립 수용 | 실제 CI 보고서·전체 인벤토리·네 패키지 실행 기록·대표 화면4종 대조 PASS; 남은 차단 결함 없음 | | Apple Developer ID·공증 | 자격증명 미확인, 적용하지 않음 | | 실제 사용자 다운로드/Gatekeeper | 미검증 | `.github/workflows/package-macos.yml`은 네이티브 macos-15-intel / macos-15에서 공식 아카이브 확인, 포함 도구 실행, DMG·ZIP 빌드, 실제 최종 앱 검사와 화면 캡처를 수행한다. 정답 제출과 미완성 실패를 분리하고, 실제 실행하지 않은 검사를 성공으로 표시하지 않는다. ephemeral CI의 기본 사용자 데이터만 새로 생성하고 소유권을 확인한 검사 데이터는 증거 폴더로 보존한다. 기존 사용자 데이터가 있으면 실행을 중단한다. -실제 CI ID·소스 SHA·패키지 SHA256·독립 검토 결과는 실행 후 이 문서에 추가한다. 소스/설정이 존재하는 것과 설치 파일 제공·실제 Mac 실행 성공은 별개다. +검증 파일은 해당 CI의 산출물이며 보존 기간은 14일이다. 정식 GitHub Release 게시·main 병합은 수행하지 않았다. 문서 후속 커밋과 패키지 생성 소스를 구분하며 아래 체크섬은 `1f93d9e`에서 생성한 파일의 값이다. -독립 검수에서 Unix 정상 종료 후 자손이 stdout/stderr를 상속하면 `close`가 늦어져 timeout으로 오인될 수 있음을 발견했다. `exit` 시점에 남은 프로세스 그룹을 정리하고 상속 파이프 회귀 검사를 추가했다. Windows 실제 감독 회귀와 최종 로컬 검사는 PASS이며 Unix 실제 실행은 Mac CI에서 확인한다. 개발자 빌드 안내의 CI 전용 QA 명령도 별도 단락으로 정정했다. +독립 검수에서 Unix 정상 종료 후 자손이 stdout/stderr를 상속하면 `close`가 늦어져 timeout으로 오인될 수 있음을 발견했다. `exit` 시점에 남은 프로세스 그룹을 정리하고 상속 파이프 회귀 검사를 추가했다. Windows 실제 감독 회귀와 두 Mac의 Unix 실제 검사에서 PASS를 확인했다. 개발자 빌드 안내의 CI 전용 QA 명령도 별도 단락으로 정정했다. 현재 사용자는 검증용 `codex/macos-packaging` 브랜치의 선행 커밋·푸시와 GitHub Actions의 Intel·Apple Silicon 검증을 명시적으로 승인했다. 이전 선행 푸시 보류는 해제됐다. 두 환경의 실제 최종 패키지 실행 검증을 확인한 뒤 README에 결과와 근거를 반영한다. README 설치 위치·기존 Windows 다운로드 안내 회귀 검사는 1 PASS, 문서 diff 검사 PASS다. 병합·릴리스 게시는 이 승인에 포함되지 않는다. @@ -34,6 +35,32 @@ 빌드 도구의 실제 호출 순서는 리소스 복사 → `afterPack` → 서명 → `afterSign` → DMG/ZIP 생성이다. 두 훅에서 포함 도구의 전체 크기·SHA256·모드·링크를 검사한다. 변경을 발견하면 빌드를 중단하며 매니페스트를 다시 작성하지 않는다. signer의 디렉터리 탐색은 제외 적용 전에 `.cstemp` 임시 파일을 지울 수 있으므로 `signIgnore`만으로 모든 파일 변경이 불가능하다고 주장하지 않는다. 이런 변경도 후속 무결성 검사 대상이다. 최종 패키지 QA는 DMG·ZIP에서 추출한 도구 전체를 원본 스테이징과 다시 비교한다. -Node24.19 Windows의 관련 검사 결과는 **9 PASS / 4 SKIP / 0 FAIL**이다. `tests/local/mac-signing.test.mjs`, `tests/local/mac-bundled-runtime.test.mjs`, `tests/local/bundled-runtime.test.mjs`를 실행했다. 파일 크기가 달라지는 변조뿐 아니라 크기가 같은 변조도 공유 로더에서 거부함을 확인했다. Mac 스키마의 `afterSign` 훅에 대한 실제 파일 변조 회귀 검사는 추가했지만 Windows에는 Unix 실행 권한이 없어 SKIP이며 native CI에서 실행해야 한다. 실제 macOS 코드 서명·설치·실행은 여전히 미검증이다. +Node24.19 Windows의 관련 검사 결과는 **9 PASS / 4 SKIP / 0 FAIL**이었다. `tests/local/mac-signing.test.mjs`, `tests/local/mac-bundled-runtime.test.mjs`, `tests/local/bundled-runtime.test.mjs`를 실행했다. 파일 크기가 달라지는 변조뿐 아니라 크기가 같은 변조도 공유 로더에서 거부함을 확인했다. Windows에서 SKIP했던 Mac 스키마 `afterSign` 훅의 실제 파일 변조 회귀 검사는 이후 두 native Mac CI에서 PASS했다. 추가 검사에 대한 별도 읽기 전용 검토에서도 수정이 필요한 결함은 없었고 독립 실행 결과는 9 PASS / 4 SKIP / 0 FAIL이었다. 명령 선택 검사에서 provisioning·자동 entitlement 처리는 비활성화했으므로 그 단계까지 실제 검증한 것으로 해석하지 않는다. + +## 실제 최종 패키지 검증 결과 + +| 환경 | DMG | ZIP | 포함 도구 파일·링크 | 앱 실행 검사 | +| --- | --- | --- | --- | --- | +| macOS 15 Intel x64 | PASS | PASS | 571개, 공급자 스테이징과 전체 일치 | 각7/7 PASS | +| macOS 15 Apple Silicon arm64 | PASS | PASS | 573개, 공급자 스테이징과 전체 일치 | 각7/7 PASS | + +네 파일 모두 앱 전체 스테이징 인벤토리와 파일 내용·모드·링크를 대조했다. 패키지별 ASAR 소스222개가 빌드 소스와 일치하고 앱·JDK의 네이티브 CPU를 확인했다. 앱의 ad-hoc 서명과 JDK의 공급자 서명을 실제 `codesign --verify --deep --strict`로 검사했으며 모두 exit0이다. 서명 후 JDK/Maven 전체 바이트가 공급자 스테이징과 일치해 `distribution integrity mismatch`는 발생하지 않았다. Intel·Apple Silicon의 파일 수 차이는 각 공급자 배포본의 목록 차이이며 두 CPU 목록을 서로 같은 것으로 취급하지 않는다. + +DMG는 실제 마운트·앱 복사·마운트 해제 후 실행했고 ZIP은 별도 경로에 해제 후 실행했다. 경로에는 한글·공백·`&`·`%JAVA_HOME%`가 포함됐다. 앱은 `packaged:true`이고 기본 `~/Library/Application Support/java-edu`를 사용했다. 호스트 Java/Maven 환경변수와 PATH를 제외한 상태에서 포함 JVM21·Maven3.10.0을 실제 실행했다. 모의 API·가짜 출력·개발 Electron 실행으로 대체하지 않았다. + +각 앱의 실제7검사: 첫 실행의 도구 자동 등록·동의 전 실행 불가, JVM21에서 컴파일 대상8 제출, 대상21 제출, Spring/H2 온라인 준비와 초안 보존, 미완성 제출0/2 실패·성취 없음, 정답의 오프라인 제출2/2 성공, 종료·재실행 후 진도·테마·코어/프로젝트 초안 복원. 총28검사 PASS, 페이지 오류0, 검사 앱의 정상 종료 확인 후 소유한 테스트 데이터를 증거 폴더에 보관했다. 패키징 전에는 코어12과제와 Spring/H2 `tx-proxy`의 실제 포함 도구 smoke 검사도 두 CPU에서 PASS했다. + +| 파일 | 바이트 | SHA256 | +| --- | ---: | --- | +| `Java-Edu-0.4.0-mac-x64.dmg` | 339571139 | `c04e0199b3e797c8427efd52028f10fd4ea62b19d6142af97a9310b3d0cc7560` | +| `Java-Edu-0.4.0-mac-x64.zip` | 342834111 | `6a78e216df3bd5850fcaca2bf9b33562dd5d9d33114377c2fb70bb4cf7f8b51d` | +| `Java-Edu-0.4.0-mac-arm64.dmg` | 339633464 | `82c079d4116e915321d900947b68c87c0d2b8d74f1136426539e3f17b62cb561` | +| `Java-Edu-0.4.0-mac-arm64.zip` | 343050074 | `da266cbdc525db73e02c4b738ac39e9ffbebdc2ea6f6c77d3ae02303b4f98359` | + +[Intel 산출물](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11819915823) · [Apple Silicon 산출물](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11818354876). 두 산출물을 로컬 `work/macos-ci-38406992540/{x64,arm64}`로 내려받아 실제 네 파일의 크기와 SHA256을 CI 기록과 대조했고 모두 일치했다. 대조 기록은 `work/macos-ci-38406992540/downloaded-package-verification.json`이다. 원본 기록은 각 산출물의 `work/macos-qa//run-*/package-qa-results.json`·`dmg-runtime/runtime-results.json`·`zip-runtime/runtime-results.json`·화면 PNG이며, 공급자 아카이브 검증은 `work/macos-vendor-/download-verification.json`이다. + +실제 다운로드 후 Gatekeeper 승인, Apple Developer ID 서명·공증, macOS13/14 등 다른 OS 버전, 개인 Mac 사용자 테스트, macOS 최종 앱의 실행 취소는 이 CI에서 검사하지 않았다. 코어의 대상8 컴파일·실행을 실제 JDK8 실행이라고 해석하지 않는다. 기본 실무20개 전체의 Mac 통합 실행 승인으로 확대하지 않으며 이번 실제 프레임워크 검사는 `tx-proxy`의 Spring/H2 대표 흐름이다. + +별도 읽기 전용 검수 문맥에서 원본 CI 보고서·서명/파일 인벤토리·설정/코어 성공/Spring 미완성 실패/재시작의 대표 실제 화면4종과 최종 문서를 확인했다. 네 패키지의 실제7검사씩 총28 PASS, 파일 수·ASAR222개·공급자 서명·기본 사용자 데이터·페이지 오류0·정상 종료를 독립 대조했으며 최종 수용 PASS다. 다운로드 후 로컬 패키지 해시 대조 기록도 CI 값과 일치했다. 이는 실제 Mac CI 증거의 독립 검토이며 검수자의 Windows에서 Mac 앱을 다시 실행한 것으로 표현하지 않는다. README의 기존 Windows 설치 안내 회귀1검사와 문서 공백 검사도 PASS다. From b8cc78126041b64246dedf49e9e5dbb9fbdda637 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:08:58 +0900 Subject: [PATCH 28/29] =?UTF-8?q?fix(ci):=20macOS=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EC=B9=98=EC=99=80=20Windows=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/package-macos.yml | 2 +- .github/workflows/verify.yml | 1 + README.md | 3 +-- docs/macos-installation.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/package-macos.yml b/.github/workflows/package-macos.yml index 3d58011..cdb04df 100644 --- a/.github/workflows/package-macos.yml +++ b/.github/workflows/package-macos.yml @@ -2,7 +2,7 @@ name: Package and verify macOS on: push: - branches: ['codex/macos-packaging'] + branches: [main, 'feat/**', 'fix/**', 'codex/macos-packaging'] paths-ignore: ['README.md', 'docs/**', 'AGENTS.md', 'CONTRIBUTING.md'] workflow_dispatch: diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 98a32e4..1130d9b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -97,6 +97,7 @@ jobs: with: distribution: temurin java-version: ${{ matrix.java }} + - run: npm ci - run: npm run test:local - name: Verify actual local JVM checks shell: pwsh diff --git a/README.md b/README.md index 09cf737..9c2dff8 100644 --- a/README.md +++ b/README.md @@ -134,10 +134,9 @@ npm run build:desktop
macOS 설치 파일 직접 빌드 -macOS 13 이상, Node.js 22.12 이상에서 해당 CPU의 Mac으로 빌드합니다. Mac 구현은 `codex/macos-packaging` 브랜치에 있습니다. 위에서 저장소를 내려받았다면 먼저 브랜치를 전환합니다. +macOS 13 이상, Node.js 22.12 이상에서 해당 CPU의 Mac으로 빌드합니다. Mac 구현은 공통 개발 브랜치 `feat/java-learning-mvp`에 통합돼 있습니다. 위에서 저장소를 내려받았다면 같은 브랜치에서 진행합니다. ```sh -git switch codex/macos-packaging npm ci npm run prepare:mac npm run check:bundle diff --git a/docs/macos-installation.md b/docs/macos-installation.md index 1e6fdcc..237e4db 100644 --- a/docs/macos-installation.md +++ b/docs/macos-installation.md @@ -18,7 +18,7 @@ macOS 13 Ventura 이상, Intel x64 또는 Apple Silicon arm64. Electron44는 mac ## Mac에서 직접 빌드 -Node22.12 이상에서 `codex/macos-packaging` 브랜치의 저장소 의존성을 설치한 후 해당 CPU의 Mac에서 아래 순서로 실행한다. 원본 아카이브·스테이징이 이미 존재하면 덮어쓰지 않고 중단한다. 재빌드는 기존 결과를 먼저 별도로 보존한다. +Node22.12 이상에서 공통 개발 브랜치 `feat/java-learning-mvp`의 저장소 의존성을 설치한 후 해당 CPU의 Mac에서 아래 순서로 실행한다. macOS 구현은 이 브랜치에 통합되어 별도 브랜치 전환이 필요 없다. 원본 아카이브·스테이징이 이미 존재하면 덮어쓰지 않고 중단한다. 재빌드는 기존 결과를 먼저 별도로 보존한다. ```sh npm ci From 0648a626bd64b19e452b3f7d2e29871787c72d68 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:13:02 +0900 Subject: [PATCH 29/29] =?UTF-8?q?fix(desktop):=20macOS=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EC=9E=91=EC=97=85=20=EC=9D=B4=EC=A0=84=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/package-macos.yml | 89 ----- .github/workflows/verify.yml | 1 - AGENTS.md | 4 - CONTRIBUTING.md | 5 +- README.md | 48 +-- desktop/build-config.cjs | 20 +- desktop/bundled-runtime.cjs | 54 +-- .../toolchain-provenance.darwin-arm64.json | 48 --- desktop/toolchain-provenance.darwin-x64.json | 48 --- docs/macos-installation.md | 42 --- docs/macos-packaging-plan.md | 22 -- docs/macos-verification.md | 66 ---- package-lock.json | 4 +- package.json | 7 +- scripts/bundle-runtime.mjs | 31 +- scripts/prepare-macos-runtime.mjs | 82 ----- scripts/verify-macos-packages.mjs | 324 ----------------- server/local/process.mjs | 14 +- tests/local/bundled-runtime-smoke.mjs | 6 +- tests/local/bundled-runtime.test.mjs | 11 +- tests/local/mac-bundled-runtime.test.mjs | 120 ------- tests/local/mac-signing.test.mjs | 44 --- tests/local/macos-archive-layout.test.mjs | 44 --- tests/local/process-fixture.mjs | 6 +- tests/local/process.test.mjs | 42 --- tests/macos-packaged.mjs | 332 ------------------ 26 files changed, 44 insertions(+), 1470 deletions(-) delete mode 100644 .github/workflows/package-macos.yml delete mode 100644 desktop/toolchain-provenance.darwin-arm64.json delete mode 100644 desktop/toolchain-provenance.darwin-x64.json delete mode 100644 docs/macos-installation.md delete mode 100644 docs/macos-packaging-plan.md delete mode 100644 docs/macos-verification.md delete mode 100644 scripts/prepare-macos-runtime.mjs delete mode 100644 scripts/verify-macos-packages.mjs delete mode 100644 tests/local/mac-bundled-runtime.test.mjs delete mode 100644 tests/local/mac-signing.test.mjs delete mode 100644 tests/local/macos-archive-layout.test.mjs delete mode 100644 tests/macos-packaged.mjs diff --git a/.github/workflows/package-macos.yml b/.github/workflows/package-macos.yml deleted file mode 100644 index cdb04df..0000000 --- a/.github/workflows/package-macos.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Package and verify macOS - -on: - push: - branches: [main, 'feat/**', 'fix/**', 'codex/macos-packaging'] - paths-ignore: ['README.md', 'docs/**', 'AGENTS.md', 'CONTRIBUTING.md'] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: macos-package-${{ github.ref }} - cancel-in-progress: false - -jobs: - windows-regression: - name: Windows execution regression - runs-on: windows-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version: '22' - - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 - with: - distribution: temurin - java-version: '21' - - run: npm ci - - run: npm run check - - run: npm run test:local - - shell: pwsh - run: | - $env:JAVA_EDU_JDK_21 = $env:JAVA_HOME - node tests/local/java-smoke.mjs - - macos: - name: macOS ${{ matrix.arch }} DMG and ZIP - runs-on: ${{ matrix.runner }} - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-intel - arch: x64 - - runner: macos-15 - arch: arm64 - env: - NODE_TLS_REJECT_UNAUTHORIZED: '1' - CSC_IDENTITY_AUTO_DISCOVERY: 'false' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version: '22' - cache: npm - - name: Verify native runner - run: node -e "if(process.platform!=='darwin'||process.arch!=='${{ matrix.arch }}')throw Error('Wrong native runner')" - - run: npm ci - - run: npm run check - - run: npm run test:local - - name: Verify and stage complete vendor distributions - run: npm run prepare:mac - - run: npm run check:bundle - - name: Execute included Java and Spring before packaging - run: | - task_node="$(command -v node)" - env -u JAVA_HOME -u MAVEN_HOME -u M2_HOME PATH=/usr/bin:/bin JAVA_EDU_BUNDLE_SMOKE_PROJECT=tx-proxy "$task_node" tests/local/bundled-runtime-smoke.mjs - - name: Build native DMG and ZIP - run: npm run build:desktop:mac -- --${{ matrix.arch }} - - name: Verify final packages and production app execution - run: node scripts/verify-macos-packages.mjs --arch ${{ matrix.arch }} - - name: Preserve packages and verification evidence - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: java-edu-macos-${{ matrix.arch }}-${{ github.sha }} - compression-level: 0 - retention-days: 14 - if-no-files-found: error - path: | - desktop-release/Java-Edu-*-mac-${{ matrix.arch }}.dmg - desktop-release/Java-Edu-*-mac-${{ matrix.arch }}.zip - work/macos-qa/**/*.json - work/macos-qa/**/*.png - work/macos-vendor-${{ matrix.arch }}/download-verification.json - work/bundled-runtime-smoke-*/results.json diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 1130d9b..98a32e4 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -97,7 +97,6 @@ jobs: with: distribution: temurin java-version: ${{ matrix.java }} - - run: npm ci - run: npm run test:local - name: Verify actual local JVM checks shell: pwsh diff --git a/AGENTS.md b/AGENTS.md index 95499c5..4a808eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,6 @@ 협업 규칙의 기준 문서는 CONTRIBUTING.md이다. 파일당 쓰기 소유자를 한 명으로 정한다. 콘텐츠, 프런트엔드, 조사 근거, 테스트를 독립 작업으로 나누고 의미 있는 구현은 독립 검토를 받는다. 기존 변경을 보존하며 추가 위임은 부모가 허용한 경우에만 한다. -2026-10-13 사용자는 이 프로젝트에서 `junbo-github-conventions` 스킬을 앞으로 따르도록 지정했다. 브랜치·커밋·PR·협업 규칙 관련 작업에서는 `C:/Users/JBSIM/.codex/skills/junbo-github-conventions/SKILL.md`를 적용하고 CONTRIBUTING.md를 기준으로 한다. 스킬 채택 자체를 원격 변경 권한으로 해석하지 않는다. 현재 사용자는 검증용 `codex/macos-packaging` 브랜치의 선행 커밋·푸시와 GitHub Actions의 Intel·Apple Silicon Mac 검증을 명시적으로 승인했다. 이전 선행 푸시 보류를 해제하며, 두 환경의 실제 검증 통과 후 README에 결과를 반영하고 같은 브랜치에 커밋·푸시한다. 병합·릴리스 게시 권한은 별도로 확인한다. - -같은 날 사용자는 `junbo-programming-education-project` 스킬을 앞으로의 구현 구조와 작업 기준으로 지정했다. 교육 제품 구현·확장에서는 `C:/Users/JBSIM/.codex/skills/junbo-programming-education-project/SKILL.md`와 현재 작업에 해당하는 참고 계약을 적용한다. 기존 Java Edu 구조를 새 scaffold로 교체하지 않는다. 교육 콘텐츠는 `curriculum/`·`examples/`·`dist/data/`, 공유 화면·편집기는 `dist/`, 웹 실행은 `server/`·`worker/`, 설치 앱·로컬 실행은 `desktop/`·`server/local/`, 빌드·검사는 `scripts/`·`tests/`, 기획·상태·근거는 `docs/`에서 확장한다. 기존 기획·아키텍처·개발 인계를 먼저 확인하고 대표 수업·실행 흐름 검증 후 확장한다. 저장·실행 어댑터, 환경 준비·실행·제출·성취, 정적/모의/실제 실행/독립 검토/출시 상태를 구분한다. 콘텐츠 개정 fingerprint는 기존 구현 완료로 주장하지 않고 변경 시 별도 설계·이행 검증한다. - 역할: 총괄은 범위·통합·검증 책임; 연구자는 공식 명세와 공고 원문 대조; 교육 설계자는 교육 데이터와 독립 Java 예제; 프런트엔드 담당자는 dist의 화면·상호작용; 독립 리뷰어는 정확성·보안·회귀; QA는 실제 기능·합성 화면 검사. 역할은 전문 자격을 의미하지 않는다. 미지원 Java 버전이나 preview 문법을 정식 예제로 표시하지 않는다. 공고 관찰을 채용 통계로 만들지 않는다. 브라우저 퀴즈와 Java 실행 검증을 구분한다. 실제 실행하지 않은 테스트는 미실행으로 기록한다. 실행기 계약은 docs/architecture.md와 docs/local-execution.md를 따른다. 교재 기준·컴파일 대상·실제 실행 JVM을 분리해서 표시한다. 웹 코어 실행은 외부 Wandbox의 실제 JVM21을 사용하며 Java8/11/17 실제 JVM이라고 주장하지 않는다. Windows 설치형은 등록한 로컬 JDK를 사용하며 원격 제공자를 호출하지 않는다. 실무 교재의 정적 공개·기준 테스트 실행·설치형 통합 검증을 분리한다. Redis/Kafka 실제 연동은 선택 환경이며 미검증을 숨기지 않는다. 이 개인 IDE를 OS 보안 샌드박스로 표현하지 않는다. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 971d228..53dfbe1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,13 @@ # 협업 규칙 -이 저장소의 기본 규칙이다. 2026-10-13 사용자가 `junbo-github-conventions` 스킬을 이 프로젝트의 지속적인 협업 기준으로 지정했다. 스킬의 기본안을 기존 프로젝트 정책에 맞춰 적용하며, 이 문서를 프로젝트 규칙의 기준으로 삼는다. 원격 보호 설정을 변경하거나 실제 확인한 것으로 해석하지 않는다. +이 저장소의 기본 규칙이다. Junbo GitHub Conventions의 추천안을 본 프로젝트에 맞춰 적용했다. 기존 사용자 저장소의 정책이나 GitHub 보호 설정을 확인한 것으로 해석하지 않는다. ## 변경 단위와 검토 - 기준 브랜치 `main`, 단기 작업 브랜치 `feat/java-learning-mvp`처럼 `type/description`. 실제 이슈가 있으면 `type/number-description`; 번호를 만들지 않는다. -- 커밋과 PR 제목은 `type(scope): 변경 내용`. 타입은 feat, fix, docs, test, refactor, perf, style, build, ci, chore. scope는 web, desktop, curriculum, evidence, examples, ci 중 실제 변경 대상에 맞게 사용한다. `desktop`은 설치형 앱·포함 도구·OS별 패키징에 사용한다. 이 목록은 팀의 추가 규칙이다. +- 커밋과 PR 제목은 `type(scope): 변경 내용`. 타입은 feat, fix, docs, test, refactor, perf, style, build, ci, chore. scope는 web, curriculum, evidence, examples, ci 중 실제 변경 대상에 맞게 사용한다. 이 목록은 팀의 추가 규칙이다. - 하나의 PR은 하나의 목적을 갖는다. 구현 중에는 Draft PR을 사용한다. 중요 변경은 구현자 외 담당자의 독립 검토와 관련 검사 결과를 남긴다. 에이전트 검토를 사람의 GitHub 승인으로 꾸미지 않는다. - `main` 강제 푸시와 기존 작업 되돌리기를 금지한다. 병합과 운영 배포는 사용자 승인 범위 안에서만 수행한다. PR 준비는 자동 병합 권한을 뜻하지 않는다. -- 스킬 채택은 커밋·푸시·PR 생성·병합·릴리스 게시·보호 설정 변경 권한을 추가하지 않는다. 현재 대화에서 확인한 승인 범위만 활용하고, 자동 승인 검토에서 차단된 작업은 승인 없이 우회하지 않는다. 관련 파일만 반영하고 로컬 작성·로컬 커밋·원격 반영을 구분한다. - `[필수]`, `[제안]`, `[질문]`, `[사소]`로 리뷰 의도를 표시한다. 필수 문제 해결 후 해당 실패 조건을 다시 검사한다. ## 교육 콘텐츠 기준 diff --git a/README.md b/README.md index 9c2dff8..2b179e6 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,7 @@ ## 소개 -Java Edu는 **Windows·macOS에서 코드를 직접 실행하는 학습 앱과 웹 교재**를 함께 만드는 한국어 교육 프로젝트입니다. 다른 언어 경험자와 현직 Java 개발자가 “메서드에 객체를 넘겼는데 왜 결과가 달라질까?”, “트랜잭션을 붙였는데 왜 데이터가 남을까?” 같은 질문을 실제 코드로 확인하도록 돕습니다. - -**macOS Intel·Apple Silicon 검증을 통과했습니다.** v0.4.0 검증용 DMG·ZIP에는 Java·Maven이 포함됩니다. macOS 13 Ventura 이상을 대상으로 하며, 실제 검증은 GitHub Actions의 macOS 15 Intel x64·Apple Silicon arm64에서 수행했습니다. [Mac 설치·빌드 안내](docs/macos-installation.md) · [실제 검증 결과](https://github.com/jbsim999/java-edu/actions/runs/38406992540) +Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교재**를 함께 만드는 한국어 교육 프로젝트입니다. 다른 언어 경험자와 현직 Java 개발자가 “메서드에 객체를 넘겼는데 왜 결과가 달라질까?”, “트랜잭션을 붙였는데 왜 데이터가 남을까?” 같은 질문을 실제 코드로 확인하도록 돕습니다. 수업은 **이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩** 순서로 진행합니다. Java 언어·표준 API는 Oracle/OpenJDK 공식 문서를, 프레임워크·라이브러리는 각 프로젝트의 공식 문서를 기준으로 합니다. 실무 주제에는 출처를 기록한 채용 공고의 기술 요건도 연결합니다. @@ -38,23 +36,6 @@ Java·Maven·Node.js가 포함돼 있어 따로 설치하거나 폴더를 등록 [자세한 설치 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) · [릴리스와 체크섬](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) -### macOS 설치 안내 — v0.4.0 검증용 빌드 - -Intel·Apple Silicon에서 DMG·ZIP 최종 앱 실행 검증을 모두 통과했습니다. Apple 메뉴 → 이 Mac에 관하여에서 **칩**이 Apple M 계열이면 `arm64`, **프로세서**가 Intel이면 `x64` 파일을 선택합니다. - -| Mac | 검증용 파일 | -| --- | --- | -| Apple Silicon | [arm64 CI 산출물](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11818354876) | -| Intel | [x64 CI 산출물](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11819915823) | - -CI 산출물 ZIP을 먼저 해제하면 `desktop-release/`에 해당 CPU의 DMG와 앱 ZIP이 있습니다. 검증 기록도 함께 포함되며 보존 기간은 생성 후 14일입니다. 정식 GitHub Release로 게시한 파일은 아닙니다. [패키지 SHA256·검증 범위](docs/macos-verification.md) - -1. 자신의 CPU에 맞는 **DMG**를 열고 **Java Edu**를 **Applications**로 끌어 놓습니다. -2. 복사가 끝나면 디스크 이미지를 추출하고, 응용 프로그램에서 **Java Edu**를 엽니다. **ZIP**은 전체 `Java Edu.app`을 해제해 응용 프로그램으로 옮깁니다. -3. 첫 코드 실습에서 실행에 동의합니다. 포함 JDK 21·Maven 3.10.0을 사용하며, Spring의 첫 환경 준비에는 인터넷이 필요합니다. - -Mac 프리뷰는 Apple Developer ID 서명·공증을 제공하지 않습니다. 최초 실행이 차단되면 출처와 체크섬을 확인한 후 Apple의 [개별 앱 실행 안내](https://support.apple.com/102445)를 따릅니다. 실제 다운로드·Gatekeeper 흐름은 CI 실행 검증과 별도입니다. [자세한 Mac 안내](docs/macos-installation.md) - ## 학습실 미리보기 ![Java Edu 학습실: 수업 목록, 과제 설명, Java 문법 색상과 직접 코딩 편집기](docs/assets/learning-lab.png) @@ -108,7 +89,7 @@ npm ci npm run dev ``` -브라우저에서 `http://127.0.0.1:4173`을 엽니다. 실무 코드는 웹에서 편집하고 프로젝트 ZIP을 내려받을 수 있습니다. Spring 프로젝트 실행은 Windows·macOS 앱 또는 로컬 IDE에서 진행합니다. +브라우저에서 `http://127.0.0.1:4173`을 엽니다. 실무 코드는 웹에서 편집하고 프로젝트 ZIP을 내려받을 수 있습니다. Spring 프로젝트 실행은 Windows 앱 또는 로컬 IDE에서 진행합니다. **Windows 학습 앱 실행** @@ -131,26 +112,9 @@ npm run build:desktop
-
-macOS 설치 파일 직접 빌드 - -macOS 13 이상, Node.js 22.12 이상에서 해당 CPU의 Mac으로 빌드합니다. Mac 구현은 공통 개발 브랜치 `feat/java-learning-mvp`에 통합돼 있습니다. 위에서 저장소를 내려받았다면 같은 브랜치에서 진행합니다. - -```sh -npm ci -npm run prepare:mac -npm run check:bundle -npm run build:desktop:mac -- --arm64 -# Intel Mac에서는 --arm64 대신 --x64를 사용합니다. -``` - -공식 macOS JDK·Maven 아카이브의 고정 SHA256을 확인하고 전체 배포본을 포함합니다. `desktop-release/`에 `Java-Edu-0.4.0-mac-arm64.dmg` 또는 `Java-Edu-0.4.0-mac-x64.dmg`와 해당 ZIP을 생성합니다. 기존 스테이징 결과를 덮어쓰지 않습니다. 최종 패키지 자동 검사는 개인 학습 기록을 보호하기 위해 일회용 macOS CI에서 수행합니다. [빌드·실행 경계](docs/macos-installation.md) · [검증 기록](docs/macos-verification.md) - -
- ### 실행 방식과 버전 -| 구분 | Windows·macOS 앱 | 웹 교재 | +| 구분 | Windows 앱 | 웹 교재 | | --- | --- | --- | | Java 코어 | 기본 포함 JVM 21, 컴파일 대상 8·11·17·21; 다른 실제 JDK는 선택 등록 | Wandbox의 JVM 21, 과제별 최소 지원 버전으로 컴파일 | | Spring 프로젝트 | 컴파일 대상 17, 포함 JDK 21·Maven 3.10.0 사용 | 교재·파일 편집·ZIP 다운로드 | @@ -170,15 +134,11 @@ npm run build:desktop:mac -- --arm64 | `npm run test:browser` | 실행 중인 로컬 서버의 모의 API 브라우저 검사 | | `npm run test:theory` | 이론 도식·원문 보존·키보드·반응형 검사 | | `npm run build` / `npm run test:worker` | 웹 Worker 빌드·산출물 검사 | -| `npm run prepare:mac` / `npm run build:desktop:mac` | Mac 공식 포함 도구 준비·네이티브 DMG/ZIP 빌드 | -| `node scripts/verify-macos-packages.mjs --arch arm64` 또는 `x64` | 일회용 Mac CI에서 최종 패키지·실제 앱 실행·Java/Spring 제출·재시작 저장 검사 | 실무 검사는 `JAVA_HOME`·`MAVEN_HOME` 설정이 필요합니다. 기본 검사는 Redis/Kafka를 제외합니다. 브라우저 검사는 실행 제공자의 실제 동작 검증과 구분합니다. 2026-10-13 기준, v0.3.1 앱 소스 `6ce0428`의 [CI 12작업](https://github.com/jbsim999/java-edu/actions/runs/38338837092)이 모두 성공했습니다. 독립 검수에서 읽기 안내 **24/24**, 기존 기능 **49/49**, 도식 **19/19**, 정적 검사 **5/5**가 통과했고 새 EXE·ZIP의 전체 파일과 포함 도구 604개를 대조했습니다. 사용 중인 앱과 학습 기록을 보존하기 위해 **신버전의 실제 설치·기동 검사는 미실행**입니다. 이전 v0.3.0 설치·제거 검증과 구분합니다. [이번 검증 범위](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/readability-release-qa-report.md) · [검증 기록](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/verification.md) -2026-10-13, 소스 `1f93d9e`의 [Mac 패키지 CI](https://github.com/jbsim999/java-edu/actions/runs/38406992540)가 **Intel·Apple Silicon·Windows 회귀 3작업 모두 성공**했습니다. 두 Mac에서 콘텐츠40 PASS/원격 제공자1 SKIP, 로컬32 PASS/Windows 전용1 SKIP를 확인했습니다. CPU별 DMG·ZIP 총 4개의 포함 도구 무결성·서명·소스 동일성과 실제 앱의 Java8/21 대상 제출·Spring/H2 환경 준비·정답/미완성 판정·재시작 저장이 통과했습니다. 포함 JVM은 21이며 실제 JDK8 실행 검증과 구분합니다. Apple Developer ID 공증·다운로드 후 Gatekeeper·개인 Mac 사용 검증은 미실행입니다. [Mac 검증 기록과 체크섬](docs/macos-verification.md) - ## 문서와 로드맵 | 찾는 내용 | 문서 | @@ -191,7 +151,6 @@ npm run build:desktop:mac -- --arm64 | 구조와 실행 경계 | [아키텍처](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/architecture.md) | | 통합 ZIP·설치 앱 검증 | [번들 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-desktop-qa-report.md) · [이전 0.2 설치 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installed-desktop-qa-report.md) | | 현재 작업·출시 상태 | [마일스톤](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/milestones.md) · [개발 인계](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/development-handoff.md) | -| macOS 설치·빌드·검증 | [Mac 안내](docs/macos-installation.md) · [Mac 검증 상태](docs/macos-verification.md) | - [x] Java 코어 12수업과 직접 코딩 과제 - [x] Java 문법 강조·3테마·다중 파일 편집 @@ -203,7 +162,6 @@ npm run build:desktop:mac -- --arm64 - [x] Java·Maven 포함 Windows EXE와 전체 ZIP 패키징 - [x] [Windows v0.3.1 통합 프리뷰 릴리스](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) - [ ] 선택 과정의 Redis/Kafka 실제 연동 검증 -- [x] macOS Intel·Apple Silicon 최종 DMG/ZIP 빌드와 실제 앱 실행 검증 ## 기여와 문의 diff --git a/desktop/build-config.cjs b/desktop/build-config.cjs index 181e0a0..c6b90d0 100644 --- a/desktop/build-config.cjs +++ b/desktop/build-config.cjs @@ -1,8 +1,5 @@ const path = require('node:path'); const { loadBundledRuntime } = require('./bundled-runtime.cjs'); -const architectures = ['ia32', 'x64', 'armv7l', 'arm64', 'universal']; -const targetOptions = (context) => ({ expectedPlatform: context.electronPlatformName, expectedArch: architectures[context.arch] }); -const packagedBundle = (context) => path.join(context.appOutDir, ...(context.electronPlatformName === 'darwin' ? ['Java Edu.app', 'Contents', 'Resources'] : ['resources']), 'toolchains'); module.exports = { appId: 'kr.javaedu.desktop', @@ -10,24 +7,11 @@ module.exports = { asar: true, asarUnpack: ['desktop/job-runner.ps1'], extraResources: [{ from: 'work/desktop-bundle', to: 'toolchains', filter: ['**/*'] }], - beforePack: async (context) => { await loadBundledRuntime(path.join(__dirname, '..', 'work', 'desktop-bundle'), targetOptions(context)); }, - afterPack: async (context) => { await loadBundledRuntime(packagedBundle(context), targetOptions(context)); }, - afterSign: async (context) => { if (context.electronPlatformName === 'darwin') await loadBundledRuntime(packagedBundle(context), targetOptions(context)); }, + beforePack: async () => { await loadBundledRuntime(path.join(__dirname, '..', 'work', 'desktop-bundle')); }, + afterPack: async ({ appOutDir }) => { await loadBundledRuntime(path.join(appOutDir, 'resources', 'toolchains')); }, directories: { output: 'desktop-release' }, files: ['desktop/**', 'server/**', 'dist/**', '!dist/server/**', '!dist/.openai/**', 'curriculum/professional/**', 'package.json', '!**/target/**', '!**/node_modules/**'], win: { target: [{ target: 'nsis', arch: ['x64'] }, { target: 'zip', arch: ['x64'] }], artifactName: 'Java-Edu-${version}-${arch}.${ext}', signAndEditExecutable: false }, - mac: { - target: ['dmg', 'zip'], - artifactName: 'Java-Edu-${version}-mac-${arch}.${ext}', - category: 'public.app-category.education', - minimumSystemVersion: '13.0.0', - identity: '-', - hardenedRuntime: false, - notarize: false, - // Preserve every vendor JDK byte and its original signature. - signIgnore: ['Contents/Resources/toolchains(?:/|$)'], - }, - dmg: { sign: false, title: 'Java Edu ${version}', contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: 'link', path: '/Applications' }] }, // Preserve vendor ARM64 files: the bundled NSIS 7z decoder predates the ARM64 codec. nsis: { artifactName: 'Java-Edu-Setup-${version}-${arch}.${ext}', differentialPackage: false, useZip: true, oneClick: false, perMachine: false, allowElevation: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true, createStartMenuShortcut: true, runAfterFinish: false, deleteAppDataOnUninstall: false }, publish: null, diff --git a/desktop/bundled-runtime.cjs b/desktop/bundled-runtime.cjs index ad74e67..bde7e94 100644 --- a/desktop/bundled-runtime.cjs +++ b/desktop/bundled-runtime.cjs @@ -1,6 +1,6 @@ const { createHash } = require('node:crypto'); const { createReadStream } = require('node:fs'); -const { lstat, readFile, readlink, readdir, realpath } = require('node:fs/promises'); +const { lstat, readFile, readdir, realpath } = require('node:fs/promises'); const path = require('node:path'); const JAVA_VERSIONS = [8, 11, 17, 21]; @@ -51,14 +51,8 @@ async function sha256File(file) { return hash.digest('hex'); } -function inside(root, child) { - const relative = path.relative(root, child); - return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); -} - -/** Preserve vendor links verbatim, but never follow links while enumerating a tree. */ -async function inventoryTree(root, { excludeManifest = false, allowSymlinks = false, includeExecutable = false, linkRoots = [root] } = {}) { - const canonicalRoot = await realpath(root); +/** No links or other special files may redirect packaged toolchains outside their root. */ +async function inventoryTree(root, { excludeManifest = false } = {}) { const files = []; async function visit(directory, prefix = '') { for (const item of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en'))) { @@ -66,21 +60,11 @@ async function inventoryTree(root, { excludeManifest = false, allowSymlinks = fa if (excludeManifest && relative === 'manifest.json') continue; const file = path.join(directory, item.name); const info = await lstat(file); - if (info.isSymbolicLink()) { - if (!allowSymlinks) throw failure('linked distribution file'); - const target = await readlink(file); - if (!target || path.isAbsolute(target) || path.win32.isAbsolute(target) || /[\\\x00-\x1f]/.test(target)) throw failure('invalid distribution link'); - const resolved = await realpath(file).catch(() => { throw failure('missing or cyclic distribution link'); }); - const owner = linkRoots.find((candidate) => inside(candidate, file)); - if (!owner || !inside(canonicalRoot, resolved) || !inside(owner, resolved)) throw failure('escaping distribution link'); - const targetInfo = await lstat(resolved); - if (!targetInfo.isFile() && !targetInfo.isDirectory()) throw failure('unsupported distribution link'); - if (files.length >= MAX_FILES) throw failure('too many distribution files'); - files.push({ path: relative, type: 'symlink', target, size: Buffer.byteLength(target), sha256: createHash('sha256').update(target).digest('hex') }); - } else if (info.isDirectory()) await visit(file, relative); + if (info.isSymbolicLink()) throw failure('linked distribution file'); + if (info.isDirectory()) await visit(file, relative); else if (info.isFile()) { if (files.length >= MAX_FILES) throw failure('too many distribution files'); - files.push({ path: relative, size: info.size, sha256: await sha256File(file), ...(includeExecutable ? { executable: (info.mode & 0o111) !== 0 } : {}) }); + files.push({ path: relative, size: info.size, sha256: await sha256File(file) }); } else throw failure('unsupported distribution file'); } } @@ -89,7 +73,7 @@ async function inventoryTree(root, { excludeManifest = false, allowSymlinks = fa } /** Check all packaged bytes before using metadata. The manifest is integrity data, not a signature. */ -async function inspectBundledRuntime(directory, { required = true, expectedPlatform = process.platform, expectedArch = process.arch } = {}) { +async function inspectBundledRuntime(directory, { required = true } = {}) { if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw failure('absolute bundle root required'); const rootInfo = await lstat(directory).catch((error) => { if (!required && error.code === 'ENOENT') return null; throw error; }); if (!rootInfo) return null; @@ -100,18 +84,14 @@ async function inspectBundledRuntime(directory, { required = true, expectedPlatf if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_MANIFEST_BYTES) throw failure('invalid manifest file'); let manifest; try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); } catch { throw failure('invalid manifest JSON'); } - const windows = manifest.schemaVersion === 1 && manifest.platform === 'win32' && manifest.arch === 'x64'; - const mac = manifest.schemaVersion === 2 && manifest.platform === 'darwin' && ['x64', 'arm64'].includes(manifest.arch); - if ((!windows && !mac) || manifest.platform !== expectedPlatform || manifest.arch !== expectedArch + if (manifest.schemaVersion !== 1 || manifest.platform !== 'win32' || manifest.arch !== 'x64' || !Array.isArray(manifest.jdks) || !manifest.jdks.length || manifest.jdks.length > JAVA_VERSIONS.length || !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > MAX_FILES || !SHA256.test(manifest.id) || manifest.id !== manifestIdentity(manifest)) throw failure('unsupported manifest'); const versions = new Set(); for (const jdk of manifest.jdks) { validateProvenance(jdk); - if (!JAVA_VERSIONS.includes(jdk.major) || versions.has(jdk.major) - || jdk.home !== `jdks/${jdk.major}${mac ? '/Contents/Home' : ''}` - || (mac ? jdk.distributionRoot !== `jdks/${jdk.major}` : jdk.distributionRoot !== undefined)) throw failure('invalid JDK layout'); + if (!JAVA_VERSIONS.includes(jdk.major) || versions.has(jdk.major) || jdk.home !== `jdks/${jdk.major}`) throw failure('invalid JDK layout'); versions.add(jdk.major); } if (!versions.has(21)) throw failure('default Java 21 missing'); @@ -122,28 +102,20 @@ async function inspectBundledRuntime(directory, { required = true, expectedPlatf for (const file of manifest.files) { const name = safeRelative(file.path); if (!SHA256.test(file.sha256) || !Number.isSafeInteger(file.size) || file.size < 0 || expected.has(name) || insensitive.has(name.toLowerCase()) - || !['THIRD-PARTY-NOTICES.txt', ...manifest.jdks.map((jdk) => `${jdk.distributionRoot || jdk.home}/`), 'maven/'].some((prefix) => prefix.endsWith('/') ? name.startsWith(prefix) : name === prefix)) throw failure('invalid file inventory'); - if (mac && (file.type === 'symlink' ? typeof file.target !== 'string' || file.executable !== undefined : file.type !== undefined || typeof file.executable !== 'boolean')) throw failure('invalid Unix file inventory'); + || !['THIRD-PARTY-NOTICES.txt', ...manifest.jdks.map((jdk) => `${jdk.home}/`), 'maven/'].some((prefix) => prefix.endsWith('/') ? name.startsWith(prefix) : name === prefix)) throw failure('invalid file inventory'); expected.set(name, file); insensitive.add(name.toLowerCase()); } for (const jdk of manifest.jdks) { - for (const name of [mac ? 'bin/java' : 'bin/java.exe', mac ? 'bin/javac' : 'bin/javac.exe', 'release', ...jdk.licenseFiles]) if (!expected.has(`${jdk.home}/${name}`)) throw failure('missing JDK or license file'); - if (mac) for (const name of ['bin/java', 'bin/javac']) if (expected.get(`${jdk.home}/${name}`).executable !== true) throw failure('JDK executable permission missing'); + for (const name of ['bin/java.exe', 'bin/javac.exe', 'release', ...jdk.licenseFiles]) if (!expected.has(`${jdk.home}/${name}`)) throw failure('missing JDK or license file'); } for (const name of ['bin/m2.conf', ...manifest.maven.licenseFiles]) if (!expected.has(`maven/${name}`)) throw failure('missing Maven or license file'); if (!expected.has('THIRD-PARTY-NOTICES.txt') || !manifest.files.some((file) => /^maven\/boot\/plexus-classworlds-[A-Za-z0-9.-]+\.jar$/.test(file.path)) || !expected.has(`maven/lib/maven-core-${manifest.maven.version}.jar`)) throw failure('incomplete Maven distribution'); - const actual = await inventoryTree(root, { excludeManifest: true, allowSymlinks: mac, includeExecutable: mac, - linkRoots: [...manifest.jdks.map((jdk) => path.join(root, jdk.distributionRoot || jdk.home)), path.join(root, 'maven')] }); - if (actual.length !== expected.size || actual.some((file) => { - const listed = expected.get(file.path); - return listed?.sha256 !== file.sha256 || listed?.size !== file.size || (mac && (listed.type !== file.type || listed.target !== file.target || listed.executable !== file.executable)); - })) throw failure('distribution integrity mismatch'); + const actual = await inventoryTree(root, { excludeManifest: true }); + if (actual.length !== expected.size || actual.some((file) => expected.get(file.path)?.sha256 !== file.sha256 || expected.get(file.path)?.size !== file.size)) throw failure('distribution integrity mismatch'); return { id: manifest.id, - platform: manifest.platform, - arch: manifest.arch, root, jdks: manifest.jdks.map((jdk) => ({ version: jdk.major, home: path.join(root, ...jdk.home.split('/')), description: `${jdk.distribution} ${jdk.version} · 앱 포함`, distribution: jdk.distribution, distributionVersion: jdk.version })), maven: { home: path.join(root, 'maven'), version: manifest.maven.version }, diff --git a/desktop/toolchain-provenance.darwin-arm64.json b/desktop/toolchain-provenance.darwin-arm64.json deleted file mode 100644 index cea873f..0000000 --- a/desktop/toolchain-provenance.darwin-arm64.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "checkedAt": "2026-10-13", - "platform": "darwin", - "arch": "arm64", - "jdks": [ - { - "major": 21, - "distribution": "Eclipse Temurin", - "version": "21.0.12.1+1", - "sourceUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.12.1_1.tar.gz", - "archiveSha256": "3623232f33a9c3baadf304480b2535f9a3cba8a58d42ecbb438ba267315d9998", - "license": "GPL-2.0-with-classpath-exception", - "licenseFiles": [ - "legal/java.base/LICENSE", - "legal/java.base/ASSEMBLY_EXCEPTION", - "NOTICE" - ], - "sourceCodeUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", - "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", - "redistributionSourceFiles": [ - "OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", - "Third-Party-Sources-0.3.0.zip/temurin-build-e6ba7dec3d07654074559310376a3ae89da5f4ac.tar.gz" - ] - } - ], - "maven": { - "distribution": "Apache Maven", - "version": "3.10.0", - "sourceUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/binaries/apache-maven-3.10.0-bin.zip", - "archiveSha256": "1f6d9909266510f039f59aa0e13dcd2c66da85f043e56276e41f2918f8bddaff", - "license": "Apache-2.0 and bundled third-party licenses", - "licenseFiles": [ - "LICENSE", - "NOTICE", - "lib/org.eclipse.sisu.inject.license", - "lib/org.eclipse.sisu.plexus.license", - "lib/javax.annotation-api.license" - ], - "sourceCodeUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/source/apache-maven-3.10.0-src.zip", - "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", - "redistributionSourceFiles": [ - "Third-Party-Sources-0.3.0.zip/apache-maven-3.10.0-src.zip", - "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.inject-1.1.0-sources.jar", - "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.plexus-1.1.0-sources.jar", - "Third-Party-Sources-0.3.0.zip/javax.annotation-api-1.3.2-sources.jar" - ] - } -} diff --git a/desktop/toolchain-provenance.darwin-x64.json b/desktop/toolchain-provenance.darwin-x64.json deleted file mode 100644 index 55bb41b..0000000 --- a/desktop/toolchain-provenance.darwin-x64.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "checkedAt": "2026-10-13", - "platform": "darwin", - "arch": "x64", - "jdks": [ - { - "major": 21, - "distribution": "Eclipse Temurin", - "version": "21.0.12.1+1", - "sourceUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_mac_hotspot_21.0.12.1_1.tar.gz", - "archiveSha256": "44db0f08196daf19a47f90d13388b0c943b67663cb537f998fe29e836fa842ce", - "license": "GPL-2.0-with-classpath-exception", - "licenseFiles": [ - "legal/java.base/LICENSE", - "legal/java.base/ASSEMBLY_EXCEPTION", - "NOTICE" - ], - "sourceCodeUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", - "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", - "redistributionSourceFiles": [ - "OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", - "Third-Party-Sources-0.3.0.zip/temurin-build-e6ba7dec3d07654074559310376a3ae89da5f4ac.tar.gz" - ] - } - ], - "maven": { - "distribution": "Apache Maven", - "version": "3.10.0", - "sourceUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/binaries/apache-maven-3.10.0-bin.zip", - "archiveSha256": "1f6d9909266510f039f59aa0e13dcd2c66da85f043e56276e41f2918f8bddaff", - "license": "Apache-2.0 and bundled third-party licenses", - "licenseFiles": [ - "LICENSE", - "NOTICE", - "lib/org.eclipse.sisu.inject.license", - "lib/org.eclipse.sisu.plexus.license", - "lib/javax.annotation-api.license" - ], - "sourceCodeUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/source/apache-maven-3.10.0-src.zip", - "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", - "redistributionSourceFiles": [ - "Third-Party-Sources-0.3.0.zip/apache-maven-3.10.0-src.zip", - "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.inject-1.1.0-sources.jar", - "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.plexus-1.1.0-sources.jar", - "Third-Party-Sources-0.3.0.zip/javax.annotation-api-1.3.2-sources.jar" - ] - } -} diff --git a/docs/macos-installation.md b/docs/macos-installation.md deleted file mode 100644 index 237e4db..0000000 --- a/docs/macos-installation.md +++ /dev/null @@ -1,42 +0,0 @@ -# macOS 설치와 패키징 - -## 지원 환경 - -macOS 13 Ventura 이상, Intel x64 또는 Apple Silicon arm64. Electron44는 macOS12를 지원하지 않는다. Apple 메뉴 → 이 Mac에 관하여에서 **칩**이 Apple M 계열이면 arm64, **프로세서**가 Intel이면 x64를 선택한다. 두 CPU의 JDK를 섞거나 Windows EXE를 Mac에서 실행하지 않는다. - -패키지는 `Java-Edu-0.4.0-mac-arm64.dmg` / `Java-Edu-0.4.0-mac-x64.dmg`와 같은 CPU별 DMG·ZIP이다. [실제 Mac CI](https://github.com/jbsim999/java-edu/actions/runs/38406992540)에서 두 CPU의 최종 DMG·ZIP 앱 실행이 통과했다. 실제 검사 환경은 macOS 15이며 다른 지원 OS 버전을 모두 검사한 것은 아니다. 현재 파일은 14일간 보존되는 CI 산출물이며 정식 릴리스로 게시하지 않았다. [Apple Silicon 파일](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11818354876) · [Intel 파일](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11819915823) · [체크섬·검증 범위](macos-verification.md). 산출물 ZIP을 먼저 풀고 `desktop-release/`에서 자신의 CPU에 맞는 DMG 또는 앱 ZIP을 선택한다. - -## 설치와 첫 실행 - -1. 자신의 CPU에 맞는 DMG를 열고 **Java Edu**를 **Applications**로 끌어 놓는다. 복사가 끝나면 디스크 이미지를 추출한다. -2. 응용 프로그램에서 **Java Edu**를 연다. ZIP을 선택했다면 전체 `Java Edu.app`을 해제해 응용 프로그램으로 옮긴다. `.app` 내부의 실행 파일·JDK만 분리하지 않는다. -3. 처음 코드 실습에서 실행 동의 내용을 읽고 동의한다. Java21·Maven3.10.0은 앱에 포함되어 있어 별도로 설치하거나 시스템 PATH를 바꾸지 않는다. Spring 실습은 처음 **환경 준비**할 때 인터넷이 필요하다. - -프리뷰는 ad-hoc 서명이며 Apple Developer ID 서명·공증은 제공하지 않는다. 인터넷에서 내려받은 앱의 첫 실행이 차단되면 출처와 체크섬을 확인한 후 **시스템 설정 → 개인정보 보호 및 보안 → 그래도 열기**로 해당 앱을 허용할 수 있다. macOS 보안 기능 전체를 끄거나 quarantine을 일괄 제거하지 않는다. 조직 관리 정책에 따라 허용되지 않을 수 있다. CI의 실행 검증과 사용자의 실제 다운로드/Gatekeeper 흐름은 별개다. - -학습 기록은 해당 Mac의 `~/Library/Application Support/java-edu`에 저장한다. Windows·웹과 자동 동기화하지 않는다. 앱을 지워도 사용자 기록을 자동 삭제하는 제거 스크립트는 제공하지 않는다. 기존 앱 교체 전에는 앱을 정상 종료한다. - -## Mac에서 직접 빌드 - -Node22.12 이상에서 공통 개발 브랜치 `feat/java-learning-mvp`의 저장소 의존성을 설치한 후 해당 CPU의 Mac에서 아래 순서로 실행한다. macOS 구현은 이 브랜치에 통합되어 별도 브랜치 전환이 필요 없다. 원본 아카이브·스테이징이 이미 존재하면 덮어쓰지 않고 중단한다. 재빌드는 기존 결과를 먼저 별도로 보존한다. - -```sh -npm ci -npm run prepare:mac -npm run check:bundle -npm run build:desktop:mac -- --arm64 -# Intel에서는 마지막 인수를 --x64로 선택한다. -``` - -최종 패키지 자동 검사는 개인 Mac의 기본 학습 기록을 보호하기 위해 **GitHub Actions의 일회용 macOS 러너에서만** 실행한다. 로컬 빌드 뒤 기존 개인 데이터로 검사 스크립트를 실행하거나 CI 전용 보호 조건을 우회하지 않는다. CI에서는 `node scripts/verify-macos-packages.mjs --arch arm64` 또는 `--arch x64`를 사용한다. - -`prepare:mac`은 공식 Temurin21.0.12.1+1 macOS 아카이브와 Maven3.10.0 ZIP의 고정 SHA256을 대조한다. 공급자의 `jdk-21.0.12.1+1/Contents` 전체와 원본 공급자 서명을 보존한다. 런타임 경로는 앱 내부 `Contents/Resources/toolchains/jdks/21/Contents/Home`이다. 공급자 고지·대응 소스 위치는 포함된 `THIRD-PARTY-NOTICES.txt`와 기존 [번들 계약](bundled-runtime.md)을 따른다. - -macOS 프로세스 취소는 프로세스 그룹·시간·출력 한도와 JVM heap 제한을 사용한다. Windows Job Object의 전체 메모리·프로세스 수 제한 또는 강제 앱 종료 뒤 정리를 macOS에도 보장하지 않는다. 개인 학습 IDE이며 OS 보안 샌드박스가 아니다. - -## 공식 근거 - -- [Electron44의 macOS 최소 버전](https://www.electronjs.org/blog/electron-44-0) -- [electron-builder26 macOS 빌드](https://www.electron.build/v26/docs/mac/) -- [GitHub의 Intel·Apple Silicon macOS 러너](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) -- [Apple의 미공증 앱 실행 안내](https://support.apple.com/102445) diff --git a/docs/macos-packaging-plan.md b/docs/macos-packaging-plan.md deleted file mode 100644 index f476b78..0000000 --- a/docs/macos-packaging-plan.md +++ /dev/null @@ -1,22 +0,0 @@ -# macOS 통합 패키징 계획 — 2026-10-13 - -사용자 요청: 기존 Windows 학습 앱을 macOS에서도 설치하고 실행하도록 추가 패키징한다. 기준 소스는 `f9d1a51f862f29c4350dd2bc8768da80064d0acd`이며 기존 교재·Windows 패키징·학습 기록 계약을 보존한다. - -## 구현 기준 - -- Intel x64와 Apple Silicon arm64를 각각 해당 CPU의 macOS CI에서 빌드한다. universal/Rosetta 의존 패키지는 만들지 않는다. -- 각 DMG와 ZIP에 Electron, 정확히 고정한 Temurin JDK21 macOS 배포본 전체, Maven3.10.0을 포함한다. 학습자에게 별도 JDK/Maven 설치를 요구하지 않는다. -- macOS JDK의 전체 `.jdk/Contents` 구조와 원본 서명·라이선스·상대 링크를 보존한다. Java home은 `Contents/Home`이다. 링크는 같은 배포본 내부에서만 해석되며 파일 SHA256·링크 문자열·실행 비트를 검증한다. -- 기존 Windows schema1 번들은 계속 지원한다. Mac schema2는 OS·CPU 불일치를 거절한다. 빌드 도구만 명시적으로 대상 플랫폼을 전달한다. -- 기존 로컬 엔진의 동의·loopback 인증·고정 Maven 호출·파일 편집 제한·학습 저장을 유지한다. macOS는 프로세스 그룹 취소·시간·출력 제한 및 JVM heap 제한을 사용한다. Windows Job Object의 전체 메모리/프로세스 수 제한이나 강제 앱 종료 후 자손 종료를 macOS에서도 보장한다고 주장하지 않는다. -- 앱에는 ad-hoc 서명을 적용하며 포함된 공급자 JDK 서명을 변경하지 않는다. Apple Developer ID 인증서와 공증 자격증명이 없는 상태에서 공증 완료라고 표시하지 않는다. Gatekeeper 최초 다운로드 흐름은 CI 실행과 별도 확인 범위다. - -## 완료 조건과 근거 - -1. Windows 회귀와 Mac 번들/경로/링크/CPU 계약 검사 성공. -2. 두 네이티브 Mac CI에서 공식 아카이브 SHA256 확인 → 스테이징 → DMG/ZIP 빌드. -3. 최종 DMG를 읽기 전용으로 마운트하여 격리된 Applications 폴더에 복사, 최종 ZIP은 별도 한글·공백·특수문자 경로에 해제. 각 실제 앱의 전체 파일/링크·번들 무결성과 소스 ASAR 대조. -4. 호스트 도구를 등록하지 않은 실제 패키지 실행, 명시적 동의, Java8/21 컴파일 대상 제출, Spring/H2 준비·정답 제출·미완성 실패, 재시작 후 저장 복원, 실제 화면 캡처. -5. 구현과 다른 읽기 전용 문맥에서 독립 검수. CI·패키지 해시·미검증을 출시 기록에 구분. - -빌드/실행/패키징은 이번 요청 범위다. 기존 앱과 개인 학습 데이터·저장소 history는 보존한다. 실제 검증이 실패하면 해결하거나 미완료로 기록한다. diff --git a/docs/macos-verification.md b/docs/macos-verification.md deleted file mode 100644 index 596908d..0000000 --- a/docs/macos-verification.md +++ /dev/null @@ -1,66 +0,0 @@ -# macOS 패키지 검증 — v0.4.0 - -2026-10-13. 기준 소스 `f9d1a51`에 macOS Intel x64·Apple Silicon arm64 패키징을 추가했다. 실제 검증 소스는 `1f93d9ef3a6f089c59e13778ff2b7ce9c6ea243c`이며 [Mac CI 38406992540](https://github.com/jbsim999/java-edu/actions/runs/38406992540)의 Intel·Apple Silicon·Windows 회귀 3작업이 모두 PASS다. 기존 Windows v0.3.1 릴리스는 변경하지 않는다. - -현재 구현과 실제 실행의 상태를 분리한다. - -| 검사 | 현재 근거 | -| --- | --- | -| Windows 실행 회귀 | 실제 Windows CI 작업 PASS; 이전 Node24.19 로컬 검사 24 PASS / macOS·Unix 4 SKIP, `work/macos-local-review/windows-local.log` | -| 콘텐츠·API·편집기 정합성 | Node40 PASS / 실제 원격 제공자1 SKIP | -| 웹 Worker | 빌드 및 CSP·인증·정적 파일 검사 PASS | -| electron-builder26 설정 | 실제 설치된26.15.3의 설정 schema 검사 PASS | -| macOS 네이티브 모드·링크·실행 | macOS 15 Intel·Apple Silicon 각각 로컬32 PASS / Windows 전용1 SKIP; 공급자 아카이브 SHA256·포함 도구 실행 PASS | -| 최종 DMG·ZIP 앱 실행 | CPU별 DMG·ZIP 총4개 PASS, 앱별 실제 실행7검사로 총28 PASS | -| 독립 코드 검토 | 최종 소스 PASS, 남은 정적 차단 없음. 별도 검수 문맥에서 loader8 PASS/3 SKIP·Windows 감독1 PASS/1 SKIP·서명 제외 helper 정적 확인 | -| 최종 독립 수용 | 실제 CI 보고서·전체 인벤토리·네 패키지 실행 기록·대표 화면4종 대조 PASS; 남은 차단 결함 없음 | -| Apple Developer ID·공증 | 자격증명 미확인, 적용하지 않음 | -| 실제 사용자 다운로드/Gatekeeper | 미검증 | - -`.github/workflows/package-macos.yml`은 네이티브 macos-15-intel / macos-15에서 공식 아카이브 확인, 포함 도구 실행, DMG·ZIP 빌드, 실제 최종 앱 검사와 화면 캡처를 수행한다. 정답 제출과 미완성 실패를 분리하고, 실제 실행하지 않은 검사를 성공으로 표시하지 않는다. ephemeral CI의 기본 사용자 데이터만 새로 생성하고 소유권을 확인한 검사 데이터는 증거 폴더로 보존한다. 기존 사용자 데이터가 있으면 실행을 중단한다. - -검증 파일은 해당 CI의 산출물이며 보존 기간은 14일이다. 정식 GitHub Release 게시·main 병합은 수행하지 않았다. 문서 후속 커밋과 패키지 생성 소스를 구분하며 아래 체크섬은 `1f93d9e`에서 생성한 파일의 값이다. - -독립 검수에서 Unix 정상 종료 후 자손이 stdout/stderr를 상속하면 `close`가 늦어져 timeout으로 오인될 수 있음을 발견했다. `exit` 시점에 남은 프로세스 그룹을 정리하고 상속 파이프 회귀 검사를 추가했다. Windows 실제 감독 회귀와 두 Mac의 Unix 실제 검사에서 PASS를 확인했다. 개발자 빌드 안내의 CI 전용 QA 명령도 별도 단락으로 정정했다. - -현재 사용자는 검증용 `codex/macos-packaging` 브랜치의 선행 커밋·푸시와 GitHub Actions의 Intel·Apple Silicon 검증을 명시적으로 승인했다. 이전 선행 푸시 보류는 해제됐다. 두 환경의 실제 최종 패키지 실행 검증을 확인한 뒤 README에 결과와 근거를 반영한다. README 설치 위치·기존 Windows 다운로드 안내 회귀 검사는 1 PASS, 문서 diff 검사 PASS다. 병합·릴리스 게시는 이 승인에 포함되지 않는다. - -소스 `e6a8479afe1fbb19c2164467e9c61b0852b7fabb`의 [첫 Mac CI](https://github.com/jbsim999/java-edu/actions/runs/38405962108)는 Windows 회귀 PASS, Intel·Apple Silicon의 콘텐츠/로컬 검사 PASS 후 공급자 아카이브 구조 확인 단계에서 FAIL이었다. SHA256 다운로드 검사는 통과했으며 패키징·최종 앱 실행은 이 실행에서 미실행이다. 실제 공식 JDK 아카이브의 최상위 폴더는 `.jdk` 확장자 없는 `jdk-21.0.12.1+1`이었다. 세 아카이브를 직접 내려받아 전체 목록·고정 SHA256을 확인한 근거는 `work/archive-layout-inspection/observed-layout.json`이다. 준비 스크립트를 정확한 공급자 폴더명·필수 Contents 구조 검사로 수정했으며 파일 이름·내용은 변경하지 않는다. 구조 회귀 검사는 Windows Node24에서 3/3 PASS다. 다운로드 직후부터 단계별 검증 자료를 보존하여 준비 단계 실패도 CI 산출물에 남긴다. - -## JDK 서명과 매니페스트 불일치 점검 - -사용자는 별도 Mac이 없음을 확인하고 `distribution integrity mismatch` 사례의 발생 가능성을 점검하도록 요청했다. 첨부 문구의 570개 중 72개 파일 불일치 수치는 실제 설치 파일·매니페스트·검사 로그를 제공받지 않았으므로 확인된 재현 결과가 아니다. JDK 네이티브 실행 파일과 dylib만 크기가 달라지고 Maven이 정상인 패턴은 재서명 가설과 부합하지만 원인 확정 근거는 아니다. [Apple 코드 서명 문서](https://developer.apple.com/library/archive/technotes/tn2206/)에 따르면 서명은 실행 파일을 변경한다. - -현재 설정은 전체 `Contents/Resources/toolchains`를 `mac.signIgnore`로 제외한다. 실제 고정된 electron-builder26.15.3의 `MacTargetHelper.buildSignOptions()`와 설치된 `@electron/osx-sign.signApp()`를 실행한 회귀 검사에서 JDK Java·javac·dylib 및 Maven 경로는 서명 명령에서 제외되고 Electron과 앱 본체는 서명 대상으로 유지됐다. 제외 설정을 제거한 대조 조건에서는 JDK도 서명 대상이었다. 서명 명령에는 `--deep`이 없으며 `--deep`은 검증 명령에만 있다. 이 검사는 네이티브 파일 탐색과 외부 명령을 대체한 **명령 선택 검사**이며 실제 Mac 서명 성공 근거가 아니다. - -빌드 도구의 실제 호출 순서는 리소스 복사 → `afterPack` → 서명 → `afterSign` → DMG/ZIP 생성이다. 두 훅에서 포함 도구의 전체 크기·SHA256·모드·링크를 검사한다. 변경을 발견하면 빌드를 중단하며 매니페스트를 다시 작성하지 않는다. signer의 디렉터리 탐색은 제외 적용 전에 `.cstemp` 임시 파일을 지울 수 있으므로 `signIgnore`만으로 모든 파일 변경이 불가능하다고 주장하지 않는다. 이런 변경도 후속 무결성 검사 대상이다. 최종 패키지 QA는 DMG·ZIP에서 추출한 도구 전체를 원본 스테이징과 다시 비교한다. - -Node24.19 Windows의 관련 검사 결과는 **9 PASS / 4 SKIP / 0 FAIL**이었다. `tests/local/mac-signing.test.mjs`, `tests/local/mac-bundled-runtime.test.mjs`, `tests/local/bundled-runtime.test.mjs`를 실행했다. 파일 크기가 달라지는 변조뿐 아니라 크기가 같은 변조도 공유 로더에서 거부함을 확인했다. Windows에서 SKIP했던 Mac 스키마 `afterSign` 훅의 실제 파일 변조 회귀 검사는 이후 두 native Mac CI에서 PASS했다. - -추가 검사에 대한 별도 읽기 전용 검토에서도 수정이 필요한 결함은 없었고 독립 실행 결과는 9 PASS / 4 SKIP / 0 FAIL이었다. 명령 선택 검사에서 provisioning·자동 entitlement 처리는 비활성화했으므로 그 단계까지 실제 검증한 것으로 해석하지 않는다. - -## 실제 최종 패키지 검증 결과 - -| 환경 | DMG | ZIP | 포함 도구 파일·링크 | 앱 실행 검사 | -| --- | --- | --- | --- | --- | -| macOS 15 Intel x64 | PASS | PASS | 571개, 공급자 스테이징과 전체 일치 | 각7/7 PASS | -| macOS 15 Apple Silicon arm64 | PASS | PASS | 573개, 공급자 스테이징과 전체 일치 | 각7/7 PASS | - -네 파일 모두 앱 전체 스테이징 인벤토리와 파일 내용·모드·링크를 대조했다. 패키지별 ASAR 소스222개가 빌드 소스와 일치하고 앱·JDK의 네이티브 CPU를 확인했다. 앱의 ad-hoc 서명과 JDK의 공급자 서명을 실제 `codesign --verify --deep --strict`로 검사했으며 모두 exit0이다. 서명 후 JDK/Maven 전체 바이트가 공급자 스테이징과 일치해 `distribution integrity mismatch`는 발생하지 않았다. Intel·Apple Silicon의 파일 수 차이는 각 공급자 배포본의 목록 차이이며 두 CPU 목록을 서로 같은 것으로 취급하지 않는다. - -DMG는 실제 마운트·앱 복사·마운트 해제 후 실행했고 ZIP은 별도 경로에 해제 후 실행했다. 경로에는 한글·공백·`&`·`%JAVA_HOME%`가 포함됐다. 앱은 `packaged:true`이고 기본 `~/Library/Application Support/java-edu`를 사용했다. 호스트 Java/Maven 환경변수와 PATH를 제외한 상태에서 포함 JVM21·Maven3.10.0을 실제 실행했다. 모의 API·가짜 출력·개발 Electron 실행으로 대체하지 않았다. - -각 앱의 실제7검사: 첫 실행의 도구 자동 등록·동의 전 실행 불가, JVM21에서 컴파일 대상8 제출, 대상21 제출, Spring/H2 온라인 준비와 초안 보존, 미완성 제출0/2 실패·성취 없음, 정답의 오프라인 제출2/2 성공, 종료·재실행 후 진도·테마·코어/프로젝트 초안 복원. 총28검사 PASS, 페이지 오류0, 검사 앱의 정상 종료 확인 후 소유한 테스트 데이터를 증거 폴더에 보관했다. 패키징 전에는 코어12과제와 Spring/H2 `tx-proxy`의 실제 포함 도구 smoke 검사도 두 CPU에서 PASS했다. - -| 파일 | 바이트 | SHA256 | -| --- | ---: | --- | -| `Java-Edu-0.4.0-mac-x64.dmg` | 339571139 | `c04e0199b3e797c8427efd52028f10fd4ea62b19d6142af97a9310b3d0cc7560` | -| `Java-Edu-0.4.0-mac-x64.zip` | 342834111 | `6a78e216df3bd5850fcaca2bf9b33562dd5d9d33114377c2fb70bb4cf7f8b51d` | -| `Java-Edu-0.4.0-mac-arm64.dmg` | 339633464 | `82c079d4116e915321d900947b68c87c0d2b8d74f1136426539e3f17b62cb561` | -| `Java-Edu-0.4.0-mac-arm64.zip` | 343050074 | `da266cbdc525db73e02c4b738ac39e9ffbebdc2ea6f6c77d3ae02303b4f98359` | - -[Intel 산출물](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11819915823) · [Apple Silicon 산출물](https://github.com/jbsim999/java-edu/actions/runs/38406992540/artifacts/11818354876). 두 산출물을 로컬 `work/macos-ci-38406992540/{x64,arm64}`로 내려받아 실제 네 파일의 크기와 SHA256을 CI 기록과 대조했고 모두 일치했다. 대조 기록은 `work/macos-ci-38406992540/downloaded-package-verification.json`이다. 원본 기록은 각 산출물의 `work/macos-qa//run-*/package-qa-results.json`·`dmg-runtime/runtime-results.json`·`zip-runtime/runtime-results.json`·화면 PNG이며, 공급자 아카이브 검증은 `work/macos-vendor-/download-verification.json`이다. - -실제 다운로드 후 Gatekeeper 승인, Apple Developer ID 서명·공증, macOS13/14 등 다른 OS 버전, 개인 Mac 사용자 테스트, macOS 최종 앱의 실행 취소는 이 CI에서 검사하지 않았다. 코어의 대상8 컴파일·실행을 실제 JDK8 실행이라고 해석하지 않는다. 기본 실무20개 전체의 Mac 통합 실행 승인으로 확대하지 않으며 이번 실제 프레임워크 검사는 `tx-proxy`의 Spring/H2 대표 흐름이다. - -별도 읽기 전용 검수 문맥에서 원본 CI 보고서·서명/파일 인벤토리·설정/코어 성공/Spring 미완성 실패/재시작의 대표 실제 화면4종과 최종 문서를 확인했다. 네 패키지의 실제7검사씩 총28 PASS, 파일 수·ASAR222개·공급자 서명·기본 사용자 데이터·페이지 오류0·정상 종료를 독립 대조했으며 최종 수용 PASS다. 다운로드 후 로컬 패키지 해시 대조 기록도 CI 값과 일치했다. 이는 실제 Mac CI 증거의 독립 검토이며 검수자의 Windows에서 Mac 앱을 다시 실행한 것으로 표현하지 않는다. README의 기존 Windows 설치 안내 회귀1검사와 문서 공백 검사도 PASS다. diff --git a/package-lock.json b/package-lock.json index 39775ce..5e17703 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "java-edu", - "version": "0.4.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "java-edu", - "version": "0.4.0", + "version": "0.3.1", "dependencies": { "@codemirror/commands": "6.11.1", "@codemirror/lang-java": "6.0.2", diff --git a/package.json b/package.json index bd16001..f9ee455 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "java-edu", - "version": "0.4.0", + "version": "0.3.1", "main": "desktop/main.cjs", "private": true, "type": "module", @@ -23,10 +23,7 @@ "test:browser": "node tests/browser.mjs", "test:theory": "node tests/theory-visuals.mjs", "test:readability": "node tests/readability.mjs", - "check": "node --check dist/app.js && node --check dist/data/curriculum.js && node --check dist/data/job-evidence.js && node --check dist/data/lesson-visuals.js && npm test", - "build:desktop:mac": "node scripts/build-editor.mjs && node scripts/build-professional.mjs && electron-builder --config desktop/build-config.cjs --mac --publish never", - "prepare:mac": "node scripts/prepare-macos-runtime.mjs", - "test:mac:packages": "node scripts/verify-macos-packages.mjs" + "check": "node --check dist/app.js && node --check dist/data/curriculum.js && node --check dist/data/job-evidence.js && node --check dist/data/lesson-visuals.js && npm test" }, "engines": { "node": ">=22" diff --git a/scripts/bundle-runtime.mjs b/scripts/bundle-runtime.mjs index a917968..0b50252 100644 --- a/scripts/bundle-runtime.mjs +++ b/scripts/bundle-runtime.mjs @@ -12,13 +12,8 @@ const { values } = parseArgs({ options: { check: { type: 'boolean', default: false }, metadata: { type: 'string' }, maven: { type: 'string' }, - platform: { type: 'string', default: process.platform }, arch: { type: 'string', default: process.arch }, jdk8: { type: 'string' }, jdk11: { type: 'string' }, jdk17: { type: 'string' }, jdk21: { type: 'string' }, }, strict: true }); -const mac = values.platform === 'darwin'; -if (!((values.platform === 'win32' && values.arch === 'x64') || (mac && ['x64', 'arm64'].includes(values.arch)))) throw new Error('지원 대상은 Windows x64 또는 macOS x64/arm64입니다.'); -const inventoryOptions = { allowSymlinks: mac, includeExecutable: mac }; -const targetOptions = { expectedPlatform: values.platform, expectedArch: values.arch }; function inside(root, child) { const relative = path.relative(root, child); @@ -41,15 +36,15 @@ async function sourceDirectory(value) { } async function copyDistribution(source, target) { - const originals = await inventoryTree(source, inventoryOptions); - await cp(source, target, { recursive: true, dereference: false, verbatimSymlinks: true, force: false, errorOnExist: true }); - const copied = await inventoryTree(target, inventoryOptions); + const originals = await inventoryTree(source); // Reject symlinks before copying a vendor distribution. + await cp(source, target, { recursive: true, dereference: false, force: false, errorOnExist: true }); + const copied = await inventoryTree(target); if (JSON.stringify(originals) !== JSON.stringify(copied)) throw new Error('복사 전후 배포본 파일이 일치하지 않습니다.'); } if (values.check) { - if (Object.entries(values).some(([key, value]) => !['check', 'platform', 'arch'].includes(key) && value !== undefined)) throw new Error('--check에는 대상 --platform/--arch만 함께 사용할 수 있습니다.'); - const bundle = await loadBundledRuntime(destination, targetOptions); + if (Object.entries(values).some(([key, value]) => key !== 'check' && value !== undefined)) throw new Error('--check는 다른 입력과 함께 사용할 수 없습니다.'); + const bundle = await loadBundledRuntime(destination); console.log(JSON.stringify({ status: 'passed', directory: destination, id: bundle.id, javaVersions: bundle.jdks.map((jdk) => jdk.version), mavenVersion: bundle.maven.version, files: bundle.fileCount, bytes: bundle.bytes }, null, 2)); } else { if (!values.metadata || !values.jdk21 || !values.maven) throw new Error('사용법: node scripts/bundle-runtime.mjs --jdk21 <폴더> --maven <폴더> --metadata <배포 출처 JSON>'); @@ -68,17 +63,13 @@ if (values.check) { const details = record(entries[0]); if (!details.sourceCodeUrl) throw new Error('JDK 재배포의 정확한 소스 코드 위치를 기록하세요.'); const root = await sourceDirectory(source); - const javaHome = mac ? path.join(root, 'Contents', 'Home') : root; - const release = await readFile(path.join(javaHome, 'release'), 'utf8'); + const release = await readFile(path.join(root, 'release'), 'utf8'); const installedVersion = /^JAVA_VERSION="([^"]+)"/m.exec(release)?.[1]; const installedMajor = Number(installedVersion?.startsWith('1.') ? installedVersion.split('.')[1] : installedVersion?.split(/[.+_-]/)[0]); - const releaseArch = /^OS_ARCH="([^"]+)"/m.exec(release)?.[1]; - const releaseOs = /^OS_NAME="([^"]+)"/m.exec(release)?.[1]; - if (installedMajor !== major || !(values.arch === 'arm64' ? ['aarch64', 'arm64'] : ['x86_64', 'amd64']).includes(releaseArch) - || !(mac ? ['Darwin', 'Mac OS X'] : ['Windows']).includes(releaseOs)) throw new Error('요청한 OS·아키텍처·JDK 버전과 release 파일이 일치하지 않습니다.'); + if (installedMajor !== major || !/^OS_ARCH="(?:x86_64|amd64)"/m.test(release) || !/^OS_NAME="Windows"/m.test(release)) throw new Error('Windows x64용 요청 JDK 버전인지 확인하세요.'); const exactVersions = [installedVersion, /^SEMANTIC_VERSION="([^"]+)"/m.exec(release)?.[1], /^JAVA_RUNTIME_VERSION="([^"]+)"/m.exec(release)?.[1]?.replace(/-LTS$/, '')]; if (!exactVersions.includes(details.version)) throw new Error('기록한 JDK 배포 버전과 실제 release 파일이 일치하지 않습니다.'); - jdks.push({ major, home: `jdks/${major}${mac ? '/Contents/Home' : ''}`, ...(mac ? { distributionRoot: `jdks/${major}` } : {}), ...details, source: root }); + jdks.push({ major, home: `jdks/${major}`, ...details, source: root }); } if (metadata.jdks.length !== jdks.length) throw new Error('지원하지 않는 JDK 배포 출처가 있습니다.'); const mavenSource = await sourceDirectory(values.maven); @@ -87,7 +78,7 @@ if (values.check) { const staging = path.join(path.dirname(destination), `desktop-bundle.partial-${randomUUID()}`); await mkdir(staging); try { - for (const jdk of jdks) await copyDistribution(jdk.source, path.join(staging, ...(jdk.distributionRoot || jdk.home).split('/'))); + for (const jdk of jdks) await copyDistribution(jdk.source, path.join(staging, ...jdk.home.split('/'))); await copyDistribution(mavenSource, path.join(staging, maven.home)); const notices = ['Java Edu bundled third-party distributions', '', 'These complete, unmodified vendor distributions retain their original licenses and notices.', @@ -101,10 +92,10 @@ if (values.check) { ]), ].join('\n'); await writeFile(path.join(staging, 'THIRD-PARTY-NOTICES.txt'), notices, 'utf8'); - const manifest = { schemaVersion: mac ? 2 : 1, platform: values.platform, arch: values.arch, jdks: jdks.map(({ source: _source, ...jdk }) => jdk), maven, files: await inventoryTree(staging, inventoryOptions) }; + const manifest = { schemaVersion: 1, platform: 'win32', arch: 'x64', jdks: jdks.map(({ source: _source, ...jdk }) => jdk), maven, files: await inventoryTree(staging) }; manifest.id = manifestIdentity(manifest); await writeFile(path.join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); - const bundle = await loadBundledRuntime(staging, targetOptions); + const bundle = await loadBundledRuntime(staging); await rename(staging, destination); console.log(JSON.stringify({ status: 'passed', directory: destination, id: bundle.id, javaVersions: bundle.jdks.map((jdk) => jdk.version), mavenVersion: bundle.maven.version, files: bundle.fileCount, bytes: bundle.bytes }, null, 2)); } catch (error) { diff --git a/scripts/prepare-macos-runtime.mjs b/scripts/prepare-macos-runtime.mjs deleted file mode 100644 index 0527ca6..0000000 --- a/scripts/prepare-macos-runtime.mjs +++ /dev/null @@ -1,82 +0,0 @@ -// Build-time only: pinned vendor downloads; never installs a system toolchain. -import { createHash } from 'node:crypto'; -import { createWriteStream } from 'node:fs'; -import { mkdir, readFile, readdir, writeFile, lstat } from 'node:fs/promises'; -import { Readable, Transform } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const run = promisify(execFile); -const root = fileURLToPath(new URL('../', import.meta.url)); - -// These exact pinned Temurin archives use jdk-/Contents, without a -// .jdk suffix. Do not rename the vendor root or discard unknown archive entries. -export async function inspectVendorLayout(jdkDirectory, mavenDirectory, { jdkVersion, mavenVersion }) { - const jdkNames = (await readdir(jdkDirectory)).sort(); - const mavenNames = (await readdir(mavenDirectory)).sort(); - const expectedJdk = `jdk-${jdkVersion}`; - const expectedMaven = `apache-maven-${mavenVersion}`; - const observed = `JDK entries=${JSON.stringify(jdkNames)}, expected=${JSON.stringify(expectedJdk)}; Maven entries=${JSON.stringify(mavenNames)}, expected=${JSON.stringify(expectedMaven)}`; - if (jdkNames.length !== 1 || jdkNames[0] !== expectedJdk || mavenNames.length !== 1 || mavenNames[0] !== expectedMaven) throw new Error(`Unexpected vendor archive layout: ${observed}`); - const jdkRoot = path.join(jdkDirectory, expectedJdk); - const mavenRoot = path.join(mavenDirectory, expectedMaven); - for (const [entry, directory] of [ - [jdkRoot, true], [path.join(jdkRoot, 'Contents'), true], [path.join(jdkRoot, 'Contents/Home'), true], - [path.join(jdkRoot, 'Contents/Home/release'), false], [path.join(jdkRoot, 'Contents/Info.plist'), false], [mavenRoot, true], - ]) { - const info = await lstat(entry).catch(() => null); - if (!info || info.isSymbolicLink() || !(directory ? info.isDirectory() : info.isFile())) throw new Error(`Unexpected vendor archive layout: invalid ${JSON.stringify(entry)}; ${observed}`); - } - return { jdkRoot, mavenRoot, jdkNames, mavenNames }; -} - -async function download(tool, destination) { - const response = await fetch(tool.sourceUrl, { signal: AbortSignal.timeout(300000) }); - if (!response.ok || !response.body) throw new Error(`Vendor download failed: ${response.status} ${tool.sourceUrl}`); - const hash = createHash('sha256'); - const hashing = new Transform({ transform(chunk, _encoding, callback) { hash.update(chunk); callback(null, chunk); } }); - await pipeline(Readable.fromWeb(response.body), hashing, createWriteStream(destination, { flags: 'wx' })); - const sha256 = hash.digest('hex'); - if (sha256 !== tool.archiveSha256) throw new Error(`Vendor checksum mismatch: ${destination}`); - return { archive: path.basename(destination), sourceUrl: tool.sourceUrl, sha256, verified: true }; -} - -async function prepareMacosRuntime() { - if (process.platform !== 'darwin' || !['x64', 'arm64'].includes(process.arch)) throw new Error('Run on a native Intel or Apple Silicon Mac.'); - const metadataPath = path.join(root, 'desktop', `toolchain-provenance.darwin-${process.arch}.json`); - const metadata = JSON.parse(await readFile(metadataPath, 'utf8')); - const input = path.join(root, 'work', `macos-vendor-${process.arch}`); - await mkdir(path.dirname(input), { recursive: true }); - await mkdir(input); // Existing archives/staging are never silently reused or replaced. - const evidence = { platform: process.platform, arch: process.arch, startedAt: new Date().toISOString(), metadata, archives: [] }; - const saveEvidence = () => writeFile(path.join(input, 'download-verification.json'), `${JSON.stringify(evidence, null, 2)}\n`); - let stage = 'download_jdk'; - try { - const jdkArchive = path.join(input, 'jdk.tar.gz'); - const mavenArchive = path.join(input, 'maven.zip'); - evidence.archives.push(await download(metadata.jdks[0], jdkArchive)); await saveEvidence(); - stage = 'download_maven'; - evidence.archives.push(await download(metadata.maven, mavenArchive)); - evidence.verifiedAt = new Date().toISOString(); evidence.status = 'downloads_verified'; await saveEvidence(); - const jdkDirectory = path.join(input, 'jdk'); - const mavenDirectory = path.join(input, 'maven'); - await mkdir(jdkDirectory); await mkdir(mavenDirectory); - stage = 'extract_archives'; - await run('/usr/bin/tar', ['-xzf', jdkArchive, '-C', jdkDirectory]); - await run('/usr/bin/ditto', ['-x', '-k', mavenArchive, mavenDirectory]); - evidence.entries = { jdk: (await readdir(jdkDirectory)).sort(), maven: (await readdir(mavenDirectory)).sort() }; await saveEvidence(); - stage = 'validate_archive_layout'; - const layout = await inspectVendorLayout(jdkDirectory, mavenDirectory, { jdkVersion: metadata.jdks[0].version, mavenVersion: metadata.maven.version }); - stage = 'stage_bundle'; - const result = await run(process.execPath, [path.join(root, 'scripts/bundle-runtime.mjs'), '--platform', 'darwin', '--arch', process.arch, '--jdk21', layout.jdkRoot, '--maven', layout.mavenRoot, '--metadata', metadataPath], { cwd: root, maxBuffer: 4 * 1024 * 1024 }); - console.log(result.stdout); - evidence.status = 'bundle_staged'; await saveEvidence(); - } catch (error) { - evidence.status = 'failed'; evidence.failureStage = stage; evidence.error = error.message; await saveEvidence(); throw error; - } -} - -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await prepareMacosRuntime(); diff --git a/scripts/verify-macos-packages.mjs b/scripts/verify-macos-packages.mjs deleted file mode 100644 index a139fd3..0000000 --- a/scripts/verify-macos-packages.mjs +++ /dev/null @@ -1,324 +0,0 @@ -import assert from 'node:assert/strict'; -import { createHash, randomUUID } from 'node:crypto'; -import { createReadStream } from 'node:fs'; -import { access, open, mkdir, lstat, readFile, readlink, readdir, realpath, rename, writeFile } from 'node:fs/promises'; -import { spawn } from 'node:child_process'; -import path from 'node:path'; -import os from 'node:os'; -import { fileURLToPath } from 'node:url'; -import { createRequire } from 'node:module'; -import { loadBundledRuntime } from '../desktop/bundled-runtime.cjs'; - -const require = createRequire(import.meta.url); -const { listPackage, statFile, extractFile } = require('@electron/asar'); -const root = path.resolve(fileURLToPath(new URL('../', import.meta.url))); -function option(name, fallback) { - const index = process.argv.indexOf(name); - if (index === -1) return fallback; - assert.ok(process.argv[index + 1] && !process.argv[index + 1].startsWith('--'), name + ' needs a value'); - return process.argv[index + 1]; -} -const arch = option('--arch'); -const release = path.resolve(root, option('--release', 'desktop-release')); -const artifactsRoot = path.resolve(root, option('--artifacts', 'work/macos-qa')); -const artifacts = path.join(artifactsRoot, arch || 'invalid'); -const runRoot = path.join(artifacts, 'run-' + randomUUID()); -const ownershipToken = randomUUID(); -const marker = '.java-edu-macos-qa-owner'; -const evidence = { startedAt: new Date().toISOString(), platform: process.platform, hostArch: process.arch, arch, - sourceRoot: root, release, artifacts: runRoot, commands: [], packageChecks: [], runtimeChecks: [], preflight: [], - notExecuted: ['Downloaded-file Gatekeeper flow', 'Developer ID notarization', 'Physical user playtesting'] }; -const exists = file => access(file).then(() => true, () => false); -const inside = (child, parent) => path.relative(parent, child) !== '' && !path.relative(parent, child).startsWith('..') && !path.isAbsolute(path.relative(parent, child)); -const hashBytes = bytes => createHash('sha256').update(bytes).digest('hex'); -let expectedUserData; -let ownedData = false; -let mountPoint; -let mountAttached = false; -let currentKind; - -async function command(executable, args, { timeout = 120000, log = null, allowedExitCodes = [0] } = {}) { - const record = { executable, args, startedAt: new Date().toISOString() }; - evidence.commands.push(record); - return new Promise((resolve, reject) => { - const child = spawn(executable, args, { cwd: root, env: process.env, shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - let timedOut = false; - const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, timeout); - const forceTimer = setTimeout(() => { child.kill('SIGKILL'); }, timeout + 10000); - child.stdout.on('data', data => { stdout += data.toString(); }); - child.stderr.on('data', data => { stderr += data.toString(); }); - child.on('error', error => { clearTimeout(timer); clearTimeout(forceTimer); record.error = error.message; reject(error); }); - child.on('close', async (code, signal) => { - clearTimeout(timer); clearTimeout(forceTimer); - Object.assign(record, { exitCode: code, signal, timedOut, stdout, stderr, finishedAt: new Date().toISOString() }); - try { - if (log) await writeFile(log, stdout + '\nSTDERR\n' + stderr); - assert.ok(!timedOut && allowedExitCodes.includes(code), executable + ' failed (' + code + ', ' + signal + '): ' + stderr.slice(-3000)); - resolve({ stdout, stderr, exitCode: code }); - } catch (error) { reject(error); } - }); - }); -} -async function fileDigest(file) { - const hash = createHash('sha256'); - for await (const chunk of createReadStream(file)) hash.update(chunk); - return hash.digest('hex'); -} -async function inventory(base) { - const entries = []; - async function visit(directory, prefix = '') { - for (const name of (await readdir(directory)).sort()) { - const file = path.join(directory, name); - const relative = prefix ? prefix + '/' + name : name; - const info = await lstat(file); - const entry = { path: relative, mode: info.mode & 0o777 }; - if (info.isSymbolicLink()) { - entry.type = 'symlink'; - entry.target = await readlink(file); - assert.ok(inside(path.resolve(path.dirname(file), entry.target), base), 'symlink escapes extracted app: ' + relative); - // Reject dangling/redirected links, including intermediate symlinks. - assert.ok(inside(await realpath(file), base), 'symlink resolves outside extracted app: ' + relative); - } else if (info.isDirectory()) entry.type = 'directory'; - else { - assert.ok(info.isFile(), 'unsupported extracted item: ' + relative); - Object.assign(entry, { type: 'file', bytes: info.size, sha256: await fileDigest(file) }); - const handle = await open(file, 'r'); - try { - const header = Buffer.alloc(4); - const { bytesRead } = await handle.read(header, 0, 4, 0); - entry.nativeCandidate = bytesRead === 4 && ['feedface', 'cefaedfe', 'feedfacf', 'cffaedfe', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca'].includes(header.toString('hex')); - } finally { await handle.close(); } - } - entries.push(entry); - if (entry.type === 'directory') await visit(file, relative); - } - } - await visit(base); - return entries; -} -async function packagedSourceFiles() { - const files = []; - async function visit(directory, relative) { - for (const name of (await readdir(directory)).sort()) { - const next = relative + '/' + name; - // Match desktop/build-config.cjs source glob exclusions. - if (name === 'node_modules' || name === 'target' || next === 'dist/server' || next === 'dist/.openai') continue; - const info = await lstat(path.join(directory, name)); - if (info.isDirectory()) await visit(path.join(directory, name), next); - else { - assert.ok(info.isFile(), 'source package contains unexpected symlink/special file: ' + next); - files.push(next); - } - } - } - for (const relative of ['desktop', 'server', 'dist', 'curriculum/professional']) await visit(path.join(root, relative), relative); - return files.sort(); -} -async function sourceAsarParity(appPath) { - const asarPath = path.join(appPath, 'Contents/Resources/app.asar'); - const result = []; - for (const listed of listPackage(asarPath)) { - const relative = listed.replace(/^[/\\]+/, '').replaceAll('\\', '/'); - const info = statFile(asarPath, relative); - if (!Object.hasOwn(info, 'size')) continue; - if (!['desktop/', 'server/', 'dist/', 'curriculum/professional/'].some(prefix => relative.startsWith(prefix))) continue; - assert.ok(inside(path.resolve(root, relative), root), 'ASAR source path escapes'); - const source = await readFile(path.join(root, relative)); - const actual = info.unpacked - ? await readFile(path.join(appPath, 'Contents/Resources/app.asar.unpacked', relative)) - : extractFile(asarPath, relative); - const comparison = { path: relative, unpacked: info.unpacked === true, sha256: hashBytes(actual), sourceSha256: hashBytes(source), bytes: actual.length }; - result.push(comparison); - assert.equal(comparison.sha256, comparison.sourceSha256, 'source/ASAR byte mismatch: ' + relative); - } - assert.deepEqual(result.map(item => item.path).sort(), await packagedSourceFiles(), - 'ASAR selected source inventory must be complete: no missing or additional source files'); - for (const required of ['desktop/main.cjs', 'desktop/preload.cjs', 'server/local/process.mjs', 'server/local/engine.mjs', 'dist/app.js', 'dist/styles.css', 'dist/data/professional.js']) assert.ok(result.some(item => item.path === required), 'ASAR missing required source: ' + required); - const packaged = JSON.parse(extractFile(asarPath, 'package.json').toString('utf8')); - const sourcePackage = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); - for (const key of ['name', 'version', 'main']) assert.equal(packaged[key], sourcePackage[key], 'packaged metadata ' + key); - return { asarSha256: await fileDigest(asarPath), packaged, files: result }; -} -async function nativeFacts(appPath, entries) { - const facts = []; - const wanted = arch === 'x64' ? 'x86_64' : 'arm64'; - for (const entry of entries.filter(item => item.nativeCandidate)) { - const file = path.join(appPath, entry.path); - const identified = await command('/usr/bin/file', ['-b', file]); - if (!identified.stdout.includes('Mach-O')) continue; // A Java class can have the fat Mach-O magic. - const result = await command('/usr/bin/lipo', ['-archs', file]); - const architectures = result.stdout.trim().split(/\s+/); - assert.ok(architectures.includes(wanted), 'wrong native arch: ' + entry.path + ' = ' + result.stdout.trim()); - facts.push({ path: entry.path, architectures, file: identified.stdout.trim() }); - } - for (const required of ['Contents/MacOS/Java Edu', 'Contents/Resources/toolchains/jdks/21/Contents/Home/bin/java', - 'Contents/Resources/toolchains/jdks/21/Contents/Home/bin/javac']) { - const fact = facts.find(item => item.path === required); - assert.ok(fact, 'required native binary absent: ' + required); - assert.deepEqual(fact.architectures, [wanted], 'entry point/tool must be native requested architecture: ' + required); - assert.ok(entries.find(item => item.path === required).mode & 0o111, 'required executable lost mode: ' + required); - } - return facts; -} -async function verifyApp(kind, appPath, stagingInventory, sourceBundleInventory) { - const record = { kind, appPath }; - evidence.packageChecks.push(record); - const actual = await inventory(appPath); - const inventoryFile = path.join(runRoot, kind + '-inventory.json'); - await writeFile(inventoryFile, JSON.stringify(actual, null, 2)); - record.inventoryFile = inventoryFile; - assert.deepEqual(actual, stagingInventory, kind + ' full bundle differs from builder staging (bytes, modes, symlinks, entries)'); - const toolchains = path.join(appPath, 'Contents/Resources/toolchains'); - const packagedTools = await inventory(toolchains); - assert.deepEqual(packagedTools, sourceBundleInventory, kind + ' toolchain bytes/signatures/modes/symlinks differ from vendor staging'); - const bundle = await loadBundledRuntime(toolchains); - assert.equal(bundle.platform, 'darwin'); - assert.equal(bundle.arch, arch); - record.bundle = { id: bundle.id, platform: bundle.platform, arch: bundle.arch, bytes: bundle.bytes, fileCount: bundle.fileCount }; - record.sourceAsar = await sourceAsarParity(appPath); - record.native = await nativeFacts(appPath, actual); - record.appSignature = await command('/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=4', appPath]); - const signature = await command('/usr/bin/codesign', ['--display', '--verbose=4', appPath]); - assert.match(signature.stderr + signature.stdout, /Signature=adhoc/, 'final app must have verifiable ad hoc signature'); - record.appSignatureDisplay = signature; - const vendorRoot = path.join(toolchains, 'jdks/21'); - record.vendorSignature = await command('/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=4', vendorRoot]); - record.vendorSignatureDisplay = await command('/usr/bin/codesign', ['--display', '--verbose=4', vendorRoot]); - record.status = 'PASS'; -} -async function archiveOwnedData(kind) { - if (!ownedData) return; - assert.ok(expectedUserData && path.dirname(expectedUserData) === path.join(os.homedir(), 'Library/Application Support')); - assert.ok((await lstat(expectedUserData)).isDirectory() && !(await lstat(expectedUserData)).isSymbolicLink()); - assert.equal((await readFile(path.join(expectedUserData, marker), 'utf8')).trim(), ownershipToken, 'production-data ownership lost'); - const destination = path.join(runRoot, kind + '-production-user-data'); - assert.equal(await exists(destination), false); - await rename(expectedUserData, destination); - ownedData = false; - evidence.archivedUserData ??= []; - evidence.archivedUserData.push({ kind, from: expectedUserData, destination }); -} -async function runtime(kind, appPath) { - assert.equal(await exists(expectedUserData), false, 'never overwrite existing user data: ' + expectedUserData); - // Marker gives a positive ownership proof even if Electron fails before - // firstWindow. No app setting/state exists before its real first launch. - await mkdir(expectedUserData, { recursive: false }); - ownedData = true; - await writeFile(path.join(expectedUserData, marker), ownershipToken + '\n', { flag: 'wx', mode: 0o600 }); - const runtimeArtifacts = path.join(runRoot, kind + '-runtime'); - await mkdir(runtimeArtifacts); - const record = { kind, artifacts: runtimeArtifacts, startedAt: new Date().toISOString() }; - evidence.runtimeChecks.push(record); - let finishedChild = false; - try { - await command(process.execPath, [path.join(root, 'tests/macos-packaged.mjs'), '--kind', kind, '--arch', arch, - '--app', appPath, '--artifacts', runtimeArtifacts, '--user-data', expectedUserData, '--ownership-token', ownershipToken], - { timeout: 1200000, log: path.join(runtimeArtifacts, 'runtime.log') }); - finishedChild = true; - record.result = JSON.parse(await readFile(path.join(runtimeArtifacts, 'runtime-results.json'), 'utf8')); - assert.equal(record.result.status, 'PASS'); - assert.equal(record.result.closed, true); - record.status = 'PASS'; - } catch (error) { - record.status = 'FAIL'; - record.error = error.stack; - record.result = JSON.parse(await readFile(path.join(runtimeArtifacts, 'runtime-results.json'), 'utf8').catch(() => '{}')); - finishedChild = record.result.closed === true; - throw error; - } finally { - // Never move data while a failed/timed-out app may still be alive. - if (finishedChild) await archiveOwnedData(kind); - else record.retainedProductionUserData = expectedUserData; - record.finishedAt = new Date().toISOString(); - } -} -async function detach() { - if (!mountAttached) return; - assert.ok(mountPoint && inside(mountPoint, runRoot), 'detach only this run mount'); - await command('/usr/bin/hdiutil', ['detach', mountPoint]); - mountAttached = false; - evidence.detached = true; -} -async function main() { - assert.equal(process.platform, 'darwin', 'execute package/runtime QA on native macOS only'); - assert.equal(process.env.GITHUB_ACTIONS, 'true', 'default-data QA requires ephemeral GitHub runner home'); - assert.ok(['x64', 'arm64'].includes(arch), 'pass --arch x64|arm64'); - assert.equal(process.arch, arch, 'native runner architecture must match package; Rosetta is not this gate'); - assert.ok(inside(runRoot, path.join(root, 'work')), 'keep extraction/evidence within workspace work/'); - await mkdir(runRoot, { recursive: true }); - const git = await command('git', ['rev-parse', 'HEAD']); - evidence.sourceSha = git.stdout.trim(); - evidence.sourceStatus = (await command('git', ['status', '--porcelain'])).stdout; - const staging = path.join(release, arch === 'arm64' ? 'mac-arm64' : 'mac', 'Java Edu.app'); - assert.equal((await lstat(staging)).isDirectory(), true); - evidence.staging = staging; - const stagingInventory = await inventory(staging); - await writeFile(path.join(runRoot, 'staging-inventory.json'), JSON.stringify(stagingInventory, null, 2)); - const sourceBundleInventory = await inventory(path.join(root, 'work/desktop-bundle')); - await writeFile(path.join(runRoot, 'source-toolchains-inventory.json'), JSON.stringify(sourceBundleInventory, null, 2)); - const stagingSource = await sourceAsarParity(staging); - // Electron initializes its default app name from productName || name in - // packaged package.json. Main does not override packaged userData. - const appName = stagingSource.packaged.productName || stagingSource.packaged.name; - assert.ok(appName && appName !== '.' && appName !== '..' && !/[\\/\0]/.test(appName)); - expectedUserData = path.join(os.homedir(), 'Library/Application Support', appName); - const candidates = [...new Set([appName, 'java-edu', 'Java Edu'])].map(name => path.join(os.homedir(), 'Library/Application Support', name)); - for (const candidate of candidates) { - const info = await lstat(candidate).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); - evidence.preflight.push({ path: candidate, exists: info !== null }); - assert.equal(info, null, 'STOP: preserve pre-existing user data: ' + candidate); - } - evidence.expectedUserData = expectedUserData; - await mkdir(path.dirname(expectedUserData), { recursive: true }); - const files = await readdir(release); - for (const kind of ['dmg', 'zip']) { - currentKind = kind; - const matches = files.filter(name => name.endsWith('.' + kind) && name.includes('-' + arch + '.')); - assert.equal(matches.length, 1, 'expected one actual ' + arch + ' ' + kind + ': ' + JSON.stringify(matches)); - const packageFile = path.join(release, matches[0]); - evidence.packages ??= []; - evidence.packages.push({ kind, path: packageFile, bytes: (await lstat(packageFile)).size, sha256: await fileDigest(packageFile) }); - const extraction = path.join(runRoot, kind + '-한글 설치 & %JAVA_HOME%', 'Applications'); - await mkdir(extraction, { recursive: true }); - const extractedApp = path.join(extraction, 'Java Edu.app'); - if (kind === 'dmg') { - mountPoint = path.join(runRoot, 'owned-dmg-mount'); - await mkdir(mountPoint); - try { - // Explicit owned mount avoids unrelated /Volumes or /Applications. - mountAttached = true; // detach attempted even after a partial attach failure - const mounted = await command('/usr/bin/hdiutil', ['attach', packageFile, '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mountPoint, '-plist']); - evidence.dmgAttach = mounted; - assert.ok((await lstat(path.join(mountPoint, 'Java Edu.app'))).isDirectory()); - await command('/usr/bin/ditto', ['--rsrc', '--extattr', path.join(mountPoint, 'Java Edu.app'), extractedApp]); - } finally { await detach(); } - } else { - await command('/usr/bin/ditto', ['-x', '-k', '--rsrc', '--extattr', packageFile, extraction]); - } - assert.ok((await lstat(extractedApp)).isDirectory() && !(await lstat(extractedApp)).isSymbolicLink(), 'actual package contains regular app'); - await verifyApp(kind, extractedApp, stagingInventory, sourceBundleInventory); - await runtime(kind, extractedApp); - } - evidence.status = 'PASS'; -} -try { await main(); } -catch (error) { - evidence.status = 'FAIL'; - evidence.error = error.stack; - console.error(error.stack); - process.exitCode = 1; -} finally { - if (mountAttached) { - try { await detach(); } - catch (error) { evidence.detachError = error.stack; evidence.status = 'FAIL'; process.exitCode = 1; } - } - if (ownedData) evidence.retainedProductionUserData = { path: expectedUserData, kind: currentKind, reason: 'Child app close was not proved; preserved data in place.' }; - evidence.finishedAt = new Date().toISOString(); - // Failures, including non-macOS attempts, always leave a result. - await mkdir(runRoot, { recursive: true }); - const resultFile = path.join(runRoot, 'package-qa-results.json'); - await writeFile(resultFile, JSON.stringify(evidence, null, 2)); - console.log(JSON.stringify({ status: evidence.status, arch, resultFile, packageChecks: evidence.packageChecks.map(item => ({ kind: item.kind, status: item.status })), runtimeChecks: evidence.runtimeChecks.map(item => ({ kind: item.kind, status: item.status })) }, null, 2)); -} diff --git a/server/local/process.mjs b/server/local/process.mjs index e9be36f..2e0a9f8 100644 --- a/server/local/process.mjs +++ b/server/local/process.mjs @@ -17,7 +17,6 @@ export function processEnvironment(javaHome, temporaryDirectory) { } env.TEMP = temporaryDirectory; env.TMP = temporaryDirectory; - if (process.platform !== 'win32') env.TMPDIR = temporaryDirectory; env.HOME = temporaryDirectory; env.USERPROFILE = temporaryDirectory; if (process.platform === 'win32') { @@ -74,17 +73,14 @@ export async function runProcess(executable, args, { } }; const onAbort = () => stop('cancelled'); - const stopUnixDescendants = () => { - if (process.platform !== 'win32' && child?.pid) { - try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already reaped */ } - } - }; const finish = async (exitCode, spawnError) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort); - stopUnixDescendants(); + if (process.platform !== 'win32' && child?.pid) { + try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already reaped */ } + } let report; if (process.platform === 'win32') { try { report = JSON.parse(await readFile(resultPath, 'utf8')); } catch { /* Helper can be killed before reporting. */ } @@ -110,10 +106,6 @@ export async function runProcess(executable, args, { }); } child.once('error', (error) => void finish(-1, error)); - // A descendant may inherit stdout/stderr and keep them open after the - // command exits. Kill the remaining Unix group on exit, before waiting - // for close to drain pipes, without changing the command's exit reason. - child.once('exit', stopUnixDescendants); child.once('close', (code) => void finish(code)); if (signal?.aborted) stop('cancelled'); }); diff --git a/tests/local/bundled-runtime-smoke.mjs b/tests/local/bundled-runtime-smoke.mjs index f1efd0f..0e8e73e 100644 --- a/tests/local/bundled-runtime-smoke.mjs +++ b/tests/local/bundled-runtime-smoke.mjs @@ -1,4 +1,4 @@ -// Opt-in actual native bundled toolchain check: no folder selection or external tool install. +// Opt-in actual Windows toolchain check: no chooseJdk/chooseMaven call, no external tool install. import assert from 'node:assert/strict'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; @@ -9,12 +9,12 @@ import { createSettingsStore } from '../../server/local/settings.mjs'; import { createLocalEngine } from '../../server/local/engine.mjs'; import { exerciseDefinitions } from '../../server/exercises.mjs'; -if (!['win32', 'darwin'].includes(process.platform)) throw new Error('Bundled runtime smoke supports Windows and macOS.'); +if (process.platform !== 'win32') throw new Error('The bundled distribution is Windows x64 only.'); const root = fileURLToPath(new URL('../../', import.meta.url)); const bundleRoot = path.join(root, 'work/desktop-bundle'); const dataRoot = path.join(root, 'work', `bundled-runtime-smoke-${randomUUID()}`); await mkdir(dataRoot); -const report = { startedAt: new Date().toISOString(), platform: process.platform, arch: process.arch, bundleRoot, dataRoot, results: [] }; +const report = { startedAt: new Date().toISOString(), bundleRoot, dataRoot, results: [] }; let engine; try { const bundle = await loadBundledRuntime(bundleRoot); diff --git a/tests/local/bundled-runtime.test.mjs b/tests/local/bundled-runtime.test.mjs index 203d2e4..7ebbcb9 100644 --- a/tests/local/bundled-runtime.test.mjs +++ b/tests/local/bundled-runtime.test.mjs @@ -3,13 +3,10 @@ import assert from 'node:assert/strict'; import { mkdtemp, mkdir, writeFile, readFile, rm, realpath, rename, symlink, cp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { inventoryTree, loadBundledRuntime as nativeLoadBundledRuntime, manifestIdentity } from '../../desktop/bundled-runtime.cjs'; +import { inventoryTree, loadBundledRuntime, manifestIdentity } from '../../desktop/bundled-runtime.cjs'; import { createSettingsStore } from '../../server/local/settings.mjs'; import { createLocalEngine } from '../../server/local/engine.mjs'; -// These fixtures deliberately exercise schema1 Windows bytes on every CI host. -const loadBundledRuntime = (directory, options) => nativeLoadBundledRuntime(directory, { expectedPlatform: 'win32', expectedArch: 'x64', ...options }); - async function scratch(t) { const parent = await realpath(tmpdir()); const root = await mkdtemp(path.join(parent, 'java-edu-bundle-test-')); @@ -67,13 +64,9 @@ test('bundle metadata is resolved only from the verified current resources root' test('changed, missing and unlisted toolchain files fail integrity before registration', async (t) => { const root = await scratch(t); - for (const scenario of ['changed', 'same-size-change', 'missing', 'extra', 'manifest']) { + for (const scenario of ['changed', 'missing', 'extra', 'manifest']) { const { directory } = await fixture(root, scenario); if (scenario === 'changed') await writeFile(path.join(directory, 'jdks/21/bin/java.exe'), 'changed'); - if (scenario === 'same-size-change') { - const java = path.join(directory, 'jdks/21/bin/java.exe'); - await writeFile(java, Buffer.alloc((await readFile(java)).length, 0x78)); - } if (scenario === 'missing') await rm(path.join(directory, 'maven/LICENSE')); if (scenario === 'extra') await writeFile(path.join(directory, 'maven/extra.jar'), 'unlisted'); if (scenario === 'manifest') { diff --git a/tests/local/mac-bundled-runtime.test.mjs b/tests/local/mac-bundled-runtime.test.mjs deleted file mode 100644 index 8418f9d..0000000 --- a/tests/local/mac-bundled-runtime.test.mjs +++ /dev/null @@ -1,120 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, readFile, rm, realpath, chmod, symlink, readlink, cp } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { inventoryTree, loadBundledRuntime, manifestIdentity } from '../../desktop/bundled-runtime.cjs'; -import buildConfig from '../../desktop/build-config.cjs'; - -const invalidBundle = { code: 'invalid_bundled_runtime' }; -const nativeUnix = { skip: process.platform === 'win32' }; -async function scratch(t) { - const parent = await realpath(tmpdir()); - const root = await mkdtemp(path.join(parent, 'java-edu-mac-bundle-test-')); - t.after(async () => { const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-mac-bundle-test-')); await rm(resolved, { recursive: true, force: true }); }); - return root; -} -async function saveManifest(directory, manifest) { - manifest.id = manifestIdentity(manifest); - await writeFile(path.join(directory, 'manifest.json'), JSON.stringify(manifest)); -} -async function fixture(root, arch = 'arm64') { - const directory = path.join(root, 'toolchains'); - const files = { - 'jdks/21/Contents/Home/bin/java': 'fixture java', 'jdks/21/Contents/Home/bin/javac': 'fixture javac', - 'jdks/21/Contents/Home/release': `JAVA_VERSION="21.0.12.1"\nOS_ARCH="${arch === 'arm64' ? 'aarch64' : 'x86_64'}"\nOS_NAME="Darwin"\n`, - 'jdks/21/Contents/Home/legal/java.base/LICENSE': 'original license', - 'jdks/21/Contents/Info.plist': 'original vendor bundle metadata', - 'maven/bin/m2.conf': 'fixture config', 'maven/LICENSE': 'original Maven license', - 'maven/boot/plexus-classworlds-2.9.0.jar': 'fixture launcher', 'maven/lib/maven-core-3.10.0.jar': 'fixture Maven', - 'THIRD-PARTY-NOTICES.txt': 'fixture provenance', - }; - for (const [relative, content] of Object.entries(files)) { const file = path.join(directory, relative); await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, content); } - for (const name of ['java', 'javac']) await chmod(path.join(directory, `jdks/21/Contents/Home/bin/${name}`), 0o755); - const provenance = (distribution) => ({ distribution, version: distribution === 'JDK' ? '21.0.12.1' : '3.10.0', sourceUrl: 'https://example.org/archive.tar.gz', archiveSha256: 'a'.repeat(64), license: 'fixture-only', licenseFiles: distribution === 'JDK' ? ['legal/java.base/LICENSE'] : ['LICENSE'], sourceCodeUrl: 'https://example.org/source' }); - const manifest = { schemaVersion: 2, platform: 'darwin', arch, jdks: [{ major: 21, distributionRoot: 'jdks/21', home: 'jdks/21/Contents/Home', ...provenance('JDK') }], maven: { home: 'maven', ...provenance('Maven') }, files: await inventoryTree(directory, { allowSymlinks: true, includeExecutable: true }) }; - await saveManifest(directory, manifest); - return { directory, manifest }; -} - -test('bundle loader refuses platform and architecture mismatches before tool registration', async (t) => { - const root = await scratch(t); - const { directory } = await fixture(root); - const mismatch = (error) => error.code === 'invalid_bundled_runtime' && /unsupported manifest/.test(error.message); - await assert.rejects(loadBundledRuntime(directory, { expectedPlatform: 'darwin', expectedArch: 'x64' }), mismatch); - await assert.rejects(loadBundledRuntime(directory, { expectedPlatform: 'win32', expectedArch: 'arm64' }), mismatch); - if (process.platform !== 'darwin' || process.arch !== 'arm64') await assert.rejects(loadBundledRuntime(directory), mismatch); - else assert.equal((await loadBundledRuntime(directory)).arch, 'arm64'); -}); - -test('Mac post-sign hook rejects changed JDK bytes without rewriting the manifest', nativeUnix, async (t) => { - const root = await scratch(t); - const resources = path.join(root, 'Java Edu.app', 'Contents', 'Resources'); - await mkdir(resources, { recursive: true }); - const { directory } = await fixture(resources); - const context = { appOutDir: root, electronPlatformName: 'darwin', arch: 3 }; - const originalManifest = await readFile(path.join(directory, 'manifest.json')); - const java = path.join(directory, 'jdks/21/Contents/Home/bin/java'); - const originalJava = await readFile(java); - await buildConfig.afterPack(context); - await buildConfig.afterSign(context); - for (const changed of [Buffer.concat([originalJava, Buffer.from('simulated signature bytes')]), - Buffer.alloc(originalJava.length, 0x78)]) { - await writeFile(java, changed); - await assert.rejects(buildConfig.afterSign(context), (error) => - error.code === 'invalid_bundled_runtime' && /distribution integrity mismatch/.test(error.message)); - assert.deepEqual(await readFile(path.join(directory, 'manifest.json')), originalManifest); - } -}); - -test('mac JDK preserves whole vendor bundle, modes and contained license links', nativeUnix, async (t) => { - const root = await scratch(t); - const { directory, manifest } = await fixture(root); - const legal = path.join(directory, 'jdks/21/Contents/Home/legal'); - await mkdir(path.join(legal, 'java.logging')); - await symlink('../java.base/LICENSE', path.join(legal, 'java.logging/LICENSE')); - manifest.files = await inventoryTree(directory, { excludeManifest: true, allowSymlinks: true, includeExecutable: true }); await saveManifest(directory, manifest); - const bundle = await loadBundledRuntime(directory, { expectedPlatform: 'darwin', expectedArch: 'arm64' }); - assert.equal(bundle.jdks[0].home, path.join(directory, 'jdks/21/Contents/Home')); - assert.equal(bundle.platform, 'darwin'); assert.equal(bundle.arch, 'arm64'); - assert.ok(manifest.files.some((file) => file.path.endsWith('Contents/Info.plist'))); - const copied = path.join(root, 'copied'); await cp(directory, copied, { recursive: true, dereference: false, verbatimSymlinks: true }); - assert.equal(await readlink(path.join(copied, 'jdks/21/Contents/Home/legal/java.logging/LICENSE')), '../java.base/LICENSE'); - await loadBundledRuntime(copied, { expectedPlatform: 'darwin', expectedArch: 'arm64' }); - await chmod(path.join(directory, 'jdks/21/Contents/Home/bin/java'), 0o644); - await assert.rejects(loadBundledRuntime(directory, { expectedPlatform: 'darwin', expectedArch: 'arm64' }), invalidBundle); -}); - -test('mac links cannot escape vendor roots, be absolute, cycle or change text', nativeUnix, async (t) => { - const root = await scratch(t); - for (const kind of ['escape', 'absolute', 'cycle', 'cross-vendor', 'changed']) { - const parent = path.join(root, kind); await mkdir(parent); - const { directory, manifest } = await fixture(parent); - const link = path.join(directory, 'jdks/21/Contents/Home/legal/link'); - if (kind === 'changed') { - await symlink('java.base/LICENSE', link); manifest.files = await inventoryTree(directory, { excludeManifest: true, allowSymlinks: true, includeExecutable: true }); await saveManifest(directory, manifest); - await rm(link); await symlink('./java.base/LICENSE', link); - } else { - await writeFile(path.join(parent, 'outside'), 'outside'); - const target = kind === 'escape' ? '../../../../../../outside' : kind === 'absolute' ? path.join(parent, 'outside') : kind === 'cycle' ? 'link' : '../../../../../maven/LICENSE'; - await symlink(target, link); - } - await assert.rejects(loadBundledRuntime(directory, { expectedPlatform: 'darwin', expectedArch: 'arm64' }), invalidBundle, kind); - } -}); - -test('mac staging checks release target and copies a complete vendor .jdk without mutation', nativeUnix, async (t) => { - const root = await scratch(t); - const { directory, manifest } = await fixture(root, 'x64'); - const project = path.join(root, 'project'); await mkdir(path.join(project, 'scripts'), { recursive: true }); await mkdir(path.join(project, 'desktop')); - await cp(new URL('../../scripts/bundle-runtime.mjs', import.meta.url), path.join(project, 'scripts/bundle-runtime.mjs')); - await cp(new URL('../../desktop/bundled-runtime.cjs', import.meta.url), path.join(project, 'desktop/bundled-runtime.cjs')); - const metadata = path.join(root, 'metadata.json'); await writeFile(metadata, JSON.stringify({ jdks: manifest.jdks, maven: manifest.maven })); - const args = [path.join(project, 'scripts/bundle-runtime.mjs'), '--platform', 'darwin', '--arch', 'x64', '--jdk21', path.join(directory, 'jdks/21'), '--maven', path.join(directory, 'maven'), '--metadata', metadata]; - const wrong = spawnSync(process.execPath, args.map((arg) => arg === 'x64' ? 'arm64' : arg), { encoding: 'utf8' }); assert.notEqual(wrong.status, 0); assert.match(wrong.stderr, /release/); - const staged = spawnSync(process.execPath, args, { encoding: 'utf8' }); assert.equal(staged.status, 0, staged.stderr); - const bundle = await loadBundledRuntime(path.join(project, 'work/desktop-bundle'), { expectedPlatform: 'darwin', expectedArch: 'x64' }); assert.equal(bundle.arch, 'x64'); - assert.equal(await readFile(path.join(bundle.jdks[0].home, '../Info.plist'), 'utf8'), 'original vendor bundle metadata'); - const checked = spawnSync(process.execPath, [args[0], '--check', '--platform', 'darwin', '--arch', 'x64'], { encoding: 'utf8' }); assert.equal(checked.status, 0, checked.stderr); -}); diff --git a/tests/local/mac-signing.test.mjs b/tests/local/mac-signing.test.mjs deleted file mode 100644 index 966bf14..0000000 --- a/tests/local/mac-signing.test.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const buildConfig = require('../../desktop/build-config.cjs'); -const { MacTargetHelper } = require('app-builder-lib/out/mac/MacTargetHelper.js'); -const signingUtil = require('@electron/osx-sign/dist/cjs/util.js'); -const { signApp } = require('@electron/osx-sign/dist/cjs/sign.js'); - -// Run the installed builder and signer, replacing only native discovery/commands. -// These command-selection checks are not real macOS signing or app execution. -test('installed Mac signer excludes vendor toolchains but still signs the app', async (t) => { - const app = '/tmp/한글 경로/Java Edu.app'; - const toolchains = `${app}/Contents/Resources/toolchains`; - const vendor = [toolchains, `${toolchains}/jdks/21`, - `${toolchains}/jdks/21/Contents/Home/bin/java`, - `${toolchains}/jdks/21/Contents/Home/bin/javac`, - `${toolchains}/jdks/21/Contents/Home/lib/libjvm.dylib`, - `${toolchains}/maven/bin/mvn`]; - const electron = [`${app}/Contents/MacOS/Java Edu`, - `${app}/Contents/Frameworks/Electron Framework.framework`, - `${app}/Contents/Resources/toolchains-neighbor/native.dylib`]; - const calls = []; - t.mock.method(signingUtil, 'validateOptsApp', async () => {}); - t.mock.method(signingUtil, 'validateOptsPlatform', async () => 'darwin'); - t.mock.method(signingUtil, 'walkAsync', async () => [...vendor, ...electron]); - t.mock.method(signingUtil, 'execFileAsync', async (command, args) => { calls.push({ command, args }); return ''; }); - - const packager = { config: { electronVersion: require('electron/package.json').version }, - resourceList: Promise.resolve([]), platformSpecificBuildOptions: buildConfig.mac }; - const helper = new MacTargetHelper(packager); - for (const protectedVendor of [true, false]) { - calls.length = 0; - const config = { ...buildConfig.mac, ...(protectedVendor ? {} : { signIgnore: [] }) }; - const options = await helper.buildSignOptions(app, { name: '-' }, 'distribution', false, config, null, 3); - await signApp({ ...options, preEmbedProvisioningProfile: false, preAutoEntitlements: false }); - const signed = calls.filter(({ args }) => args.includes('--sign')); - const signedPaths = signed.map(({ args }) => args.at(-1)); - assert.deepEqual(new Set(signedPaths), new Set([...electron, app, ...(protectedVendor ? [] : vendor)])); - assert.ok(signed.every(({ command, args }) => command === 'codesign' && !args.includes('--deep'))); - assert.ok(calls.some(({ args }) => args.includes('--verify') && args.includes('--deep'))); - } -}); diff --git a/tests/local/macos-archive-layout.test.mjs b/tests/local/macos-archive-layout.test.mjs deleted file mode 100644 index 7cbff34..0000000 --- a/tests/local/macos-archive-layout.test.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, rm, realpath, rename, symlink } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { inspectVendorLayout } from '../../scripts/prepare-macos-runtime.mjs'; - -const versions = { jdkVersion: '21.0.12.1+1', mavenVersion: '3.10.0' }; -async function fixture(t) { - const parent = await realpath(tmpdir()); const root = await mkdtemp(path.join(parent, 'java-edu-archive-layout-')); - t.after(async () => { const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-archive-layout-')); await rm(resolved, { recursive: true, force: true }); }); - const jdk = path.join(root, 'jdk'); const maven = path.join(root, 'maven'); - const jdkRoot = path.join(jdk, 'jdk-21.0.12.1+1'); const mavenRoot = path.join(maven, 'apache-maven-3.10.0'); - await mkdir(path.join(jdkRoot, 'Contents/Home'), { recursive: true }); await mkdir(mavenRoot, { recursive: true }); - await writeFile(path.join(jdkRoot, 'Contents/Home/release'), 'original vendor release'); await writeFile(path.join(jdkRoot, 'Contents/Info.plist'), 'original signed bundle metadata'); - return { root, jdk, maven, jdkRoot, mavenRoot }; -} - -test('pinned Temurin archive root without .jdk suffix resolves the complete Contents tree', async (t) => { - const data = await fixture(t); const result = await inspectVendorLayout(data.jdk, data.maven, versions); - assert.equal(result.jdkRoot, data.jdkRoot); assert.equal(result.mavenRoot, data.mavenRoot); - assert.deepEqual(result.jdkNames, ['jdk-21.0.12.1+1']); assert.deepEqual(result.mavenNames, ['apache-maven-3.10.0']); -}); - -test('unexpected vendor roots and extra archive entries reject with observed names', async (t) => { - for (const kind of ['suffix', 'wrong-version', 'extra-jdk', 'extra-maven']) { - const data = await fixture(t); - if (kind === 'suffix') await rename(data.jdkRoot, `${data.jdkRoot}.jdk`); - if (kind === 'wrong-version') await rename(data.jdkRoot, path.join(data.jdk, 'jdk-21.0.11+1')); - if (kind === 'extra-jdk') await writeFile(path.join(data.jdk, 'unexpected.txt'), 'unexpected'); - if (kind === 'extra-maven') await mkdir(path.join(data.maven, '__MACOSX')); - await assert.rejects(inspectVendorLayout(data.jdk, data.maven, versions), (error) => /Unexpected vendor archive layout/.test(error.message) && /JDK entries=/.test(error.message) && /Maven entries=/.test(error.message), kind); - } -}); - -test('archive root contract rejects incomplete Contents trees and redirected roots', async (t) => { - for (const kind of ['missing-release', 'missing-plist', 'linked-root']) { - const data = await fixture(t); - if (kind === 'missing-release') await rm(path.join(data.jdkRoot, 'Contents/Home/release')); - if (kind === 'missing-plist') await rm(path.join(data.jdkRoot, 'Contents/Info.plist')); - if (kind === 'linked-root') { const moved = path.join(data.root, 'outside'); await rename(data.jdkRoot, moved); await symlink(moved, data.jdkRoot, process.platform === 'win32' ? 'junction' : 'dir'); t.after(async () => { await rm(data.jdkRoot, { force: true }); }); } - await assert.rejects(inspectVendorLayout(data.jdk, data.maven, versions), /Unexpected vendor archive layout: invalid/, kind); - } -}); diff --git a/tests/local/process-fixture.mjs b/tests/local/process-fixture.mjs index a9d6578..e022bac 100644 --- a/tests/local/process-fixture.mjs +++ b/tests/local/process-fixture.mjs @@ -1,6 +1,6 @@ // Fixed maintenance fixtures, never learner-supplied commands. import { spawn } from 'node:child_process'; -import { writeFileSync, existsSync } from 'node:fs'; +import { writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; if (process.argv[2] === 'echo') process.stdout.write(JSON.stringify(process.argv.slice(3))); @@ -15,7 +15,3 @@ else if (process.argv[2] === 'supervisor-parent') { const { runProcess, processEnvironment } = await import('../../server/local/process.mjs'); await runProcess(process.execPath, [fileURLToPath(import.meta.url), 'tree', process.argv[4]], { cwd: process.argv[3], env: processEnvironment(undefined, process.argv[3]), timeoutMs: 20_000 }); } -else if (['tree-exit', 'tree-exit-inherited'].includes(process.argv[2])) { - spawn(process.execPath, [fileURLToPath(import.meta.url), 'sleep', process.argv[3]], { stdio: process.argv[2] === 'tree-exit-inherited' ? 'inherit' : 'ignore', shell: false }); - setInterval(() => { if (existsSync(process.argv[3])) process.exit(0); }, 20); -} diff --git a/tests/local/process.test.mjs b/tests/local/process.test.mjs index 77b894d..c7ef69f 100644 --- a/tests/local/process.test.mjs +++ b/tests/local/process.test.mjs @@ -10,48 +10,6 @@ import { runProcess, processEnvironment } from '../../server/local/process.mjs'; const fixedFixture = fileURLToPath(new URL('./process-fixture.mjs', import.meta.url)); -test('Unix native argv, private temp, timeout, cancellation, output and normal-exit descendant cleanup', { skip: process.platform === 'win32' }, async (t) => { - const parent = await realpath(tmpdir()); - const root = await mkdtemp(path.join(parent, 'java-edu-local-unix-공백 & % 🚀-')); - const descendantPids = []; - t.after(async () => { - for (const pid of descendantPids) { try { process.kill(pid, 'SIGKILL'); } catch { /* already stopped */ } } - const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-local-unix-')); await rm(resolved, { recursive: true, force: true }); - }); - const env = processEnvironment(undefined, root); - assert.equal(env.TMPDIR, root); - for (const name of ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', 'MAVEN_OPTS', 'NODE_OPTIONS', 'CLASSPATH']) assert.equal(Object.hasOwn(env, name), false); - const argv = ['공백 포함', 'a&b%PATH%', 'a"quoted"', 'trailing\\', '']; - const echo = await runProcess(process.execPath, [fixedFixture, 'echo', ...argv], { cwd: root, env }); - assert.equal(echo.exitCode, 0, JSON.stringify(echo)); assert.deepEqual(JSON.parse(echo.stdout), argv); - const temporary = await runProcess(process.execPath, ['-p', 'JSON.stringify([process.env.TMPDIR,process.env.HOME,process.cwd()])'], { cwd: root, env }); - assert.deepEqual(JSON.parse(temporary.stdout), [root, root, root]); - async function readPid(file) { - let pid; - for (let retry = 0; retry < 100 && !pid; retry++) { await delay(50); pid = Number(await readFile(file, 'utf8').catch(() => '')); } - assert.ok(pid, 'Fixed descendant must start'); descendantPids.push(pid); return pid; - } - async function stopped(pid) { - for (let retry = 0; retry < 100; retry++) { try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') return; throw error; } await delay(50); } - assert.fail(`Descendant ${pid} survived process-group termination`); - } - const timeoutFile = path.join(root, 'timeout.pid'); - const timeout = await runProcess(process.execPath, [fixedFixture, 'tree', timeoutFile], { cwd: root, env, timeoutMs: 1200 }); - assert.equal(timeout.reason, 'timeout'); await stopped(await readPid(timeoutFile)); - const cancelledFile = path.join(root, 'cancelled.pid'); const controller = new AbortController(); - const running = runProcess(process.execPath, [fixedFixture, 'tree', cancelledFile], { cwd: root, env, signal: controller.signal }); - const cancelledPid = await readPid(cancelledFile); controller.abort(); const cancelled = await running; - assert.equal(cancelled.reason, 'cancelled'); await stopped(cancelledPid); - const flood = await runProcess(process.execPath, [fixedFixture, 'overflow'], { cwd: root, env, outputLimit: 1024 }); - assert.equal(flood.reason, 'output_limit'); assert.ok(Buffer.byteLength(flood.stdout) <= 1024); - for (const fixture of ['tree-exit', 'tree-exit-inherited']) { - const exitedFile = path.join(root, `${fixture}.pid`); - const exited = await runProcess(process.execPath, [fixedFixture, fixture, exitedFile], { cwd: root, env, timeoutMs: 2000 }); - assert.equal(exited.exitCode, 0, JSON.stringify(exited)); assert.equal(exited.reason, 'exited', `${fixture} must retain natural exit rather than wait for inherited pipes until timeout`); - await stopped(await readPid(exitedFile)); - } -}); - test('Windows Job supervisor preserves Unicode/metacharacter argv and stops timeout process trees', { skip: process.platform !== 'win32' }, async (t) => { const parent = await realpath(tmpdir()); const root = await mkdtemp(path.join(parent, 'java-edu-local-test-공백 & % 🚀-')); diff --git a/tests/macos-packaged.mjs b/tests/macos-packaged.mjs deleted file mode 100644 index c1d2e9e..0000000 --- a/tests/macos-packaged.mjs +++ /dev/null @@ -1,332 +0,0 @@ -import assert from 'node:assert/strict'; -import { access, lstat, readFile, mkdir, writeFile } from 'node:fs/promises'; -import { randomUUID } from 'node:crypto'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { _electron as electron } from 'playwright'; -import { lessons } from '../dist/data/curriculum.js'; -import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; - -// Production packaged app only. This harness observes real APIs and real tools; -// it never overrides dialogs, network, clipboard, JVM/Maven, HOME or userData. -const root = path.resolve(fileURLToPath(new URL('../', import.meta.url))); -const option = name => process.argv[process.argv.indexOf(name) + 1]; -const appPath = path.resolve(option('--app')); -const artifacts = path.resolve(option('--artifacts')); -const expectedUserData = path.resolve(option('--user-data')); -const ownershipToken = option('--ownership-token'); -const kind = option('--kind'); -const arch = option('--arch'); -const marker = '.java-edu-macos-qa-owner'; -const expectedVersion = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).version; -const evidence = { startedAt: new Date().toISOString(), kind, arch, appPath, expectedUserData, - scope: 'Actual DMG/ZIP production packaged app; real bundled JVM/Maven and loopback APIs; no mocks or home/data overrides.', - notExecuted: ['Gatekeeper downloaded-file/quarantine approval', 'Developer ID notarization', 'Physical Mac user playtest', 'Optional execution cancellation'] }; -const results = []; -const pageErrors = []; -const apiRequests = []; -const coreCases = [8, 21].map(version => lessons.find(lesson => lesson.minVersion === version)); -const project = professionalLessons.find(lesson => lesson.id === 'tx-proxy'); -const projectExercise = professionalExercises.find(exercise => exercise.id === project.exerciseId); -const progressKey = 'java-edu:progress:v1'; -const preferencesKey = 'java-edu:editor-preferences:v1'; -const inside = (child, parent) => path.relative(parent, child) !== '' && !path.relative(parent, child).startsWith('..') && !path.isAbsolute(path.relative(parent, child)); -const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); -const exists = file => access(file).then(() => true, () => false); -let app; -let page; -let runtime; -let launchAttempted = false; -let childClosed = true; - -async function eventually(read, expected, message, timeout = 15000) { - const until = Date.now() + timeout; - let value; - while (Date.now() < until) { - value = await read(); - if (typeof expected === 'function' ? expected(value) : value === expected) return value; - await pause(100); - } - assert.fail(message + ': ' + JSON.stringify(value)); -} -const editor = () => page.locator('#code-editor [role="textbox"]'); -const badge = type => page.locator('#lesson-achievements [data-achievement="' + type + '"]').getAttribute('data-complete'); -const stored = () => page.evaluate(keys => Object.fromEntries(keys.map(key => [key, window.javaEduDesktop.storage.getItem(key)])), [progressKey, preferencesKey]); -const progress = async () => JSON.parse((await stored())[progressKey] || '{}'); - -async function capture(name) { - await page.locator('#toast.is-visible').waitFor({ state: 'hidden', timeout: 10000 }); - await page.evaluate(() => document.fonts.ready.then(() => undefined)); - // Do not hide/move UI or overwrite styles for screenshots. - await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); - const file = path.join(artifacts, name + '.png'); - await page.screenshot({ path: file, fullPage: false }); - evidence.captures ??= []; - evidence.captures.push({ name, file, viewport: await page.evaluate(() => ({ width: innerWidth, height: innerHeight, scrollX, scrollY, hash: location.hash })) }); -} - -async function check(name, action) { - const start = Date.now(); - console.log('START ' + kind + ' ' + name); - try { - await action(); - results.push({ name, status: 'PASS', durationMs: Date.now() - start }); - console.log('PASS ' + kind + ' ' + name); - } catch (error) { - results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - start }); - if (page) await capture('failure-' + name).catch(() => {}); - throw error; - } -} - -async function launch() { - const env = { ...process.env }; - const forbidden = new Set(['PATH', 'JAVA_HOME', 'JDK_HOME', 'MAVEN_HOME', 'M2_HOME', 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', - 'JDK_JAVA_OPTIONS', 'MAVEN_OPTS', 'JAVA_EDU_QA_DATA', 'ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS', 'CLASSPATH']); - for (const key of Object.keys(env)) if (forbidden.has(key.toUpperCase()) || key.startsWith('DYLD_')) delete env[key]; - env.PATH = ''; - launchAttempted = true; - childClosed = false; - app = await electron.launch({ executablePath: path.join(appPath, 'Contents/MacOS/Java Edu'), args: [], env, timeout: 90000 }); - app.context().on('request', request => { - const url = new URL(request.url()); - if (url.pathname.startsWith('/api/')) apiRequests.push({ origin: url.origin, path: url.pathname, method: request.method() }); - }); - page = await app.firstWindow(); - page.setDefaultTimeout(20000); - page.on('pageerror', error => pageErrors.push(error.message)); - await page.locator('#workspace-grid').waitFor({ state: 'visible', timeout: 60000 }); - const facts = await app.evaluate(({ app, BrowserWindow }) => { - const prefs = BrowserWindow.getAllWindows()[0].webContents.getLastWebPreferences(); - return { packaged: app.isPackaged, name: app.getName(), version: app.getVersion(), platform: process.platform, arch: process.arch, - executable: app.getPath('exe'), appPath: app.getAppPath(), userData: app.getPath('userData'), home: app.getPath('home'), - preferences: Object.fromEntries(['contextIsolation', 'nodeIntegration', 'sandbox', 'webSecurity', 'allowRunningInsecureContent'].map(key => [key, prefs[key]])) }; - }); - evidence.applications ??= []; - evidence.applications.push(facts); - assert.equal(facts.packaged, true); - assert.equal(facts.version, expectedVersion); - assert.equal(facts.platform, 'darwin'); - assert.equal(facts.arch, arch, 'app runs native target arch'); - assert.equal(facts.executable, path.join(appPath, 'Contents/MacOS/Java Edu')); - assert.equal(facts.appPath, path.join(appPath, 'Contents/Resources/app.asar')); - assert.equal(facts.userData, expectedUserData, 'uses actual production default user data'); - for (const [key, value] of Object.entries({ contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, allowRunningInsecureContent: false })) assert.equal(facts.preferences[key], value); - const environment = await app.evaluate((_electron, names) => ({ - selected: Object.fromEntries(names.map(name => [name, process.env[name] ?? null])), - dyld: Object.keys(process.env).filter(key => key.startsWith('DYLD_')) - }), [...forbidden]); - assert.equal(environment.selected.PATH, ''); - assert.ok(Object.entries(environment.selected).filter(([key]) => key !== 'PATH').every(([, value]) => value === null)); - assert.deepEqual(environment.dyld, []); - evidence.appEnvironment = environment; - runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); - await verifyRuntime(runtime); -} - -async function verifyRuntime(value) { - assert.equal(value.mode, 'local'); - assert.deepEqual(value.supportedTargets, [8, 11, 17, 21]); - assert.deepEqual(value.runtimes.filter(item => item.available).map(item => item.javaVersion), [21]); - const jdk = value.runtimes.find(item => item.javaVersion === 21); - assert.equal(jdk.source, 'bundled'); - assert.equal(value.maven.source, 'bundled'); - assert.equal(value.maven.available, true); - assert.ok(value.bundledRuntime?.id); - assert.deepEqual(value.bundledRuntime.javaVersions, [21]); - assert.equal(jdk.path, path.join(appPath, 'Contents/Resources/toolchains/jdks/21/Contents/Home')); - assert.ok(inside(value.maven.path, path.join(appPath, 'Contents/Resources/toolchains'))); - for (const file of ['bin/java', 'bin/javac']) { - const info = await lstat(path.join(jdk.path, file)); - assert.ok(info.isFile() && (info.mode & 0o111) !== 0, file + ' is executable'); - } - assert.ok(await exists(path.join(value.maven.path, 'boot'))); -} - -async function openLesson(lesson, step = 'lab') { - await page.evaluate(hash => { location.hash = hash; }, 'lesson=' + lesson.id + '&step=' + step + '&version=21'); - await eventually(() => page.locator('#lesson-title').textContent(), text => text?.includes(lesson.title), 'lesson selected'); - await eventually(() => page.locator('[data-step="' + step + '"]').getAttribute('aria-selected'), 'true', 'stage selected'); -} -async function settings() { - await page.locator('#desktop-settings-open').click(); - await page.locator('#desktop-settings-dialog').waitFor({ state: 'visible' }); - await eventually(() => page.locator('#execution-consent').isEnabled(), true, 'settings loaded'); -} -async function closeSettings() { - await page.locator('#desktop-settings-dialog [data-close-dialog="desktop-settings-dialog"]').first().click(); - await page.locator('#desktop-settings-dialog').waitFor({ state: 'hidden' }); -} -async function quiz(lesson) { - await openLesson(lesson, 'quiz'); - for (const question of lesson.quiz) await page.locator('#quiz-form [name="quiz-' + question.id + '"][value="' + question.correctIndex + '"]').check(); - await page.locator('#quiz-submit').click(); - await eventually(() => badge('quiz'), 'true', 'quiz achievement persisted'); - await openLesson(lesson); -} -async function execute(button, expectedStatus = 'passed', timeout = 90000) { - const pathname = button === '#prepare-project' ? '/api/local/prepare' : '/api/execute'; - const pending = page.waitForResponse(response => new URL(response.url()).pathname === pathname && response.request().method() === 'POST', { timeout }); - void pending.catch(() => {}); - await page.locator(button).click(); - const response = await pending; - const result = await response.json(); - const request = response.request().postDataJSON(); - evidence.executions ??= []; - evidence.executions.push({ request, httpStatus: response.status(), result }); - await eventually(() => page.locator('#execution-output').getAttribute('data-status'), status => status !== 'running', 'result rendered'); - assert.equal(response.status(), 200); - assert.equal(result.provider, request.exerciseId === project.exerciseId ? 'Local Maven' : 'Local JDK'); - assert.equal(result.runtimeVersion, 21); - assert.equal(result.status, expectedStatus, result.stderr || result.compilerOutput || result.stdout); - assert.ok(result.total > 0); - if (expectedStatus === 'passed') assert.ok(result.passed === result.total && result.checks.every(check => check.passed)); - else assert.ok(result.passed < result.total && result.checks.some(check => !check.passed), 'actual failing tests returned'); - return { result, request }; -} -async function fillProject(reference) { - const files = {}; - for (const file of project.professionalExercise.files.filter(item => item.editable)) { - files[file.path] = reference ? await readFile(path.join(root, 'curriculum/professional', file.referencePath), 'utf8') : projectExercise.files.find(item => item.path === file.path).source; - await page.locator('#project-file-tabs [data-file-path="' + file.path + '"]').click(); - await editor().fill(files[file.path]); - } - return files; -} - -async function main() { - assert.equal(process.platform, 'darwin'); - assert.equal(process.env.GITHUB_ACTIONS, 'true', 'production-data gate is limited to ephemeral GitHub macOS CI'); - assert.ok(['dmg', 'zip'].includes(kind)); - assert.ok(['x64', 'arm64'].includes(arch)); - assert.equal((await readFile(path.join(expectedUserData, marker), 'utf8')).trim(), ownershipToken, 'orchestrator owns fresh production data'); - await mkdir(artifacts, { recursive: true }); - const solutions = JSON.parse(await readFile(path.join(root, 'examples/solutions.json'), 'utf8')); - await launch(); - const firstOrigin = new URL(page.url()).origin; - await check('first-launch-bundled-tools-before-consent', async () => { - evidence.runtimeBeforeConsent = runtime; - assert.equal(runtime.consent.accepted, false); - assert.equal(runtime.available, false); - await openLesson(coreCases[0]); - assert.equal(await page.locator('#run-code').isDisabled(), true); - assert.equal(await page.locator('#submit-code').isDisabled(), true); - await settings(); - assert.equal(await page.locator('#execution-consent').isChecked(), false); - assert.deepEqual(await page.locator('#desktop-bundle-status .settings-bundle-tools li span').allTextContents(), ['앱에 포함', '앱에 포함']); - assert.equal(apiRequests.filter(item => item.method === 'POST' && ['/api/execute', '/api/local/prepare'].includes(item.path)).length, 0); - await capture('first-launch-settings'); - await page.locator('#execution-consent').check(); - await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, true, 'explicit consent saved'); - await closeSettings(); - await eventually(() => page.locator('#run-code').isEnabled(), true, 'bundled tools enabled'); - }); - for (const lesson of coreCases) await check('core-jvm21-target' + lesson.minVersion, async () => { - await openLesson(lesson); - await editor().fill(solutions[lesson.exerciseId]); - await quiz(lesson); - const { result, request } = await execute('#submit-code'); - assert.equal(result.targetVersion, lesson.minVersion); - assert.equal(request.source, solutions[lesson.exerciseId]); - assert.equal(await badge('lab'), 'true'); - await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); - await capture('core-target' + lesson.minVersion + '-passed'); - }); - await check('spring-h2-online-prepare-preserves-draft-no-achievement', async () => { - await openLesson(project); - await eventually(() => page.locator('#prepare-project').isEnabled(), true, 'prepare enabled'); - assert.equal(await page.locator('#submit-code').isDisabled(), true); - const first = projectExercise.files.find(file => file.editable); - await page.locator('#project-file-tabs [data-file-path="' + first.path + '"]').click(); - await editor().fill(first.source + '\n// preparation draft sentinel\n'); - await openLesson(project, 'theory'); - await openLesson(project); - const before = (await progress()).fileDrafts; - const { result, request } = await execute('#prepare-project', 'passed', 420000); - assert.equal(result.targetVersion, 17); - assert.equal(result.prepared, true); - assert.equal(request.source, undefined); - assert.equal(request.files, undefined); - assert.deepEqual((await progress()).fileDrafts, before); - assert.equal(await badge('quiz'), 'false'); - assert.equal(await badge('lab'), 'false'); - await eventually(() => page.locator('#project-preparation-state').getAttribute('data-prepared'), 'true', 'preparation stored'); - await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); - await capture('spring-h2-prepared'); - }); - await check('spring-h2-starter-fails-without-completion', async () => { - const expected = await fillProject(false); - const { result, request } = await execute('#submit-code', 'failed', 240000); - assert.deepEqual(request.files, expected); - assert.equal(result.targetVersion, 17); - assert.equal(await badge('lab'), 'false'); - await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); - await capture('spring-h2-starter-failed'); - }); - await check('spring-h2-reference-offline-submit', async () => { - const expected = await fillProject(true); - await quiz(project); - const { result, request } = await execute('#submit-code', 'passed', 240000); - assert.deepEqual(request.files, expected); - assert.equal(Object.hasOwn(request.files, 'pom.xml'), false); - assert.equal(result.targetVersion, 17); - assert.equal(await badge('lab'), 'true'); - await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); - await capture('spring-h2-reference-passed'); - }); - await check('quit-relaunch-progress-theme-core-project-drafts', async () => { - const core = coreCases[0]; - const sentinel = 'macos ' + kind + ' restart ' + randomUUID(); - await openLesson(core); - await editor().fill(solutions[core.exerciseId] + '\n// ' + sentinel + '\n'); - await page.locator('#editor-theme-select').selectOption('contrast'); - await openLesson(project); - const first = projectExercise.files.find(file => file.editable); - await page.locator('#project-file-tabs [data-file-path="' + first.path + '"]').click(); - await editor().fill(first.source + '\n// ' + sentinel + ' project\n'); - await openLesson(coreCases[1], 'theory'); - const before = await stored(); - const registered = await page.evaluate(() => window.javaEduDesktop.getSettings()); - await app.close(); childClosed = true; app = null; - await launch(); - evidence.origins = [firstOrigin, new URL(page.url()).origin]; - // A new ephemeral port normally changes; durability must not depend on it. - const after = await stored(); - assert.equal(after[preferencesKey], before[preferencesKey]); - for (const field of ['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'notes']) assert.deepEqual(JSON.parse(after[progressKey])[field], JSON.parse(before[progressKey])[field], field + ' persists'); - assert.equal(runtime.consent.accepted, true); - assert.deepEqual(runtime.bundledRuntime, registered.bundledRuntime); - evidence.runtimeAfterRestart = runtime; - await openLesson(core); - assert.equal(await page.locator('#editor-theme-select').inputValue(), 'contrast'); - await editor().press('Meta+End'); - await eventually(() => editor().innerText(), text => text.includes(sentinel), 'core draft restored in actual editor'); - await openLesson(project); - await page.locator('#project-file-tabs [data-file-path="' + first.path + '"]').click(); - await editor().press('Meta+End'); - await eventually(() => editor().innerText(), text => text.includes(sentinel + ' project'), 'project draft restored in actual editor'); - assert.equal(await page.locator('#prepare-project').getAttribute('data-prepared'), 'false', 'runtime preparation resets across quit'); - await openLesson(coreCases[1]); - assert.equal(await badge('quiz'), 'true'); - assert.equal(await badge('lab'), 'true'); - await capture('restarted-progress'); - }); - assert.deepEqual(pageErrors, []); - assert.ok(apiRequests.every(request => new URL(request.origin).hostname === '127.0.0.1')); -} -try { await main(); } -catch (error) { - if (!results.some(result => result.status === 'FAIL')) results.push({ name: 'preflight-or-launch', status: 'FAIL', message: error.message }); - evidence.error = error.stack; - console.error(error.stack); - process.exitCode = 1; -} finally { - if (app) { - try { await app.close(); childClosed = true; evidence.closed = true; } - catch (error) { evidence.closeError = error.message; process.exitCode = 1; } - } else evidence.closed = !launchAttempted || childClosed; - evidence.finishedAt = new Date().toISOString(); - Object.assign(evidence, { results, pageErrors, apiRequests, status: process.exitCode ? 'FAIL' : 'PASS' }); - await mkdir(artifacts, { recursive: true }); - await writeFile(path.join(artifacts, 'runtime-results.json'), JSON.stringify(evidence, null, 2)); -}

Rv)y9H=~R803F`4uhc${279!!< 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 { const index = process.argv.indexOf(name); return index === -1 ? fallback : process.argv[index + 1]; @@ -19,7 +20,7 @@ const option = (name, fallback) => { const kind = option('--kind', process.env.BUNDLED_PACKAGE_KIND); const executableInput = option('--executable', process.env.BUNDLED_APP_EXE); const executablePath = executableInput && path.resolve(executableInput); -const artifactsRoot = path.join(root, 'work/bundled-desktop-qa'); +const artifactsRoot = path.resolve(root, process.env.BUNDLED_QA_ARTIFACT_DIR || 'work/bundled-desktop-qa'); const artifacts = path.join(artifactsRoot, kind === 'zip' || kind === 'nsis' ? kind : 'invalid-input'); const preflightPath = path.join(artifactsRoot, 'preflight.json'); const progressKey = 'java-edu:progress:v1'; @@ -327,7 +328,7 @@ async function main() { userData = facts.userData; evidence.application = facts; assert.equal(facts.packaged, true); - assert.equal(facts.version, '0.3.0'); + assert.equal(facts.version, expectedVersion, 'packaged version matches the canonical release package'); assert.equal(normalize(facts.executable), normalize(executablePath)); assert.ok(preflight.AppData.some(item => normalize(item.Path) === normalize(userData)), 'default production AppData only'); for (const [key, value] of Object.entries({ contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, allowRunningInsecureContent: false })) assert.equal(facts.preferences[key], value); @@ -357,6 +358,41 @@ async function main() { await closeSettings(); }); + await check('all34-packaged-reading-guides-and-prediction-without-achievement', async () => { + const lessons = [...coreLessons, ...professionalLessons]; + assert.equal(lessons.length, 34); + const beforeRequests = executionCount(); + const learningState = async () => { + const stored = await progress(); + return Object.fromEntries(['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'notes'].map(field => [field, stored[field] || []])); + }; + const before = await learningState(); + for (const lesson of lessons) { + assert.ok(lesson.learningGuide, `${lesson.id}: expected release guide exists`); + await openLesson(lesson, 'theory'); + assert.equal(await page.locator('.learning-guide').count(), 1, `${lesson.id}: packaged guide renders`); + assert.equal(await page.locator('.guide-scenario-text').textContent(), lesson.learningGuide.scenario); + assert.equal(await page.locator('#theory-code-panel code').textContent(), lesson.learningGuide.code.source); + assert.deepEqual(await page.locator('.guide-walkthrough li').allTextContents(), lesson.learningGuide.walkthrough); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), null); + await page.locator('.guide-prediction summary').focus(); + await page.keyboard.press('Enter'); + assert.equal(await page.locator('.guide-answer-text').isVisible(), true); + assert.equal(await page.locator('.guide-answer-text').textContent(), lesson.learningGuide.prediction.answer); + assert.equal(await page.locator('.guide-answer-explanation').textContent(), lesson.learningGuide.prediction.explanation); + await page.keyboard.press('Space'); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), null); + } + assert.deepEqual(await learningState(), before, 'reading and revealing answers do not grant or erase learning state'); + assert.equal(executionCount(), beforeRequests, 'reading guides never execute or prepare code'); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + evidence.packagedReadingGuides = { verified: lessons.map(lesson => lesson.id), predictionGrantsAchievement: false }; + await openLesson(professionalLessons.find(lesson => lesson.id === 'jpa-state'), 'theory'); + await page.locator('#theory-code-panel').scrollIntoViewIfNeeded(); + await capture('bundled-reading-guide-code'); + await openLesson(coreCases[0]); + }); + await check('consent-enables-bundled-tools-without-directory-selection', async () => { await settings(); await page.locator('#execution-consent').check(); diff --git a/tests/local/reference-policy.test.mjs b/tests/local/reference-policy.test.mjs index 50ef37e..06c1239 100644 --- a/tests/local/reference-policy.test.mjs +++ b/tests/local/reference-policy.test.mjs @@ -30,8 +30,16 @@ test('hiring evidence, actual UI links and installation guide downloads remain a const uiUrls = uiFiles.flatMap((content) => [...content.matchAll(/https:\/\/[^\s'"<>`]+/g)].map((match) => match[0])); const installUrls = [...(await text('docs/installation.md')).matchAll(/\]\((https:\/\/[^\s)]+)\)/g)].map((match) => match[1]); assert.ok(uiUrls.some((url) => url.startsWith('https://github.com/jbsim999/java-edu/releases'))); - assert.ok(installUrls.some((url) => new URL(url).hostname === 'adoptium.net')); - assert.ok(installUrls.some((url) => new URL(url).hostname === 'maven.apache.org')); + // Bundled releases replaced the former mandatory JDK/Maven download steps. + // Require the actual learner downloads while retaining the host allowlist + // checks for every documented and rendered link below. + for (const extension of ['exe', 'zip']) { + assert.ok(installUrls.some((value) => { + const url = new URL(value); + return url.hostname === 'github.com' + && new RegExp(`^/jbsim999/java-edu/releases/download/v[^/]+/[^/]+\\.${extension}$`, 'i').test(url.pathname); + }), `Missing direct ${extension.toUpperCase()} bundled release download`); + } assert.equal(jobEvidence.length, 5); for (const url of [...jobEvidence.map((entry) => entry.url), ...uiUrls, ...installUrls]) { assert.equal(externalReferenceUrl(url), new URL(url).href, url); diff --git a/tests/readability.mjs b/tests/readability.mjs new file mode 100644 index 0000000..d97d4cb --- /dev/null +++ b/tests/readability.mjs @@ -0,0 +1,687 @@ +import assert from 'node:assert/strict'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { exercises as coreExercises } from '../dist/data/exercises.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; +import { lessonVisuals } from '../dist/data/lesson-visuals.js'; + +// Isolated browser emulation only. Every execution API and external navigation +// is intercepted; no learner source or source-reference request leaves the PC. +const root = fileURLToPath(new URL('../', import.meta.url)); +const base = process.env.QA_BASE_URL || 'http://127.0.0.1:4173'; +const artifacts = path.resolve(process.env.QA_ARTIFACT_DIR || path.join(root, 'work/readability-qa')); +const filter = process.env.QA_FILTER ? new RegExp(process.env.QA_FILTER) : null; +const lessons = [...coreLessons, ...professionalLessons]; +const storageKey = 'java-edu:progress:v1'; +const results = []; +const filesToHash = ['dist/index.html', 'dist/app.js', 'dist/styles.css', 'dist/data/curriculum.js', 'dist/data/professional.js', 'dist/data/lesson-visuals.js', 'tests/readability.mjs']; +let browser; + +async function hashes() { + return Object.fromEntries(await Promise.all(filesToHash.map(async relative => + [relative, createHash('sha256').update(await readFile(path.join(root, relative))).digest('hex')]))); +} + +async function eventually(read, expected, message, timeout = 8000) { + const deadline = Date.now() + timeout; + let value; + do { + value = await read(); + if (typeof expected === 'function' ? expected(value) : value === expected) return value; + await new Promise(resolve => setTimeout(resolve, 40)); + } while (Date.now() < deadline); + assert.fail(`${message}: ${JSON.stringify(value)}`); +} + +async function openLesson(page, id, step = 'theory') { + const lesson = lessons.find(item => item.id === id); + await page.evaluate(hash => { location.hash = hash; }, `lesson=${id}&step=${step}&version=21`); + await eventually(() => page.locator('#lesson-title').textContent(), text => text?.includes(lesson.title), `${id} selected`); + await eventually(() => page.locator(`[data-step="${step}"]`).getAttribute('aria-selected'), 'true', `${step} selected`); +} + +async function snapshot(page) { + return page.evaluate(key => { + const stored = JSON.parse(localStorage.getItem(key) || '{}'); + return Object.fromEntries(['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'notes'].map(field => [field, stored[field] || []])); + }, storageKey); +} + +async function originalTheory(page, lesson) { + const sections = await page.locator('#lesson-panel > .theory-section').evaluateAll(nodes => + nodes.map(node => ({ heading: node.querySelector('h3').textContent, text: node.querySelector('p').textContent }))); + assert.deepEqual(sections, lesson.theory.map(({ heading, text }) => ({ heading, text })), `${lesson.id}: original theory remains accessible`); + for (const section of await page.locator('#lesson-panel > .theory-section').all()) assert.equal(await section.isVisible(), true, `${lesson.id}: complete theory is expanded by default`); +} + +async function keyboardDetails(page, selector, open) { + const details = page.locator(selector); + const summary = details.locator(':scope > summary'); + await summary.focus(); + if (await details.evaluate(node => node.open) !== open) await page.keyboard.press(open ? 'Enter' : 'Space'); + assert.equal(await details.evaluate(node => node.open), open, `${selector}: keyboard disclosure state`); + assert.equal(await summary.evaluate(node => node === document.activeElement), true, `${selector}: summary retains focus`); + const outline = await summary.evaluate(node => ({ width: getComputedStyle(node).outlineWidth, style: getComputedStyle(node).outlineStyle })); + assert.ok(parseFloat(outline.width) > 0 && outline.style !== 'none', `${selector}: visible keyboard focus`); +} + +async function inspectSupport(page, lesson) { + assert.equal(await page.locator('#lesson-info').evaluate(node => node.open), false); + assert.equal(await page.locator('#reference-column').evaluate(node => node.open), false); + assert.equal(await page.locator('.learning-goals').count(), 1, 'goals appear once'); + assert.equal(await page.locator('#lesson-panel .learning-goals').count(), 0, 'goals do not repeat within the reading flow'); + assert.equal(await page.locator('#lesson-description').isVisible(), false); + assert.equal(await page.locator('#source-list').isVisible(), false); + assert.equal(await page.locator('#lesson-description').textContent(), lesson.description); + assert.deepEqual(await page.locator('#lesson-goals li span').allTextContents(), lesson.goals); + await keyboardDetails(page, '#lesson-info', true); + assert.equal(await page.locator('#lesson-description').isVisible(), true); + await keyboardDetails(page, '#reference-column', true); + const sources = await page.locator('#source-list a').evaluateAll(nodes => nodes.map(node => ({ + title: node.querySelector('strong').textContent, url: node.href, target: node.target, rel: node.rel, metadata: node.querySelector('span').textContent, + }))); + assert.deepEqual(sources.map(({ title, url }) => ({ title, url })), lesson.sources.map(({ title, url }) => ({ title, url: new URL(url).href }))); + sources.forEach((source, index) => { + assert.equal(source.target, '_blank'); + assert.ok(source.rel.includes('noopener') && source.rel.includes('noreferrer')); + if (lesson.sources[index].edition) assert.ok(source.metadata.includes(lesson.sources[index].edition)); + if (lesson.sources[index].checkedAt) assert.ok(source.metadata.includes(lesson.sources[index].checkedAt)); + }); + assert.match(await page.locator('.evidence-disclaimer').textContent(), /통계가 아닙니다/); + assert.match(await page.locator('.evidence-disclaimer').textContent(), /확인일.*게시일.*채용 여부/); + await keyboardDetails(page, '#reference-column', false); + await keyboardDetails(page, '#lesson-info', false); + const legend = page.locator('.code-legend-disclosure'); + if (await legend.count()) { + assert.equal(await legend.evaluate(node => node.open), false); + assert.equal(await legend.locator('.syntax-legend').isVisible(), false); + await keyboardDetails(page, '.code-legend-disclosure', true); + assert.equal(await legend.locator('.syntax-legend').isVisible(), true); + assert.ok(await legend.locator('.syntax-legend-item').count() >= 3); + await keyboardDetails(page, '.code-legend-disclosure', false); + } +} + +async function inspectGuide(page, lesson) { + const guide = lesson.learningGuide; + assert.equal(await page.locator('.learning-guide').count(), 1, `${lesson.id}: single guide`); + for (const [selector, expected] of [ + ['.guide-scenario-text', guide.scenario], ['.guide-question', guide.question], + ['.guide-code-filename', guide.code.file], ['#theory-code-panel code', guide.code.source], + ['.guide-prediction-question', guide.prediction.question], ['.guide-answer-text', guide.prediction.answer], + ['.guide-answer-explanation', guide.prediction.explanation], ['.guide-takeaway > p:last-child', guide.takeaway], + ]) assert.equal(await page.locator(selector).textContent(), expected, `${lesson.id}: ${selector}`); + const terms = await page.locator('.guide-term').evaluateAll(nodes => nodes.map(node => + ({ term: node.querySelector('dt').textContent, meaning: node.querySelector('dd').textContent }))); + assert.deepEqual(terms, guide.terms, `${lesson.id}: accessible term-definition pairs`); + assert.deepEqual(await page.locator('.guide-walkthrough li').allTextContents(), guide.walkthrough, `${lesson.id}: numbered code trace`); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), null, `${lesson.id}: answer initially hidden`); + assert.equal(await page.locator('.guide-answer-text').isVisible(), false); + const order = await page.locator('.learning-guide').evaluate(node => [...node.children].map(child => child.className)); + assert.deepEqual(order, ['guide-scenario', 'guide-section guide-terms', 'guide-section guide-code-section', 'guide-section guide-walkthrough', 'guide-section guide-prediction-section', 'guide-takeaway']); + assert.equal(await page.locator('#theory-code-panel').getAttribute('tabindex'), '0', 'long code block accepts keyboard focus'); + assert.match(await page.locator('#theory-code-panel').getAttribute('aria-label'), /이론.*발췌/); + const invalidIds = await page.evaluate(() => { + const ids = [...document.querySelectorAll('[id]')].map(node => node.id); + const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index); + const unresolved = [...document.querySelectorAll('.learning-guide [aria-describedby],.learning-guide [aria-labelledby]')] + .flatMap(node => ['aria-describedby', 'aria-labelledby'].flatMap(attribute => (node.getAttribute(attribute) || '').split(/\s+/).filter(Boolean))) + .filter(id => !document.getElementById(id)); + return { duplicates, unresolved }; + }); + assert.deepEqual(invalidIds, { duplicates: [], unresolved: [] }); + await originalTheory(page, lesson); +} + +async function geometry(page, id) { + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const value = await page.evaluate(() => { + const guide = document.querySelector('.learning-guide'); + const boundary = guide?.getBoundingClientRect(); + const bad = guide ? [...guide.querySelectorAll('h3,p,dt,dd,li,summary,button,select,pre,.guide-code-filename,.syntax-legend-item')].flatMap(node => { + if (!node.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true })) return []; + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + const outside = rect.left < boundary.left - 1 || rect.right > boundary.right + 1; + const clipped = node.tagName !== 'PRE' && node.clientWidth > 0 && ['clip', 'hidden'].includes(style.overflowX) && node.scrollWidth > node.clientWidth + 1; + return outside || clipped ? [{ tag: node.tagName, text: node.textContent.slice(0, 100), outside, clipped, left: rect.left, right: rect.right }] : []; + }) : []; + const card = document.querySelector('#lesson-card').getBoundingClientRect(); + for (const node of document.querySelectorAll('.lesson-support p,.lesson-support a,.lesson-support summary,.lesson-support li,.lesson-support span,.lesson-support strong,.lesson-support h3,.reference-card')) { + // Closed native details can retain old layout rectangles in Chromium. + // Only visible descendants participate; the suite also opens every card. + if (!node.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true })) continue; + const rect = node.getBoundingClientRect(); + if (rect.left < card.left - 1 || rect.right > card.right + 1) bad.push({ tag: node.tagName, text: node.textContent.slice(0, 100), left: rect.left, right: rect.right, reason: 'expanded support escapes lesson card' }); + } + return { width: document.documentElement.clientWidth, scroll: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth), bad }; + }); + assert.ok(value.scroll <= value.width + 1, `${id}: page horizontal overflow ${JSON.stringify(value)}`); + assert.deepEqual(value.bad, [], `${id}: visible lesson content escapes or clips its container`); +} + +async function activeTabGeometry(page, label) { + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const value = await page.locator('.lesson-tabs [aria-selected="true"]').evaluate(node => { + const rect = node.getBoundingClientRect(); + const strip = node.parentElement.getBoundingClientRect(); + return { tab: node.id, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, stripLeft: strip.left, stripRight: strip.right, stripTop: strip.top, stripBottom: strip.bottom, scrollLeft: node.parentElement.scrollLeft }; + }); + assert.ok(value.left >= value.stripLeft - 1 && value.right <= value.stripRight + 1 && value.top >= value.stripTop - 1 && value.bottom <= value.stripBottom + 1, `${label}: selected tab fully visible inside strip ${JSON.stringify(value)}`); + return value; +} + +async function capture(page, filename, selector = '.guide-scenario') { + await page.locator(selector).first().scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(artifacts, `${filename}.png`), fullPage: false }); +} + +async function captureBoundary(page, filename, nextSelector) { + // Ordinary document scrolling: retain the real composed viewport, including + // the preceding content and the start of the next existing section. + await page.locator(nextSelector).first().evaluate(node => { + const top = node.getBoundingClientRect().top + scrollY; + window.scrollTo({ top: Math.max(0, top - innerHeight * 0.4), behavior: 'instant' }); + }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await page.screenshot({ path: path.join(artifacts, `${filename}.png`), fullPage: false }); +} + +function contrastRatio(foreground, background) { + const luminance = rgb => rgb.match(/[\d.]+/g).slice(0, 3).map(value => Number(value) / 255) + .map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4) + .reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0); + const [a, b] = [luminance(foreground), luminance(background)]; + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +async function run(name, test, options = {}) { + if (filter && !filter.test(name)) return; + const context = await browser.newContext({ viewport: options.viewport || { width: 1440, height: 1000 }, hasTouch: options.touch || false, serviceWorkers: 'block' }); + const requests = [], external = [], pageErrors = []; + await context.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { + writeText: async value => { + if (window.qaClipboardDenied) throw new DOMException('QA denied', 'NotAllowedError'); + window.qaClipboardText = value; + }, + } }); + }); + await context.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.origin !== new URL(base).origin) { + external.push(url.href); + return route.fulfill({ contentType: 'text/plain', body: 'QA blocks remote network.' }); + } + if (url.pathname.startsWith('/api/')) { + requests.push({ path: url.pathname, method: route.request().method() }); + if (url.pathname === '/api/runtime') return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ provider: 'Wandbox', available: true, runtime: { javaVersion: 21 }, supportedTargets: [8, 11, 17, 21], notice: 'QA 모의 실행. 외부 코드 전송 없음.' }) }); + if (url.pathname === '/api/execute' && options.mockSubmit) { + const request = route.request().postDataJSON(); + return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ requestId: request.requestId, status: 'passed', runtimeVersion: 21, targetVersion: 8, stdout: 'QA mocked result', stderr: '', compilerOutput: '', checks: [{ name: 'QA mock check', passed: true }], passed: 1, total: 1, provider: 'QA mock', durationMs: 1 }) }); + } + return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { code: 'qa_no_execution', message: 'This suite blocks execution.' } }) }); + } + if (url.pathname === '/data/curriculum.js' && options.core) return route.fulfill({ contentType: 'text/javascript', body: `export const lessons = ${JSON.stringify(options.core)};` }); + if (url.pathname === '/data/professional.js' && options.professional) return route.fulfill({ contentType: 'text/javascript', body: `export const professionalLessons = ${JSON.stringify(options.professional)}; export const professionalExercises = ${JSON.stringify(professionalExercises)};` }); + if (url.pathname === '/assets/editor.bundle.js' && options.noEditor) return route.abort('failed'); + return route.continue(); + }); + const page = await context.newPage(); + page.setDefaultTimeout(8000); + page.on('pageerror', error => pageErrors.push(error.message)); + const started = Date.now(); + try { + await page.goto(base); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + if (options.font === 200) await page.locator('html').evaluate(node => { node.style.fontSize = '200%'; }); + const evidence = await test({ page, context, requests, external }); + assert.deepEqual(pageErrors, [], 'no uncaught browser errors'); + assert.equal(requests.filter(request => request.path !== '/api/runtime').length, options.mockSubmit ? 1 : 0, 'only the explicitly requested mock submission is permitted'); + if (options.activateReference) assert.equal(external.length, 1, 'one explicitly tested source activation is intercepted'); + else assert.deepEqual(external, [], 'this case does not activate remote links'); + results.push({ name, status: 'PASS', durationMs: Date.now() - started, requests, ...(evidence ? { evidence } : {}) }); + console.log(`PASS ${name}`); + } catch (error) { + await page.screenshot({ path: path.join(artifacts, `failure-${name}.png`), fullPage: true }).catch(() => {}); + results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - started, requests, pageErrors }); + console.error(`FAIL ${name}: ${error.stack}`); + } finally { await context.close(); } +} + +async function main() { + await mkdir(artifacts, { recursive: true }); + assert.equal(lessons.length, 34); + assert.ok(lessons.every(lesson => lesson.learningGuide), 'wait until all 34 guides and the generated professional distribution are ready'); + const response = await fetch(base, { signal: AbortSignal.timeout(5000) }); + assert.ok(response.ok, 'parent-owned preview is available'); + const before = await hashes(); + browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}) }); + const browserVersion = browser.version(); + try { + await run('all34-guide-content-order-and-original-theory', async ({ page }) => { + for (const lesson of lessons) { + await openLesson(page, lesson.id); + await inspectGuide(page, lesson); + await geometry(page, lesson.id); + await page.locator('.learning-guide').screenshot({ path: path.join(artifacts, `${lesson.id}-desktop-guide.png`) }); + } + for (const [id, viewport] of [['java-basics', { width: 1440, height: 1000 }], ['jpa-state', { width: 390, height: 844 }]]) { + await page.setViewportSize(viewport); + await openLesson(page, id); + await geometry(page, id); + await captureBoundary(page, `${id}-${viewport.width}-guide-to-theory-boundary`, '.theory-reading-title'); + await captureBoundary(page, `${id}-${viewport.width}-theory-to-visual-boundary`, '.theory-visual'); + if (viewport.width === 390) { + await page.locator('#mobile-menu-toggle').click(); + assert.equal(await page.locator('#mobile-menu-toggle').getAttribute('aria-expanded'), 'true'); + await page.screenshot({ path: path.join(artifacts, `${id}-390-open-curriculum-drawer.png`), fullPage: false }); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#mobile-menu-toggle').getAttribute('aria-expanded'), 'false'); + assert.equal(await page.locator('#mobile-menu-toggle').evaluate(node => node === document.activeElement), true); + } + } + }); + + await run('all34-support-disclosures-keyboard-focus-and-source-access', async ({ page, context, external }) => { + const before = await snapshot(page); + for (const lesson of lessons) { + await openLesson(page, lesson.id); + await inspectSupport(page, lesson); + assert.equal(await page.locator('.guide-scenario > h3').count(), 1); + assert.equal(await page.locator('.guide-scenario > .small-label').count(), 0); + if (lesson.id === 'java-basics') { + for (const [selector, name, target] of [['#lesson-info', 'lesson-info', '.lesson-information'], ['#reference-column', 'references', '.reference-content'], ['.code-legend-disclosure', 'syntax-legend', '.guide-code-tools']]) { + await keyboardDetails(page, selector, true); + await capture(page, `java-basics-1440-expanded-${name}`, target); + await keyboardDetails(page, selector, false); + } + } + } + await openLesson(page, 'exceptions'); + await keyboardDetails(page, '#reference-column', true); + const source = page.locator('#source-list a').first(); + await source.focus(); + const expected = await source.getAttribute('href'); + const popupPromise = context.waitForEvent('page'); + await page.keyboard.press('Enter'); + const popup = await popupPromise; + await popup.waitForLoadState('domcontentloaded'); + assert.equal(popup.url(), new URL(expected).href); + assert.equal(await popup.evaluate(() => window.opener), null); + assert.deepEqual(external, [new URL(expected).href]); + assert.match(await popup.locator('body').innerText(), /QA blocks remote network/); + await popup.close(); + await keyboardDetails(page, '#reference-column', false); + assert.deepEqual(await snapshot(page), before, 'all informational disclosure actions preserve records'); + return { sourceUrl: expected, sourceActivation: 'intercepted local mock; external source content not fetched' }; + }, { activateReference: true }); + + await run('wide-reading-layout-and-twelve-original-diagram-boundaries', async ({ page }) => { + const widths = []; + for (const [width, minimum] of [[1280, 880], [1440, 1000], [1920, 1200]]) { + await page.setViewportSize({ width, height: width === 1280 ? 600 : 1000 }); + await openLesson(page, 'java-basics'); + await geometry(page, `wide-${width}`); + const measurements = await page.evaluate(() => { + const widthOf = selector => document.querySelector(selector).getBoundingClientRect().width; + return { viewport: innerWidth, lesson: widthOf('#lesson-card'), panel: widthOf('#lesson-panel'), code: widthOf('.guide-code'), visual: widthOf('.theory-visual'), sidebar: widthOf('#curriculum-sidebar'), columns: getComputedStyle(document.querySelector('#workspace-grid')).gridTemplateColumns, referenceInsideArticle: document.querySelector('#lesson-card').contains(document.querySelector('#reference-column')) }; + }); + assert.ok(measurements.code >= minimum && measurements.visual >= minimum, `wide reading target: ${JSON.stringify(measurements)}`); + assert.equal(measurements.columns.split(' ').length, 2, 'only navigation and lesson occupy persistent columns'); + assert.equal(measurements.referenceInsideArticle, true); + widths.push(measurements); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + if (width === 1280) { + const visible = await page.locator('.guide-scenario-text').evaluate(node => { + const rect = node.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom, height: rect.height, visibleHeight: Math.min(innerHeight, rect.bottom) - Math.max(0, rect.top) }; + }); + assert.ok(visible.visibleHeight >= Math.min(visible.height, 40) - 1, `1280×600 starts with readable scenario: ${JSON.stringify(visible)}`); + } + await page.screenshot({ path: path.join(artifacts, `reading-layout-${width}-top.png`), fullPage: false }); + } + const transitions = []; + for (const width of [1280, 1250, 1280]) { + await page.setViewportSize({ width, height: 600 }); + await geometry(page, `desktop-transition-${width}`); + transitions.push({ width, codeWidth: (await page.locator('.guide-code').boundingBox()).width }); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await page.screenshot({ path: path.join(artifacts, `reading-layout-${width}-transition-top.png`), fullPage: false }); + } + assert.ok(Math.abs(transitions[0].codeWidth - transitions[1].codeWidth) < 100, '1250 breakpoint does not suddenly narrow the reading area'); + assert.equal(transitions[0].codeWidth, transitions[2].codeWidth, 'restored desktop viewport restores reading width'); + assert.equal(Object.keys(lessonVisuals).length, 12); + const diagrams = []; + await page.setViewportSize({ width: 1440, height: 1000 }); + for (const [id, visual] of Object.entries(lessonVisuals)) { + await openLesson(page, id); + const figure = page.locator('#lesson-panel > figure.theory-visual'); + assert.equal(await figure.count(), 1); + assert.equal(await figure.isVisible(), true); + assert.equal(await figure.getAttribute('data-visual-id'), visual.id); + assert.equal(await figure.locator('.visual-title').textContent(), visual.title); + assert.equal(await figure.locator('.visual-description').textContent(), visual.caption); + assert.equal(await figure.locator('.visual-alt-text').textContent(), visual.altText); + const preceding = await figure.evaluate(node => [...node.parentElement.children].slice(0, [...node.parentElement.children].indexOf(node)).filter(item => item.classList.contains('theory-section')).length); + assert.equal(preceding, visual.afterSection + 1, `${id}: original theory boundary remains`); + assert.equal(await figure.evaluate(node => Boolean(node.closest('details'))), false, 'diagram is not hidden in an information disclosure'); + diagrams.push({ id, visual: visual.id, precedingTheorySections: preceding }); + } + return { widths, transitions, diagrams }; + }); + + await run('all34-five-stages-retain-example-quiz-practice-and-lab', async ({ page }) => { + for (const lesson of lessons) { + for (const step of ['theory', 'example', 'quiz', 'practice', 'lab']) { + await openLesson(page, lesson.id, step); + assert.ok((await page.locator('#lesson-panel').innerText()).trim().length > 40, `${lesson.id}/${step}`); + assert.equal(await page.locator('#lesson-panel').getAttribute('aria-labelledby'), `tab-${step}`); + if (step !== 'theory') assert.equal(await page.locator('.learning-guide').count(), 0); + } + } + }); + + await run('keyboard-disclosure-repetition-never-grants-achievement', async ({ page }) => { + for (const id of ['java-basics', 'jpa-state', 'jwt-validation']) { + await openLesson(page, id); + const before = await snapshot(page); + const summary = page.locator('.guide-prediction summary'); + await summary.focus(); + for (let repeat = 0; repeat < 3; repeat++) { + await page.keyboard.press('Enter'); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), ''); + assert.equal(await page.locator('.guide-answer-text').isVisible(), true); + await page.keyboard.press('Space'); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), null); + assert.equal(await summary.evaluate(node => node === document.activeElement), true); + } + await page.keyboard.press('Enter'); + assert.deepEqual(await snapshot(page), before, 'prediction has no persistence side effects'); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + assert.equal(await page.locator('#lesson-achievements [data-achievement="quiz"]').getAttribute('data-complete'), 'false'); + assert.equal(await page.locator('#lesson-achievements [data-achievement="lab"]').getAttribute('data-complete'), 'false'); + await capture(page, `${id}-keyboard-answer`, '.guide-prediction'); + } + }); + + await run('three-themes-copy-selection-and-keyboard-code-scroll', async ({ page }) => { + await openLesson(page, 'java-basics'); + const source = coreLessons.find(lesson => lesson.id === 'java-basics').learningGuide.code.source; + const palettes = []; + for (const theme of ['dark', 'light', 'contrast']) { + await page.locator('#theory-theme-select').selectOption(theme); + assert.equal(await page.locator('.guide-code').getAttribute('data-editor-theme'), theme); + const palette = await page.locator('#theory-code-panel').evaluate(node => ({ + background: getComputedStyle(node.closest('.code-shell')).backgroundColor, + foreground: getComputedStyle(node).color, + colors: [...new Set([...node.querySelectorAll('[class*="tok-"]')].map(token => getComputedStyle(token).color))], + })); + assert.ok(palette.colors.length >= 3, `${theme}: syntax colors are rendered`); + for (const color of [...palette.colors, palette.foreground]) assert.ok(contrastRatio(color, palette.background) >= 4.5, `${theme}: code color contrast ${color}/${palette.background}`); + palettes.push(palette); + await page.locator('#copy-theory-code').focus(); + await page.keyboard.press('Enter'); + assert.equal(await page.evaluate(() => window.qaClipboardText), source); + await page.evaluate(() => { window.qaClipboardDenied = true; }); + await page.locator('#copy-theory-code').click(); + assert.equal(await page.evaluate(() => getSelection().toString()), source, 'denied clipboard selects the complete source'); + await capture(page, `java-basics-${theme}-selected-code`, '#theory-code-panel'); + await page.evaluate(() => { window.qaClipboardDenied = false; getSelection().removeAllRanges(); }); + } + assert.notDeepEqual(palettes[0], palettes[1]); + assert.notDeepEqual(palettes[0], palettes[2]); + await page.setViewportSize({ width: 320, height: 720 }); + const pre = page.locator('#theory-code-panel'); + await pre.focus(); + const scrollable = await pre.evaluate(node => node.scrollWidth > node.clientWidth); + if (scrollable) { + await page.keyboard.press('ArrowRight'); + await eventually(() => pre.evaluate(node => node.scrollLeft), value => value > 0, 'keyboard can scroll long code without page overflow'); + } + await geometry(page, 'keyboard-code-scroll'); + await page.reload(); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#theory-theme-select').inputValue(), 'contrast'); + }); + + await run('guides-preserve-earned-progress-core-and-project-drafts-and-notes', async ({ page, requests }) => { + const first = coreLessons[0]; + await openLesson(page, first.id, 'quiz'); + for (const question of first.quiz) await page.locator(`#quiz-form [name="quiz-${question.id}"][value="${question.correctIndex}"]`).check(); + await page.locator('#quiz-submit').click(); + await openLesson(page, first.id, 'practice'); + const note = '코드를 따라 읽은 뒤에도 보존할 QA 설계 기록'; + await page.locator('#design-notes').fill(note); + await openLesson(page, first.id, 'lab'); + const draft = `${coreExercises.find(exercise => exercise.id === first.exerciseId).template}\n// independent readability QA draft\n`; + await page.locator('#code-editor [role="textbox"]').fill(draft); + await page.locator('#submit-code').click(); + await eventually(() => page.locator('#lesson-achievements [data-achievement="lab"]').getAttribute('data-complete'), 'true', 'mock submission establishes a prior earned snapshot'); + await openLesson(page, professionalLessons[0].id, 'lab'); + const file = professionalExercises[0].files.find(item => item.editable); + const projectDraft = `${file.source}\n// independent QA project draft\n`; + await page.locator('#code-editor [role="textbox"]').fill(projectDraft); + await openLesson(page, first.id); + const before = await snapshot(page); + assert.ok(before.quizPassed.includes(first.id)); + assert.ok(before.labPassed.some(item => item.source === draft)); + assert.ok(before.drafts.some(item => item.source === draft)); + assert.ok(before.fileDrafts.some(item => item.source === projectDraft)); + for (const id of ['java-basics', 'oop', 'jpa-state', 'security-csrf', 'jwt-validation', 'kafka-idempotency']) { + await openLesson(page, id); + for (const selector of ['#lesson-info', '#reference-column']) { + await keyboardDetails(page, selector, true); + await keyboardDetails(page, selector, false); + } + await page.locator('.guide-prediction summary').click(); + await page.locator('#theory-theme-select').selectOption('light'); + await page.locator('#copy-theory-code').click(); + } + await page.reload(); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + assert.deepEqual(await snapshot(page), before); + assert.equal(requests.filter(request => request.path === '/api/execute').length, 1); + await openLesson(page, first.id, 'practice'); + assert.equal(await page.locator('#design-notes').inputValue(), note); + await openLesson(page, first.id); + assert.equal(await page.locator('#overall-completed').textContent(), '1'); + assert.equal(await page.locator('#theory-theme-select').inputValue(), 'light'); + }, { mockSubmit: true }); + + const withoutGuides = values => values.map(({ learningGuide, ...lesson }) => lesson); + await run('all34-missing-guide-fallback-keeps-original-theory-and-five-stages', async ({ page }) => { + for (const lesson of lessons) { + await openLesson(page, lesson.id); + assert.equal(await page.locator('.learning-guide').count(), 0); + await originalTheory(page, lesson); + for (const step of ['example', 'quiz', 'practice', 'lab']) { + await openLesson(page, lesson.id, step); + assert.ok((await page.locator('#lesson-panel').innerText()).trim().length > 40); + } + } + }, { core: withoutGuides(coreLessons), professional: withoutGuides(professionalLessons) }); + + const malformed = structuredClone(coreLessons); + [null, [], 5, { terms: [null, {}, { term: '빈 정의' }], code: { source: 4 }, walkthrough: [null, 5] }].forEach((value, index) => { malformed[index].learningGuide = value; }); + await run('malformed-optional-guide-values-do-not-break-original-learning', async ({ page }) => { + for (const lesson of malformed.slice(0, 4)) { + await openLesson(page, lesson.id); + assert.equal(await page.locator('.learning-guide').count(), 0); + await originalTheory(page, lesson); + await page.locator('#lesson-panel .next-step-row button').click(); + assert.equal(await page.locator('[data-step="example"]').getAttribute('aria-selected'), 'true'); + } + }, { core: malformed }); + + const injected = structuredClone(coreLessons); + const payload = ''; + Object.assign(injected[0].learningGuide, { scenario: payload, question: payload, terms: [{ term: payload, meaning: payload }], code: { file: 'Main.java', source: payload }, walkthrough: [payload], prediction: { question: payload, answer: payload, explanation: payload }, takeaway: payload }); + await run('guide-content-is-rendered-as-text-not-executable-markup', async ({ page }) => { + await openLesson(page, injected[0].id); + await page.locator('.guide-prediction summary').click(); + for (const selector of ['.guide-scenario-text', '.guide-question', '#theory-code-panel code', '.guide-answer-text']) assert.equal(await page.locator(selector).textContent(), payload); + assert.equal(await page.locator('.learning-guide img,.learning-guide script').count(), 0); + assert.equal(await page.evaluate(() => window.qaGuideInjected), undefined); + }, { core: injected }); + + await run('syntax-module-failure-keeps-exact-guide-code-copy-and-prediction', async ({ page }) => { + for (const id of ['java-basics', 'jpa-state', 'jwt-validation']) { + await openLesson(page, id); + const lesson = lessons.find(item => item.id === id); + assert.equal(await page.locator('#theory-code-panel code').textContent(), lesson.learningGuide.code.source); + assert.equal(await page.locator('#theory-code-panel [class*="tok-"]').count(), 0); + assert.match(await page.locator('.guide-code-hint').textContent(), /원문/); + await page.locator('#copy-theory-code').click(); + assert.equal(await page.evaluate(() => window.qaClipboardText), lesson.learningGuide.code.source); + await page.locator('.guide-prediction summary').click(); + assert.equal(await page.locator('.guide-answer-text').isVisible(), true); + } + await capture(page, 'jwt-fallback-code', '#theory-code-panel'); + }, { noEditor: true }); + + for (const width of [320, 360, 390, 768, 1440]) for (const font of [100, 200]) { + await run(`all34-layout-${width}px-${font}percent-text`, async ({ page }) => { + for (const lesson of lessons) { + await openLesson(page, lesson.id); + await page.locator('.guide-prediction summary').click(); + await geometry(page, lesson.id); + // The prior overflow lived in the references column. A collapsed + // replacement must not merely conceal it, so inspect both states. + await page.locator('#lesson-info > summary').press('Enter'); + await page.locator('#reference-column > summary').press('Enter'); + if (await page.locator('.code-legend-disclosure').count()) await page.locator('.code-legend-disclosure > summary').press('Enter'); + await geometry(page, `${lesson.id}/expanded-support`); + } + for (const id of ['java-basics', 'jpa-state', 'jwt-validation']) { + await openLesson(page, id); + if (id === 'java-basics') { + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await page.screenshot({ path: path.join(artifacts, `reading-layout-${width}-${font}-top.png`), fullPage: false }); + } + await capture(page, `${id}-${width}-${font}-scenario`); + await capture(page, `${id}-${width}-${font}-code`, '#theory-code-panel'); + if ((width === 320 || width === 390) && font === 200) { + await page.locator('.guide-prediction summary').click(); + await capture(page, `${id}-${width}-${font}-answer`, '.guide-prediction-answer'); + await capture(page, `${id}-${width}-${font}-trace`, '.guide-walkthrough'); + await keyboardDetails(page, '#reference-column', true); + await capture(page, `${id}-${width}-${font}-expanded-references`, '.reference-content'); + } + } + }, { viewport: { width, height: width < 500 ? 844 : 1000 }, font }); + } + + for (const width of [704, 689]) { + await run(`desktop-minimum-web-viewport-${width}x521-at-100-and-200percent-text`, async ({ page }) => { + const checks = []; + for (const font of [100, 200]) { + await page.locator('html').evaluate((node, value) => { node.style.fontSize = `${value}%`; }, font); + await openLesson(page, 'exceptions'); + const before = await snapshot(page); + const menu = page.locator('#mobile-menu-toggle'); + await menu.focus(); + await page.keyboard.press('Enter'); + assert.equal(await menu.getAttribute('aria-expanded'), 'true'); + assert.equal(await page.locator('#curriculum-sidebar').evaluate(node => node.contains(document.activeElement)), true); + await page.keyboard.press('Escape'); + assert.equal(await menu.getAttribute('aria-expanded'), 'false'); + assert.equal(await menu.evaluate(node => node === document.activeElement), true, 'drawer restores opener focus'); + for (const selector of ['#lesson-info', '#reference-column']) await keyboardDetails(page, selector, true); + await geometry(page, `minimum-${width}-${font}/open-support`); + await capture(page, `minimum-${width}-${font}-expanded-references`, '.reference-content'); + await page.setViewportSize({ width: width + 80, height: 650 }); + await geometry(page, `minimum-${width}-${font}/resized`); + for (const selector of ['#lesson-info', '#reference-column']) assert.equal(await page.locator(selector).evaluate(node => node.open), true, 'resize preserves expanded information'); + await page.setViewportSize({ width, height: 521 }); + for (const selector of ['#reference-column', '#lesson-info']) await keyboardDetails(page, selector, false); + assert.deepEqual(await snapshot(page), before); + await openLesson(page, 'java-basics'); + const pre = page.locator('#theory-code-panel'); + await pre.focus(); + const preDimensions = await pre.evaluate(node => ({ width: node.clientWidth, scroll: node.scrollWidth })); + if (preDimensions.scroll > preDimensions.width) { + await page.keyboard.press('ArrowRight'); + await eventually(() => pre.evaluate(node => node.scrollLeft), value => value > 0, 'long guide code scrolls internally'); + } + await page.locator('#tab-theory').focus(); + for (const step of ['example', 'quiz', 'practice', 'lab']) { + await page.keyboard.press('ArrowRight'); + await eventually(() => page.locator(`#tab-${step}`).getAttribute('aria-selected'), 'true', `minimum window ${step} keyboard stage`); + assert.equal(await page.locator(`#tab-${step}`).evaluate(node => node === document.activeElement), true); + await activeTabGeometry(page, `${width}px/${font}%/${step}/keyboard`); + await geometry(page, `minimum-${width}-${font}/${step}`); + } + await openLesson(page, 'java-basics', 'theory'); + await openLesson(page, 'java-basics', 'lab'); + await activeTabGeometry(page, `${width}px/${font}%/direct-hash-lab`); + const draft = `${coreExercises.find(item => item.id === coreLessons[0].exerciseId).template}\n// ${width}px ${font}% ${'long_identifier_'.repeat(80)}\n`; + await page.locator('#code-editor [role="textbox"]').fill(draft); + const drafted = await eventually(() => snapshot(page), value => value.drafts.some(item => item.source === draft), 'the edited source is saved before testing persistence'); + const scroll = await page.locator('#code-editor .cm-scroller').evaluate(node => { + node.scrollLeft = 180; + return { width: node.clientWidth, scroll: node.scrollWidth, left: node.scrollLeft }; + }); + assert.ok(scroll.scroll > scroll.width && scroll.left > 0, 'long editor source stays inside its scroller'); + await keyboardDetails(page, '#lesson-info', true); + await keyboardDetails(page, '#reference-column', true); + await page.setViewportSize({ width: 844, height: 390 }); + await geometry(page, `minimum-${width}-${font}/landscape-editor`); + await activeTabGeometry(page, `${width}px/${font}%/landscape-editor`); + await page.setViewportSize({ width, height: 521 }); + await geometry(page, `minimum-${width}-${font}/restored-editor`); + await activeTabGeometry(page, `${width}px/${font}%/restored-editor`); + await page.locator('#copy-code').click(); + assert.equal(await page.evaluate(() => window.qaClipboardText), draft, 'draft remains exact after information toggles and resize'); + assert.deepEqual(await snapshot(page), drafted); + await capture(page, `minimum-${width}-${font}-editor`, '#run-code'); + await page.locator('#tab-lab').focus(); + await page.keyboard.press('Home'); + await eventually(() => page.locator('#tab-theory').getAttribute('aria-selected'), 'true', 'Home returns to theory'); + assert.equal(await page.locator('#tab-theory').evaluate(node => node === document.activeElement), true); + await keyboardDetails(page, '#reference-column', false); + await keyboardDetails(page, '#lesson-info', false); + assert.deepEqual(await snapshot(page), drafted); + await openLesson(page, 'java-basics', 'lab'); + await page.reload(); + await page.locator('#code-editor [role="textbox"]').waitFor({ state: 'visible' }); + await page.locator('html').evaluate((node, value) => { node.style.fontSize = `${value}%`; }, font); + await activeTabGeometry(page, `${width}px/${font}%/reload-lab`); + await page.locator('#copy-code').click(); + assert.equal(await page.evaluate(() => window.qaClipboardText), draft, 'complete editor source survives reload at the minimum viewport'); + assert.deepEqual(await snapshot(page), drafted); + checks.push({ font, guideCode: preDimensions, editorScroll: scroll, draftPreserved: true, drawerFocusRestored: true, allFiveTabsKeyboard: true, directHashAndResizeAndReloadTabVisible: true }); + } + return { environment: 'web viewport emulation, not an Electron app launch', viewport: { width, height: 521 }, checks }; + }, { viewport: { width, height: 521 } }); + } + + await run('touch-emulation-resize-and-short-height-keep-open-prediction', async ({ page }) => { + await openLesson(page, 'jpa-state'); + await page.locator('.guide-prediction summary').tap(); + for (const viewport of [{ width: 844, height: 390 }, { width: 1280, height: 600 }, { width: 390, height: 844 }, { width: 844, height: 390 }]) { + await page.setViewportSize(viewport); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), ''); + assert.equal(await page.locator('.guide-answer-text').isVisible(), true); + await geometry(page, 'jpa-state'); + await capture(page, `jpa-state-${viewport.width}x${viewport.height}-open-answer`, '.guide-prediction-answer'); + } + await page.locator('.guide-prediction summary').tap(); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), null); + }, { viewport: { width: 390, height: 844 }, touch: true }); + } finally { await browser.close(); } + const after = await hashes(); + const changed = filesToHash.filter(file => before[file] !== after[file]); + if (changed.length) results.push({ name: 'verified-source-did-not-change-during-run', status: 'BLOCKED', changed }); + const report = { recordedAt: new Date().toISOString(), browser: process.env.PLAYWRIGHT_CHANNEL || 'chromium', browserVersion, node: process.version, mode: 'Isolated browser and touch emulation; mocked API; 200% document text, not physical devices or OS scaling', before, after, results }; + await writeFile(path.join(artifacts, `results-${Date.now()}.json`), JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report, null, 2)); + if (!results.length || results.some(result => result.status !== 'PASS')) process.exitCode = 1; +} + +main().catch(error => { console.error(error.stack); process.exitCode = 1; }); diff --git a/tests/readability.test.mjs b/tests/readability.test.mjs new file mode 100644 index 0000000..4ff84f0 --- /dev/null +++ b/tests/readability.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { exercises as coreExercises } from '../dist/data/exercises.js'; +import { exerciseDefinitions } from '../server/exercises.mjs'; +import { professionalLessons } from '../dist/data/professional.js'; + +const course = new URL('../curriculum/professional/', import.meta.url); +const catalog = JSON.parse(await readFile(new URL('catalog.json', course), 'utf8')); +const authoredProfessional = await Promise.all(catalog.lessons.map(async entry => + JSON.parse(await readFile(new URL(entry.lessonPath, course), 'utf8')))); +const exampleManifest = JSON.parse(await readFile(new URL('../examples/expected-output.json', import.meta.url), 'utf8')); +const lessons = [...coreLessons, ...authoredProfessional]; +const nonempty = value => typeof value === 'string' && value.trim().length > 0; +const canonical = value => Array.isArray(value) ? value.map(canonical) + : value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) + : value; +const digest = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex'); +const contracts = values => values.map(({ id, minVersion, preview, exerciseId, example, quiz, professionalExercise, executionVerified }) => + ({ id, minVersion, preview, exerciseId, example: { code: example.code, output: example.output, files: example.files }, + quiz: quiz.map(({ explanation, ...question }) => question), professionalExercise, executionVerified })); + +// Captured independently before learningGuide was added, 2026-10-13, and +// checked against Git b2dda6d4f2225e5f2d9b31a5287539bd1b0685b6. +// This readability change must not revise examples, grading, target versions, +// starter files, or trusted checks under already-earned achievement IDs. +const originalContracts = { + core: 'fd73ccfc033780f4419e59f4c1dd84abc64a8d0c1d4c8235c4bd4b132e43a509', + professional: '0a9ab6fadae8aa061705168a8f898c49eb91b2a3e1cd9b97dfb7fbaf13e2dcfb', + exercises: '88d31bde3c83d8f82d5b95b6cbdf88426d8db637339daf104766483f55bdd5e6', + definitions: '2550a87d02ad27c2a274df56bc36fc5d132e4ee3420a2073ec29064cee460a0e', +}; + +test('all 34 lessons have a concrete situation, defined terms, code trace, prediction, and practical takeaway', () => { + assert.equal(coreLessons.length, 12); + assert.equal(authoredProfessional.length, 22); + assert.equal(new Set(lessons.map(lesson => lesson.id)).size, 34); + for (const lesson of lessons) { + const guide = lesson.learningGuide; + assert.ok(guide && !Array.isArray(guide), `${lesson.id}: learningGuide is required`); + for (const field of ['scenario', 'question', 'takeaway']) { + assert.ok(nonempty(guide[field]), `${lesson.id}: ${field}`); + assert.match(guide[field], /[가-힣]/u, `${lesson.id}: ${field} must explain the lesson in Korean`); + } + assert.ok(Array.isArray(guide.terms) && guide.terms.length >= 2, `${lesson.id}: terms are unpacked before use`); + assert.equal(new Set(guide.terms.map(item => item.term)).size, guide.terms.length, `${lesson.id}: unique term definitions`); + for (const item of guide.terms) { + assert.ok(nonempty(item.term) && nonempty(item.meaning), `${lesson.id}: complete term definition`); + assert.notEqual(item.term.trim(), item.meaning.trim(), `${lesson.id}: definition does not merely repeat the term`); + assert.match(item.meaning, /[가-힣]/u, `${lesson.id}/${item.term}: plain-language definition`); + } + assert.ok(guide.code && nonempty(guide.code.file) && nonempty(guide.code.source), `${lesson.id}: source and file name`); + assert.ok(Array.isArray(guide.walkthrough) && guide.walkthrough.length >= 2 && guide.walkthrough.every(nonempty), `${lesson.id}: an ordered explanation of the code`); + assert.ok(guide.prediction && ['question', 'answer', 'explanation'].every(field => nonempty(guide.prediction[field])), `${lesson.id}: predict, answer, and explain why`); + assert.notEqual(guide.prediction.answer.trim(), guide.prediction.explanation.trim(), `${lesson.id}: explanation adds a reason to the answer`); + assert.ok(lesson.theory.length >= 2 && lesson.quiz.length >= 2 && lesson.practice.tasks.length >= 2, `${lesson.id}: the original five-stage lesson remains present`); + } + for (const field of ['scenario', 'question']) { + assert.equal(new Set(lessons.map(lesson => lesson.learningGuide[field].trim())).size, 34, `${field} is specific to each lesson`); + } +}); + +test('every guide code block is one contiguous verbatim excerpt of its named existing example', async () => { + for (const lesson of lessons) { + const guide = lesson.learningGuide; + assert.ok(guide?.code, `${lesson.id}: guide code exists`); + let files = lesson.example.files; + if (!files?.length) { + const relative = guide.code.file.replace(/^examples\//, ''); + assert.ok(Object.hasOwn(exampleManifest, relative), `${lesson.id}: file exists in the original executable example manifest`); + const original = await readFile(new URL(`../examples/${relative}`, import.meta.url), 'utf8'); + assert.equal(original.trim(), lesson.example.code.trim(), `${lesson.id}: named source is this lesson's published example`); + files = [{ path: guide.code.file, code: lesson.example.code }]; + } + const file = files.find(item => item.path === guide.code.file); + assert.ok(file, `${lesson.id}: ${guide.code.file} is a published example file`); + assert.ok(guide.code.source.trim().length > 0, `${lesson.id}: excerpt is not empty`); + assert.ok(file.code.includes(guide.code.source), `${lesson.id}: excerpt is exact, consecutive original source; no omitted/reordered/rewritten lines`); + assert.ok(guide.code.source.split('\n').length >= 2, `${lesson.id}: excerpt contains a traceable code sequence`); + } +}); + +test('published professional guides exactly match all 22 authored lessons', () => { + assert.equal(professionalLessons.length, 22); + for (const lesson of authoredProfessional) { + assert.deepEqual(professionalLessons.find(item => item.id === lesson.id)?.learningGuide, lesson.learningGuide, `${lesson.id}: regenerate the professional distribution before verification`); + assert.ok(lesson.learningGuide, `${lesson.id}: authored guide is not missing`); + } +}); + +test('readability additions preserve the pre-change examples, quiz answers, execution targets, and exercise contracts', () => { + assert.equal(digest(contracts(coreLessons)), originalContracts.core, 'core examples, quiz, minimum versions, and exercise IDs'); + assert.equal(digest(contracts(authoredProfessional)), originalContracts.professional, 'professional examples, quiz, and local project execution contracts'); + assert.equal(digest(coreExercises), originalContracts.exercises, 'published core templates and criteria'); + assert.equal(digest(exerciseDefinitions), originalContracts.definitions, 'trusted runtime/grading checks'); +}); + +test('README puts installation directly after the introduction with explicit EXE and ZIP asset links', async () => { + const readme = await readFile(new URL('../README.md', import.meta.url), 'utf8'); + const sections = [...readme.matchAll(/^##\s+(.+)$/gm)].map(match => ({ title: match[1].trim(), index: match.index })); + const introduction = sections.findIndex(section => section.title === '소개'); + assert.ok(introduction >= 0, 'introduction is present'); + assert.match(sections[introduction + 1]?.title || '', /설치/, 'installation immediately follows the introduction'); + const installation = readme.slice(sections[introduction + 1].index, sections[introduction + 2]?.index); + const links = [...installation.matchAll(/\[[^\]]+\]\((https:\/\/[^\s)]+)\)/g)].map(match => new URL(match[1])); + for (const extension of ['exe', 'zip']) { + assert.ok(links.some(url => url.hostname === 'github.com' && new RegExp(`^/jbsim999/java-edu/releases/download/v[^/]+/[^/]+\\.${extension}$`, 'i').test(url.pathname)), `${extension}: direct GitHub release asset link`); + } + assert.match(installation, /Windows\s*x64/i); + assert.match(installation, /Java Edu\.exe/); + assert.match(installation, /압축.*해제/); + assert.match(installation, /환경 준비/); +}); From 0848b49b6eef100cd85048657dee020f25787aa1 Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:08:21 +0900 Subject: [PATCH 21/29] =?UTF-8?q?docs(web):=20=EA=B5=90=EC=9E=AC=20?= =?UTF-8?q?=EA=B0=80=EB=8F=85=EC=84=B1=EA=B3=BC=20=EC=83=88=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EA=B2=80=EC=A6=9D=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/readability-qa-report.md | 127 +++++++++++++++++--------- docs/readability-release-qa-report.md | 47 ++++++++++ 2 files changed, 129 insertions(+), 45 deletions(-) create mode 100644 docs/readability-release-qa-report.md diff --git a/docs/readability-qa-report.md b/docs/readability-qa-report.md index fecf003..73f34a0 100644 --- a/docs/readability-qa-report.md +++ b/docs/readability-qa-report.md @@ -1,71 +1,108 @@ # 교재 읽기 안내 독립 QA -2026-10-13. 현재 판정은 **FAIL — 재현 결함 수정·재검사 대기**다. 정적 검사는 5/5 PASS다. 반복 정보 정리·본문 폭 확대 뒤 검사에서 좁은 확대 화면의 학습 목표 잘림과 최소창의 선택 탭 잘림을 확인해 구현자에게 전달했다. 수정본의 최종 검사 전까지 이전 통과 결과를 최신 화면의 통과로 사용하지 않는다. +2026-10-13 KST. **웹 교재·레이아웃·기존 브라우저 기능 판정: PASS.** 최종 동결본에서 정적 5/5, 읽기 안내 24/24, 기존 기능 49/49, 기존 도식 19/19가 통과했다. 발견한 가로 잘림과 활성 탭 문제를 수정본에서 재검증했으며, 이 검사 범위에 남은 재현 결함은 없다. -## 범위와 소유권 +**새 설치 패키지 실행 판정: BLOCKED·미실행.** 총괄 preflight에서 기존 앱과 학습 데이터를 확인했다. 사용자가 기존 앱을 이용 중이라 학습 데이터 보존 조건을 충족할 수 없어 총괄이 이번 신규 설치·앱 실행 검사를 보류했다. 이는 기존 앱과 기록을 보존하기 위한 조치이며 사용자에게 새 승인을 요청하는 상태가 아니다. QA는 기존 앱·AppData를 실행·변경·이동·삭제하지 않았다. 아래 최소창 검사는 웹 브라우저의 viewport 에뮬레이션이다. -요구사항은 Java 경력자가 실제 상황과 기존 코드의 값을 따라가며 추상 개념을 이해하도록 34개 수업에 읽기 안내를 추가하고, README 소개 다음에 설치와 직접 다운로드를 배치하는 것이다. +## 수행 범위와 환경 -QA는 `tests/readability.test.mjs`, `tests/readability.mjs`, 이 보고서 및 `work/readability-qa/`만 작성한다. 교재·화면·README·실행기 구현을 수정하지 않는다. 프리뷰 서버는 총괄이 소유하고, QA는 별도의 임시 브라우저 컨텍스트를 사용한다. +목표는 Java 경력자가 34개 수업의 실제 상황·용어 정의·기존 코드·변수/값 추적·예측과 이유·실무 정리를 따라 읽도록 보완하고, 반복 정보를 정리해 본문 폭을 확보하는 것이다. README 소개 다음의 설치 절차와 직접 다운로드도 검증했다. -총괄이 추가로 승인한 `tests/bundled-desktop.mjs`에는 package.json 기준 버전 검사, 34개 실제 패키지 안내 렌더링과 정답 열람의 성취 불변 검사, 독립 산출물 경로 환경변수를 준비했다. 설치·패키지 실행은 별도 preflight와 실행 신호 전까지 수행하지 않는다. +실제 부모 소유 HTTP 프리뷰 `http://127.0.0.1:4173`에 접속했다. Windows의 Edge 155.0.4283.45, Node v22.17.0, Playwright 1.62.1을 사용했고 매 시나리오마다 별도 임시 브라우저 컨텍스트를 만들었다. 기본 샌드박스의 localhost/브라우저 접근 제한은 허용된 확장 권한으로 해결했다. -후속 총괄 preflight에서 기존 AppData가 발견되어 실제 패키지 실행은 현재 **BLOCKED·미실행**이다. 기존 absence 보호 검사를 유지하며 QA는 해당 데이터를 수정·이동·삭제하거나 앱을 실행하지 않았다. 소스·구문 검사나 ASAR 비교는 실제 설치 앱 실행을 대신하는 결과로 기록하지 않는다. +외부 요청과 실행 API는 가로챘다. 읽기 안내의 성취 보존 검사에서 제출 1건은 명시적인 모의 응답이다. 기존 기능 검사도 실행 API를 모의 처리한다. 공식 링크는 키보드로 새 창을 여는 동작·정확한 URL·opener 분리를 검사하고 응답은 로컬에서 대신 제공했다. 원격 문서나 코드 실행 제공자에 학습 코드를 보내지 않았다. -## 검증 매트릭스 +## 실제 검사와 결과 -| 요구사항·위험 | 검사 방법 | 초기 상태 | +| 명령 | 최종 결과 | 근거 | | --- | --- | --- | -| 34개 안내 누락·빈 설명 | 상황·질문·풀어쓴 용어 정의·코드·순서 설명·예측/답/이유·실무 정리의 필수 구조 | 실행 대기 | -| 코드와 설명의 어긋남 | 표시 파일명에 해당하는 기존 example의 연속 원문인지 검증; 핵심 수업의 값·흐름 수동 대조 | 실행 대기 | -| 기존 성취 계약의 변경 | 예제·퀴즈·과제 ID·대상 버전·실무 실행 계약·trusted checks의 변경 전 SHA-256 비교 | 기준 확보 | -| 생성 데이터 누락 | 22개 원본 JSON과 professional 배포 모듈의 안내를 정확히 비교 | 실행 대기 | -| 5단 수업 회귀 | 34개 이론·예제·문제·실무·코드 실습 렌더링 | 실행 대기 | -| 답 펼치기와 성취 혼동 | Enter/Space 반복 열기·닫기 뒤 quizPassed/labPassed·완료 수 불변 | 실행 대기 | -| 입력 손실 | 기존 퀴즈·모의 제출 성취·코어/프로젝트 초안·메모를 만든 뒤 안내 탐색·재로드 | 실행 대기 | -| 코드 표시·복사 접근성 | 다크/라이트/고대비, 실제 계산 색 대비, 키보드 초점·가로 스크롤, 복사 성공·거절 시 원문 선택 | 실행 대기 | -| 선택 자료 장애 | 안내 없음·잘못된 값·문법 모듈 실패의 원문과 다음 단계 유지, HTML처럼 생긴 텍스트 주입 | 실행 대기 | -| 반응형 | 34수업 × 320/360/390/768/1440 CSS px × 100/200% 문서 글자 크기, 실제 캡처 | 실행 대기 | -| 이동·짧은 높이 | 390×844 ↔ 844×390, 1280×600에서 펼친 답 유지·터치 에뮬레이션 | 실행 대기 | -| 관련 회귀 | 기존 tests/browser.mjs 실행; 정상·오류·취소·반복·저장 실패·키보드·편집기 | 실행 대기 | -| 설치 접근성 | README 소개→설치 순서와 EXE/ZIP 직접 자산 링크; 총괄의 GitHub 자산 메타데이터 근거와 대조 | 실행 대기 | -| 추가 요청: 본문 폭·중복 정리 | 1280/1440 실제 본문 폭, 반복 정보 제거·접기, 열기/닫기와 키보드 초점·초안 유지 | 새 명세 대기 | -| 추가 요청: 원문 접근성 | 접힌 원문과 공식 출처 접근, 기존 12개 도식 유지, guide→기존 목표와 이론→도식 경계의 실제 viewport | 새 명세 대기 | +| `node --test tests/readability.test.mjs` | 5/5 PASS | final-verified/static-readability.log | +| `node tests/readability.mjs` | 24/24 PASS | final-verified/results-1791860453905.json | +| `node work/readability-qa/run-baseline.mjs` | 49/49 PASS | final-verified/baseline/results.json | +| `node tests/theory-visuals.mjs` | 19/19 PASS | final-verified/theory/results-1791859939453.json | +| 담당 테스트 3개 `node --check` | PASS | readability.test.mjs, readability.mjs, bundled-desktop.mjs 구문 검사 | -## 새 레이아웃에서 확인한 재현 결함 +위 산출물 경로의 기준은 `work/readability-qa/`다. 브라우저 실행 환경변수는 `PLAYWRIGHT_CHANNEL=msedge`, `QA_BASE_URL=http://127.0.0.1:4173`이며, `QA_ARTIFACT_DIR`를 각 표의 디렉터리로 분리했다. 기존 기능 49개는 원본 tests/browser.mjs를 수정하지 않고 외부 요청 차단 wrapper로 실행했다. 기존 tests/theory-visuals.mjs도 수정 없이 실행했다. -- **P2: 펼친 학습 목표 잘림.** `build-maven`을 320×844·문서 글자 200%에서 열고 수업 정보를 펼치면 목표 LI가 left 25/right 379.46875로 카드 밖에 배치된다. `lesson-card`의 clip 때문에 문서 scrollWidth는 320이어도 오른쪽 글자가 잘린다. 360/390px·200%에서도 검사에 걸렸다. `work/readability-qa/final/build-maven-320-200-visibility-probe.json`과 `build-maven-320-200-info-open-probe.png`로 열린 상태를 독립 재현했다. -- **P2: 최소창의 선택된 마지막 탭 잘림.** 704×521·200%에서 이론 탭에 초점을 놓고 ArrowRight 4회로 코드 실습을 선택하면 tab right 764.14가 strip right 688.92를 넘는다. 각 입력 뒤 600ms 기다려도 scrollLeft=0이었다. 689px에서도 재현했다. `work/readability-qa/minimum-probe/704-200-keyboard-tabs.json`과 `704-200-keyboard-tabs-viewport.png`가 증거다. 실제 Electron 실행이 아닌 웹 viewport 에뮬레이션이다. +`final-verified/final-summary.json`은 결과 개수, 실행 환경, 소스 해시, 실제 본문 폭 및 설치형 미실행 상태를 모은 파일이다. 최종 24개 실행 전후의 모든 기록 대상 해시가 같고, 종료 후 현재 파일과도 다시 일치했다. -최신 완료 실행 `work/readability-qa/final/results-1791858691475.json`은 24개 중 19 PASS·5 FAIL이며 소스 전후 해시는 같았다. 3개 실패는 같은 목표 잘림이고, 최소창 2개는 처음에는 450ms 저장 지연 전에 읽은 QA 조건 때문에 중단됐다. 저장 완료를 기다리도록 보정한 별도 최소창 검사에서 위 탭 결함을 발견했다. 닫힌 native details가 잔존 layout rect를 반환하는 점도 `checkVisibility`로 보정했다. 펼친 내용의 경계 검사와 원문·성취 검증은 그대로 유지한다. +| 요구사항·위험 | 실제 검증 | 판정 | +| --- | --- | --- | +| 34개 교재 안내 | 상황·질문·용어/정의·기존 코드·순서 추적·예측/답/이유·실무 정리 필수 구조, 34개 실제 DOM과 원본 데이터의 일치 | PASS | +| 기존 예제와의 연결 | 파일명이 가리키는 기존 example 원문의 연속 발췌인지 검사; 22개 작성 JSON과 배포 모듈의 안내 일치 | PASS | +| 성취·실행 계약 | 이전 예제 코드/출력, 퀴즈 ID/질문/선지/정답, 과제 ID/대상 버전·실행 필드·trusted checks 해시 보존 | PASS | +| 단계와 fallback | 34개 수업의 5단계, 안내가 없는 34개 수업, 잘못된 안내 값, 문법 모듈 장애, HTML처럼 생긴 텍스트 | PASS | +| 읽기 정보의 중복 | 학습 목표는 정보 영역에 1회만 존재; 수업 정보·출처·색상 안내는 기본 닫힘; 상세 이론과 12개 도식은 기본 노출·기존 순서 유지 | PASS | +| 접기·성취·입력 보존 | Enter/Space 반복, 초점 표시/복귀, 예상 답 열람의 성취 불변, 기존 퀴즈·모의 제출 성취·코어/파일별 초안·설계 메모·재로드 | PASS | +| 코드 표시와 조작 | 3개 테마의 실제 계산 색 대비 4.5:1 이상, 구문 강조, 키보드 가로 스크롤, 모의 복사와 권한 거절 시 전체 원문 선택 | PASS | +| 좁은 화면·확대 | 34수업 × 320/360/390/768/1440 CSS px × 100/200% 문서 글자. 안내·예상 답과 학습 정보/출처/있는 경우 색상 안내를 모두 펼쳐 경계 검사 | PASS | +| 도식 회귀 | 12도식 × 같은 5개 폭·2개 글자 크기, 원문/접근성 연결/공식 출처/afterSection 위치/오류 fallback | PASS | +| 최소창 | 704×521·689×521 각각 100/200%에서 목차 열기·Escape·초점 복귀, 5탭 방향키, 직접 hash, 정보 접기, 긴 코드 내부 스크롤, resize·재로드 후 선택 탭과 초안 유지 | PASS | +| 이동·작은 높이 | 390×844↔844×390, 1280×600, 펼친 답 유지·터치 에뮬레이션·실제 viewport 캡처 | PASS | +| 관련 기능 | 정상/오류/취소/반복/늦은 응답·저장 장애·초안/되돌리기·버전/검색/히스토리·편집기 회귀 | PASS | +| README 설치 접근 | 소개 바로 다음 설치, EXE/ZIP 직접 URL, 전체 압축 해제와 환경 준비 안내, 게시 메타데이터 대조 | PASS | + +34개 안내를 직접 읽어 입력값·분기·변경 후 상태·반환값과 예측을 대조했다. Redis/Kafka·Docker·OIDC의 코드 읽기와 실제 서비스 검증 범위를 구분했다. 이 검토는 실제 학습자의 이해도 개선을 측정한 연구가 아니다. + +이전 계약은 Git 기준 `b2dda6d4f2225e5f2d9b31a5287539bd1b0685b6`의 원문과 대조했다. 테스트에는 정규화한 계약 해시만 고정해 CI의 얕은 checkout에서도 실행할 수 있다. example/quiz의 설명 문구는 보존 대상에서 제외했다. + +## 본문 폭과 실제 합성 화면 + +| viewport | 코드·도식 실제 폭 | 상주 목차 폭 | +| --- | ---: | ---: | +| 1280px | 938.8px | 220px | +| 1440px | 1092.4px | 220px | +| 1920px | 1554px | 228px | -`final/interrupted-before-last-css.log`는 마지막 320px 제목 규칙 제거 전 중단한 검사다. 그 실행의 `failure-wide-reading-layout-*`는 실제 한 줄 문장 높이 28.5px에 40px을 요구한 QA 조건 오류였다. 조건을 전체 문장 또는 한 줄 노출로 보정한 후 넓은 본문·12개 도식 검사는 통과했다. 이 파일을 제품 결함으로 집계하지 않는다. +1280→1250→1280 전환에서 코드 폭은 938.8→910→938.8px로 복원됐다. 1280×600 첫 진입에 실제 상황 문장이 노출됐다. 일반 문장과 코드·도식의 폭을 따로 확인했다. -## 최초 동결본의 실제 결과 +QA가 직접 본 최종 viewport에는 데스크톱 첫 화면, 320px·200% 상단, 390px·200% 예상 답/이유, guide→상세 이론→도식 경계, 펼친 목차, 라이트 테마와 선택, 강조 모듈 실패의 원문 코드, 최소창 실행 버튼이 포함된다. 200%에서는 세로 스크롤로 긴 내용을 읽으며 코드의 내부 가로 스크롤을 유지한다. -`node --test tests/readability.test.mjs`: **5/5 PASS**. 원문 발췌·34개 구조·배포 생성물 동기화·기존 실행 계약·README 순서를 검증했다. 실행 계약의 해시는 설명 문구를 제외하고 코드·출력·퀴즈 ID/질문/선지/정답과 실행 필드를 비교한다. 테스트 런타임에 과거 Git 이력이나 work baseline 파일을 요구하지 않는다. +대표 산출물: + +- `final-verified/reading-layout-1280-top.png`, `reading-layout-1440-top.png`, `reading-layout-1920-top.png` +- `final-verified/reading-layout-320-200-top.png` +- `final-verified/java-basics-1440-guide-to-theory-boundary.png`, `java-basics-1440-theory-to-visual-boundary.png` +- `final-verified/jpa-state-390-guide-to-theory-boundary.png`, `jpa-state-390-theory-to-visual-boundary.png` +- `final-verified/jpa-state-390-open-curriculum-drawer.png` +- `final-verified/java-basics-1440-expanded-lesson-info.png`, `java-basics-1440-expanded-references.png`, `java-basics-1440-expanded-syntax-legend.png` +- `final-verified/theory/*-desktop-figure.png`: 원래 도식 12종의 실제 요소 캡처 +- `final-verified/*-desktop-guide.png`: 안내 34개 전체 요소 캡처 + +별도 visual_director에게 위 캡처와 12개 도식을 전달했다. 전체 시각적 조화의 최종 승인은 그 독립 검수 결과를 따른다. 요소 전체 캡처와 실제 viewport 캡처를 구분하며, 순간적으로 나타나는 복사 토스트는 원래 UI 상태 그대로 기록했다. + +## 발견·수정·재검증 + +| 결함 | 재현 근거 | 최종 상태 | +| --- | --- | --- | +| P2: 기존 공고 카드 가로 넘침 | 최초 320px·200% exceptions에서 문서 337px/viewport 320px. 최초 results-1791856409669.json, overflow-exceptions-320-200.json | 34수업의 닫힘/펼침 행렬에서 재검증 PASS | +| P2: 새 정보 영역의 목표 잘림 | build-maven, 320/360/390px·200%. LI right 379.47px, 카드 clip으로 오른쪽 글자 손실. final/build-maven-320-200-visibility-probe.json, info-open-probe.png | 펼친 실제 요소·텍스트 경계를 포함해 재검증 PASS | +| P2: 최소창의 선택 탭 잘림 | 704/689×521·200%, ArrowRight로 lab 선택. 600ms 뒤도 tab right 764.14 > strip right 688.92, scrollLeft 0. minimum-probe/704-200-keyboard-tabs.json와 viewport.png | 키보드·직접 hash·resize·재로드에서 재검증 PASS | -`PLAYWRIGHT_CHANNEL=msedge node tests/readability.mjs`: **19 PASS, 1 FAIL**, 종료 코드 1. `work/readability-qa/results-1791856409669.json`과 `browser-readability.log`에 실제 결과와 검사 전후 동일한 소스 해시를 남겼다. +수정 확인 실행 `fix-verification/results-1791859832771.json`의 3/3 PASS 뒤 전체 최종 검사를 실행했다. 이전 실패 산출물은 삭제하지 않고 유지하며 최신 PASS 근거는 `final-verified/`로 분리했다. -재현 결함 **P2 — 320px·200% 글자 확대에서 공고 카드 가로 넘침**: +QA 조건도 근거에 맞춰 보정했다. 첫 문장의 높이가 28.5px인 경우 전체가 보여도 40px을 요구하던 조건을 수정했고, 닫힌 native details가 남기는 레이아웃 rect는 실제 가시성으로 제외했다. 초안은 기존 450ms 저장 지연이 끝나 저장값이 나타난 뒤 보존을 검사한다. 원문·성취·펼친 내용의 경계 조건은 유지했다. -1. Edge에서 실제 로컬 프리뷰의 `#lesson=exceptions&step=theory&version=21`을 연다. -2. viewport를 320×844 CSS px로 설정하고 문서 기본 글자를 200%로 확대한다. -3. 예상 답을 펼친 상태에서 전체 페이지 너비를 읽는다. +## README와 변경 파일 -기대는 코드 영역 내부의 가로 스크롤만 허용하는 것이다. 실제 `document.clientWidth=320`, `scrollWidth=337`이었다. `#reference-column`은 left 24/right 296인데 `.reference-card.evidence-card`는 right 337.015625로 커졌다. 구현자에게 수치와 재현 단계를 전달했다. 독립 재검증 전이므로 구현자의 자체 검사 통과를 QA 완료로 기록하지 않는다. +검사 시점의 README 직접 링크는 v0.3.0 EXE 362,799,820바이트와 ZIP 362,369,704바이트였다. 총괄이 확보한 `work/bundled-desktop-qa/release-published.json`의 릴리스 ID 410409363, uploaded 상태, URL·크기·SHA-256과 일치했다. 대용량 파일은 재다운로드하지 않았다. 대조 기록은 `work/readability-qa/readme-assets-audit.json`이다. 후속 v0.3.1 공개/다운로드 검증은 총괄의 새 릴리스 기록으로 관리한다. -증거: `failure-all34-layout-320px-200percent-text.png`, `overflow-exceptions-320-200.json`. 다른 9개 너비·글자 크기 조합은 각각 34개 수업에서 통과했다. 정답 반복 열람·3개 테마의 구문 색 대비·복사/거절 선택·성취/초안/메모 보존·34수업 5단계와 안내 없는 fallback·잘못된 자료·문법 모듈 실패 검사는 통과했다. +QA 변경 파일은 `tests/readability.test.mjs`, `tests/readability.mjs`, `tests/bundled-desktop.mjs`, 이 보고서와 `work/readability-qa/` 산출물이다. 교재·UI·README·실행기·Git·배포는 QA가 수정하지 않았다. -34개 안내를 직접 읽어 입력값·실행 분기·변경 후 상태·반환값과 예측을 대조했다. Redis/Kafka·Docker·OIDC는 코드 읽기와 실환경 검증 범위를 구분한다. 이 검토를 실제 학습자 이해도 향상 측정으로 표현하지 않는다. +추가 승인된 bundled-desktop 테스트에는 package.json의 버전 비교, 독립 산출물 디렉터리 환경변수, 실제 패키지의 34개 안내와 예상 답 열람 시 성취 불변 검사를 준비했다. 기존 사용자 데이터 부재를 요구하는 preflight를 보존했으며 실제 패키지 테스트는 실행하지 않았다. -## 현재 확보한 근거 +## 검증한 소스와 미확인 범위 -- `node --check tests/readability.mjs`, `node --check tests/readability.test.mjs`: 실제 실행 PASS. -- Git 기준 `b2dda6d4f2225e5f2d9b31a5287539bd1b0685b6` 원문과 이번 독립 수집의 기존 교재·실행 계약 해시가 일치했다. guide가 추가되기 전 수집했으며 중간 변경이 기준에 섞이지 않았음을 확인했다. -- 기본 샌드박스에서 Edge 실행이 프로필 접근 오류로 실패했다. 허용된 `require_escalated` 실행으로 부모 소유 `http://127.0.0.1:4173`에 접속해 HTTP 200, 학습 workspace 표시, Edge `155.0.4283.45`를 확인했다. 이 접속 확인을 전체 QA 통과로 확대하지 않는다. -- 브라우저 검사에서 외부 요청과 실행 API를 모두 가로챈다. 진도 보존 검사의 명시적인 제출 1건은 모의 응답이며 실제 Java 실행이 아니다. +| 파일 | SHA-256 | +| --- | --- | +| dist/index.html | 4300648bde32c51c134d4d0d9d12659b5149630995041c7b588cd121a4105269 | +| dist/app.js | 1f0d9746ee94a8d0a4e51f7c2b798e797026303bad179119680ea7d129258a0b | +| dist/styles.css | 8baf9bf900bdbcb2f8a7ab82fb0a17b1fada55dc7e94532c144c2ee3e911816d | +| dist/data/curriculum.js | e61c9547892d3df1b9709a3191699026f608b806e57be04d1b1cc88057fa1e20 | +| dist/data/professional.js | fdd4c80e335c941ea936afbc14f75be18ddcc15d3b72088cad9c35c5fa582ce0 | +| dist/data/lesson-visuals.js | 6a1a2085a55b9e41ca33f972052953adf4445fd7c49dd738f6b6ed4cc67cc442 | +| tests/readability.mjs | 06190fa7911ad97929c8cc1ab94a69fa79776bcb1b13c10a7eaa20c96e8d3b12 | -## 미확인 범위 +물리 휴대전화, 실제 Windows 한글 IME, OS 클립보드, 화면 읽기 프로그램의 음성 출력, OS 배율·실제 브라우저 페이지 줌, 학습자의 이해도 개선은 미검증이다. 200%는 문서 기본 글자 확대이고 터치와 최소창은 브라우저 에뮬레이션이다. 기존 기능 검사의 한글 composition 이벤트 모의 검증은 실제 IME 검증과 구분한다. -물리 휴대전화, 실제 한글 IME, NVDA/JAWS/VoiceOver 음성 출력, OS 배율과 실제 브라우저 페이지 줌, 학습자의 이해도 개선은 검사하지 않는다. 200%는 문서 기본 글자 확대이며, 터치는 브라우저 에뮬레이션이다. 이번 UI 검사는 설치 패키지 실행, JVM 실행, Redis/Kafka 연동, 원격 배포 검증을 대신하지 않는다. 시각적 조화는 별도 visual_director의 실제 합성 화면 검수를 따른다. +이번 웹 QA는 JVM 실행, Redis/Kafka 연동, 설치형 실행, 배포/다운로드 후 설치 검증을 대신하지 않는다. 새 설치 패키지는 기존 사용자 데이터 보호 때문에 **BLOCKED·미실행** 상태로 남긴다. 웹·정적 검사 범위의 최종 판정은 **PASS**다. diff --git a/docs/readability-release-qa-report.md b/docs/readability-release-qa-report.md new file mode 100644 index 0000000..4329d39 --- /dev/null +++ b/docs/readability-release-qa-report.md @@ -0,0 +1,47 @@ +# v0.3.1 교재·수업 화면과 패키지 검증 + +2026-10-13. 검증한 앱 소스는 `6ce0428a802be7a8a757a27a40853996a5b7fa04`다. 후속 QA·설치 안내·출시 기록 문서 커밋과 구분한다. 출시와 웹 배포 결과는 [배포 기록](deployment.md)에 기록한다. + +## 내용과 화면 + +34개 수업에 상황·질문·풀어쓴 용어·기존 원문 발췌·값과 상태의 변화·결과 예측·실무 판단을 추가했다. 공식 규칙을 과도하게 압축했던 설명을 보완했으며 명세의 보장과 예제가 선택한 정책을 구분했다. 예제·정답·버전·과제·실행 계약은 보존했다. + +반복된 큰 소개·통계·워크스페이스 안내를 정리하고 목차와 본문 2열로 바꿨다. 수업 정보·목표·공식 자료·색상 안내는 펼쳐 확인하며 기존 상세 이론과 12개 도식은 기본 표시한다. 실제 코드·도식 폭은 1280px에서938.8px, 1440px에서1092.4px, 1920px에서1554px다. + +## 실제 검사 + +| 검사 | 실제 결과·근거 | +| --- | --- | +| 내용·구문·계약 | Node40 PASS, 원격 제공자 실제 호출1 SKIP; `work/readability-final-check.log` | +| 독립 읽기 안내 QA | 정적5/5, 신규 브라우저24/24, 기존 기능49/49, 도식19/19 PASS; `work/readability-qa/final-verified/final-summary.json` | +| 독립 내용·코드 검토 | 34개 안내와 원문·보호 필드·화면 코드·마지막 수정의 해시 대조 PASS | +| 독립 시각 검수 | 실제 34가이드·12도식·대표 반응형·200% 글자·3테마·README 소개→설치 PASS | +| Windows 실행 정책 | 로컬23/23 PASS; `work/readability-local.log` | +| Worker | 최종 빌드와 인증·CSP·정적 경로·비공개 파일 제외 검사 PASS | +| 원격 CI | [38338837092](https://github.com/jbsim999/java-edu/actions/runs/38338837092): 검증 소스6ce0428의12작업 모두 성공 | +| 새 Windows 파일 | EXE·ZIP 빌드 완료, 실제 설치·앱 기동은 미실행 | + +초기 QA에서 320px·200%의 자료 카드 넘침, 이동한 학습 목표의 긴 식별자 잘림, 확대 시 상단 공백, 최소 창의 활성 탭 잘림을 발견해 수정했다. 탭은 목차·딥링크·새로고침·리사이즈·글자 확대에서도 확인했다. 최초 FAIL 기록을 최종 PASS로 덮어 해석하지 않는다. [독립 QA](readability-qa-report.md)에 재현·수정 후 검사·미확인 범위를 기록했다. + +## 새 패키지의 정적 대조 + +빌드 ASAR, ZIP 추출본, NSIS 내부 payload의 **197개 소스+24개 생성물**을 검증 소스와 비교했다. 포함 도구604개의 manifest·파일 목록·크기·해시를 검사했으며 bundle ID는 `548c060050b821dbcb99fd90e3f9e992e47e6ab3899d12551c9d7fb965581760`다. ASAR SHA256은 `b78441c83a849aa728145cecb8d993b8e2cf8afbfdc7de303ad6de205720b00f`다. + +ZIP은 전체678파일, NSIS payload는 전체679파일을 빌드본과 대조했다. 초기 집합 비교에서는 ZIP의 `resources/elevate.exe` 부재 때문에 실패했다. electron-builder26.15.3의 NSIS 단계가 해당 vendor helper를 나중에 복사하는 타깃 차이를 원본 코드와 실제 파일로 확인했다. ZIP에서 이 한 경로만 명시적으로 제외하고, 빌드본·NSIS payload·공식 NSIS3.0.4.1 원본의 helper 해시 `9b1fbf0c11c520ae714af8aa9af12cfd48503eedecd7398d8992ee94d1b4dc37`가 일치함을 확인했다. 다른 누락·추가 파일은 허용하지 않았다. 포함 도구와 제품 패키징 설정은 변경하지 않았다. + +추출 입력 EXE·ZIP의 해시를 추출 전후와 결과 대조에 연결했다. 7-Zip의 NSIS EXE 내부 ZIP 읽기는 후미 데이터 경고와 exit0을 반환하며, 이는 설치 실행 성공을 뜻하지 않는다. 증거는 `work/readable-desktop-qa/extract-*.json`, `package-*.json`, 추출 로그다. + +| 파일 | 바이트 | SHA256 | +| --- | ---: | --- | +| Java-Edu-Setup-0.3.1-x64.exe | 362890668 | `85b709c8c4efeeaed697bd5921b9f0135068e0f25be4de12f3efc983172ca1ad` | +| Java-Edu-0.3.1-x64.zip | 362460548 | `ed8ea284972cca006370ac379603400c8d623af9ebdc998220755d4627e5ddd3` | + +JDK·제3자 원본과 고지는 v0.3.0과 동일하다. 대응 소스 파일명과 기존의 유효한 대응 소스 URL을 보존한다. 실제 EXE 서명 검사 결과는 `NotSigned`다. + +## 검증의 경계 + +사용자가 Downloads의 v0.3.0 앱을 사용 중이고 AppData 학습 기록이 존재해 absence preflight를 통과하지 않았다. 이 앱을 종료하거나 데이터를 옮기거나 삭제하지 않았다. **새 v0.3.1의 실제 설치·앱 실행 검사는 BLOCKED·미실행**이며 정적 payload 비교와 브라우저 검증으로 구분한다. v0.3.0의 실제 설치/제거·앱10/10 결과를 신버전의 실제 실행 결과로 사용하지 않는다. + +브라우저 API는 모의 응답으로 격리했으며 실제 Java 제출 성공이 아니다. 200%는 문서 글자 확대, 모바일·터치는 브라우저 에뮬레이션이다. 실기기·스크린리더·OS IME/클립보드·실제 학습 효과·선택 Redis/Kafka 연동은 미검증이다. 넓어진 CSRF/JWT 도식의 마지막 행 빈 공간은 비차단 시각 개선 사항으로 남겼다. + +웹 배포 체크아웃에서는 소스 동기화 완료 전의 선행 빌드가 v0.3.0/35 PASS 로그를 반환하는 작업 순서 오류를 확인했다. 중간 결과를 폐기하고 전체274파일의 Git blob 일치를 확인한 뒤 같은 v0.3.1 소스로 다시 빌드·Worker 검사·Node40 PASS/1 SKIP를 확인했다. 배포에는 이 최종 소스와 산출물만 사용한다. From 236ce0d3e461fdfea11929669981999110e1b0cf Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:16:59 +0900 Subject: [PATCH 22/29] =?UTF-8?q?docs(web):=20v0.3.1=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EC=99=80=20=EA=B2=80=EC=A6=9D=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +++++++------- docs/deployment.md | 4 ++++ docs/development-handoff.md | 8 ++++++-- docs/installation.md | 8 +++++--- docs/milestones.md | 4 ++++ docs/verification.md | 4 ++++ 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 24bc036..2b179e6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 다른 언어 경험자의 Java 전환 학습부터 현직 개발자의 OOP·백엔드 심화까지. -[![Java](https://img.shields.io/badge/Java-8%20%7C%2011%20%7C%2017%20%7C%2021-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/java-version-matrix.md) [![Windows](https://img.shields.io/badge/Desktop-Windows%20x64-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) [![Lessons](https://img.shields.io/badge/Lessons-34-806223?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) [![Status](https://img.shields.io/badge/Status-v0.3.0%20preview-806223?style=flat-square)](https://github.com/jbsim999/java-edu/issues) [![CI](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml/badge.svg?branch=feat%2Fjava-learning-mvp)](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml?query=branch%3Afeat%2Fjava-learning-mvp) +[![Java](https://img.shields.io/badge/Java-8%20%7C%2011%20%7C%2017%20%7C%2021-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/java-version-matrix.md) [![Windows](https://img.shields.io/badge/Desktop-Windows%20x64-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) [![Lessons](https://img.shields.io/badge/Lessons-34-806223?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) [![Status](https://img.shields.io/badge/Status-v0.3.1%20preview-806223?style=flat-square)](https://github.com/jbsim999/java-edu/issues) [![CI](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml/badge.svg?branch=feat%2Fjava-learning-mvp)](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml?query=branch%3Afeat%2Fjava-learning-mvp) [교육 로드맵](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) · [개발 소스](https://github.com/jbsim999/java-edu/tree/feat/java-learning-mvp) · [기여 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/CONTRIBUTING.md) · [버그·제안](https://github.com/jbsim999/java-edu/issues) @@ -20,13 +20,13 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교 수업은 **이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩** 순서로 진행합니다. Java 언어·표준 API는 Oracle/OpenJDK 공식 문서를, 프레임워크·라이브러리는 각 프로젝트의 공식 문서를 기준으로 합니다. 실무 주제에는 출처를 기록한 채용 공고의 기술 요건도 연결합니다. -> **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. [Windows v0.3.0 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)를 설치해 학습할 수 있습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 34수업·이론 도식 12개·편집기 테마가 배포돼 있습니다. +> **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. [Windows v0.3.1 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1)를 설치해 학습할 수 있습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 34수업·이론 도식 12개·편집기 테마가 배포돼 있습니다. ## 설치하고 시작하기 **Windows x64용입니다. 아래에서 하나만 다운로드하세요.** -**[설치 EXE 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.0/Java-Edu-Setup-0.3.0-x64.exe)** · **[설치 없이 사용하는 ZIP 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.0/Java-Edu-0.3.0-x64.zip)** +**[설치 EXE 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.1/Java-Edu-Setup-0.3.1-x64.exe)** · **[설치 없이 사용하는 ZIP 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.1/Java-Edu-0.3.1-x64.zip)** 1. **EXE:** 다운로드한 파일을 실행해 설치한 뒤 **Java Edu**를 엽니다. **ZIP:** 폴더 전체를 압축 해제한 뒤 `Java Edu.exe`를 엽니다. 2. 앱에서 수업을 선택하고 **이론 → 예시 → 문제 → 실무 → 코드 실습** 순서로 진행합니다. @@ -34,7 +34,7 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교 Java·Maven·Node.js가 포함돼 있어 따로 설치하거나 폴더를 등록할 필요가 없습니다. Spring 실습의 첫 환경 준비에는 인터넷이 필요합니다. ZIP 안의 EXE만 따로 옮기지 마세요. -[자세한 설치 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) · [릴리스와 체크섬](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0) +[자세한 설치 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) · [릴리스와 체크섬](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) ## 학습실 미리보기 @@ -108,7 +108,7 @@ npm run check:bundle npm run build:desktop ``` -공식 원본 ZIP의 체크섬을 대조하고 전체 배포 폴더를 준비한 뒤 빌드합니다. `desktop-release/`에 Windows x64 NSIS 설치 EXE와 전체 ZIP을 생성합니다. 직접 재빌드한 파일은 배포 파일과 별도로 검증해야 합니다. [번들·출처·대응 소스 계약](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-runtime.md) · [배포 파일과 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0) +공식 원본 ZIP의 체크섬을 대조하고 전체 배포 폴더를 준비한 뒤 빌드합니다. `desktop-release/`에 Windows x64 NSIS 설치 EXE와 전체 ZIP을 생성합니다. 직접 재빌드한 파일은 배포 파일과 별도로 검증해야 합니다. [번들·출처·대응 소스 계약](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-runtime.md) · [배포 파일과 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) @@ -137,7 +137,7 @@ npm run build:desktop 실무 검사는 `JAVA_HOME`·`MAVEN_HOME` 설정이 필요합니다. 기본 검사는 Redis/Kafka를 제외합니다. 브라우저 검사는 실행 제공자의 실제 동작 검증과 구분합니다. -2026-10-12 기준, 통합 패키지 소스 `0e1ff5e`의 [CI 12작업](https://github.com/jbsim999/java-edu/actions/runs/38223955175)이 모두 성공했습니다. 최종 실제 ZIP **10/10**, NSIS 설치 앱 **10/10**, 원본 도구 604파일 대조·설치/제거·데이터 보존 및 독립 시각 검수 PASS. 기본 실무 20프로젝트·54개 JUnit, 4실제 JDK 검사는 CI에서 별도로 통과했습니다. CI 배지는 개발 브랜치의 현재 결과를 표시하며 검증 범위와 미확인은 기록을 따릅니다. [통합 패키지 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-desktop-qa-report.md) · [검증 기록](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/verification.md) +2026-10-13 기준, v0.3.1 앱 소스 `6ce0428`의 [CI 12작업](https://github.com/jbsim999/java-edu/actions/runs/38338837092)이 모두 성공했습니다. 독립 검수에서 읽기 안내 **24/24**, 기존 기능 **49/49**, 도식 **19/19**, 정적 검사 **5/5**가 통과했고 새 EXE·ZIP의 전체 파일과 포함 도구 604개를 대조했습니다. 사용 중인 앱과 학습 기록을 보존하기 위해 **신버전의 실제 설치·기동 검사는 미실행**입니다. 이전 v0.3.0 설치·제거 검증과 구분합니다. [이번 검증 범위](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/readability-release-qa-report.md) · [검증 기록](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/verification.md) ## 문서와 로드맵 @@ -160,7 +160,7 @@ npm run build:desktop - [x] Windows CI·설치/제거 검증·최종 시각 검수 - [x] 34수업·이론 도식 웹 교재 배포 - [x] Java·Maven 포함 Windows EXE와 전체 ZIP 패키징 -- [x] [Windows v0.3.0 통합 프리뷰 릴리스](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0) +- [x] [Windows v0.3.1 통합 프리뷰 릴리스](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) - [ ] 선택 과정의 Redis/Kafka 실제 연동 검증 ## 기여와 문의 diff --git a/docs/deployment.md b/docs/deployment.md index 6d8f37b..e7de194 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -70,3 +70,7 @@ M4.1 당시에는 Windows v0.2.0 설치 파일의 로컬 빌드만 완료됐고 웹 통합 설치 안내는 기존 소유자 전용 URL에 2026-10-12 08:25:24 UTC succeeded로 배포했다. Sites source `199443b5e185acdcd83bb3d4ca0a4ca43eefc3d8`, 버전 `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_60be210ba2a48191b036dc7530cad71f`, 배포 `appgdep_6acc996c9de48191a896c90d291afd9f`, archiveSHA256 `dd25ad4a14f440d3d4ad956b3332669ef0531724b74bf2cb7ea9cb38fa73e0c9`다. 공식 helper로 연 체크아웃에 GitHub ffb77751의268개 파일을 복사하고 Git blob을 전부 대조했다. npm ci(audit0)·Worker 빌드·Worker 검사 후 공식 source commit/push·package 결과의 SHA만 배포에 사용했다. Windows helper의 npm 실행 경로·Git Bash PATH·GNU tar Windows colon 처리를 확인한 뒤 같은 소스로 패키징했으며 도구를 수정하거나 TLS 검사를 해제하지 않았다. 후속0e1ff5는 desktop 설치 압축 설정만 수정했고 웹 UI·Worker는 동일하다. 호스팅에서 Spring/JVM을 실행하는 기능은 추가하지 않았다. 배포 완료를 인증된 실제 웹 제출·실기기 검사로 표현하지 않는다. Windows 기본 실제 JVM21, 실무 첫 의존성 준비의 인터넷 필요, 선택 Redis/Kafka 미검증과 코드 서명되지 않은 프리뷰 범위를 유지한다. 구현 PR은 미병합이며 GitHub 공개와 웹 소유자 전용 접근을 구분한다. + +## M9 — v0.3.1 프리뷰 릴리스 + +2026-10-13 03:14:44UTC에 [v0.3.1](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1)을 공개했다. Release ID411215208, 태그·대상 커밋 `6ce0428a802be7a8a757a27a40853996a5b7fa04`, CI38338837092의12작업 PASS. EXE362890668바이트·ZIP362460548바이트·대응 소스2개·SHA256SUMS 총5첨부의 업로드 상태·크기·SHA256을 로컬 원본과 대조했다. 릴리스 태그도 대상 커밋과 일치한다. 전체 payload 정합성은 [이번 QA](readability-release-qa-report.md)를 따른다. 새 실제 설치·앱 기동은 미실행이며 과거 패키지의 실행 결과를 대신 사용하지 않는다. diff --git a/docs/development-handoff.md b/docs/development-handoff.md index 21d37ed..d960f71 100644 --- a/docs/development-handoff.md +++ b/docs/development-handoff.md @@ -1,6 +1,10 @@ -# Java Edu 개발 인계 — v0.3 통합 패키지 +# Java Edu 개발 인계 — v0.3.1 교재·화면 개선 -최신 앱 소스는 `0e1ff5eb38484d1594d4fd1c231d4dbe229b44e8`, 원격은 jbsim999/java-edu, 구현 브랜치는 feat/java-learning-mvp이며 PR1은 미병합이다. [v0.3 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)는 Windows x64 EXE·전체 ZIP에 Temurin21.0.12.1+1·Maven3.10.0·Electron/Node를 포함한다. 학습자의 별도 도구 설치·폴더 등록은 필요 없다. 기본 실제 JVM은21이며 컴파일 대상8·11·17·21과 구분한다. 다른 실제 JDK 등록은 고급 설정의 선택 기능이고 기존 외부 설정을 보존한다. 기본20실무 첫 준비에는 인터넷이 필요하며 Redis/Kafka 서버는 포함하지 않는다. +최신 앱·릴리스 소스는 `6ce0428a802be7a8a757a27a40853996a5b7fa04`다. [v0.3.1 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1)는 2026-10-13 03:14:44UTC에 공개했다. 34수업에 상황·용어·실제 코드·값 추적·예측 해설을 넣고, 반복 안내를 펼침 항목으로 정리했다. 1440px 화면의 본문 폭은 약762px에서1092px로 넓어졌다. CI38338837092의12작업과 독립 정적5/5·읽기24/24·기존49/49·도식19/19 PASS, 새 EXE·ZIP 전체 payload 및 도구604파일 대조 PASS다. 사용 중인 기존 앱과 데이터를 보존하여 새 설치·기동 검사는 미실행이다. [이번 QA](readability-release-qa-report.md)·[설치 안내](installation.md)를 따른다. 문서 후속 커밋은 앱 패키징 소스와 구분한다. + +## 이전 v0.3.0 통합 패키지 기록 + +당시 앱 소스는 `0e1ff5eb38484d1594d4fd1c231d4dbe229b44e8`, 원격은 jbsim999/java-edu, 구현 브랜치는 feat/java-learning-mvp이며 PR1은 미병합이다. [v0.3 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)는 Windows x64 EXE·전체 ZIP에 Temurin21.0.12.1+1·Maven3.10.0·Electron/Node를 포함한다. 학습자의 별도 도구 설치·폴더 등록은 필요 없다. 기본 실제 JVM은21이며 컴파일 대상8·11·17·21과 구분한다. 다른 실제 JDK 등록은 고급 설정의 선택 기능이고 기존 외부 설정을 보존한다. 기본20실무 첫 준비에는 인터넷이 필요하며 Redis/Kafka 서버는 포함하지 않는다. CI38223955175의12작업, 최종 실제 ZIP10/10·NSIS10/10, 도구604개 전체 무결성·원본197+생성2파일 대조, 코드·기능·시각 독립 검수 PASS. package.json은 builder가 제거한 개발 필드를 제외한 런타임 필드를 비교한다. 첫7z NSIS의2파일 누락은 내부 ZIP으로 수정해 한글 설치 경로에서 재검증했다. 설치 제거·학습 데이터 보존·원래 AppData 부재 복원 PASS. 검사 자료는 work/bundled-desktop-qa에 보존하며 Git에 넣지 않는다. [통합 QA](bundled-desktop-qa-report.md)·[번들 계약](bundled-runtime.md)·[설치 안내](installation.md)·[배포 기록](deployment.md)을 먼저 읽는다. diff --git a/docs/installation.md b/docs/installation.md index e590f5a..1c5f122 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,11 +1,11 @@ # Windows 설치와 학습 시작 -Windows 10/11 x64용 [v0.3.0 통합 패키지와 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)를 제공한다. Electron/Node 실행 환경, Eclipse Temurin JDK21.0.12.1+1, Apache Maven3.10.0이 포함되어 학습자가 Java·Maven·Node.js를 따로 설치하거나 폴더를 등록할 필요가 없다. 시스템 PATH를 바꾸지 않는다. +Windows 10/11 x64용 [v0.3.1 통합 패키지와 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1)를 제공한다. Electron/Node 실행 환경, Eclipse Temurin JDK21.0.12.1+1, Apache Maven3.10.0이 포함되어 학습자가 Java·Maven·Node.js를 따로 설치하거나 폴더를 등록할 필요가 없다. 시스템 PATH를 바꾸지 않는다. ## EXE 또는 ZIP으로 시작 -1. 설치하려면 [설치 EXE 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.0/Java-Edu-Setup-0.3.0-x64.exe)를 눌러 받은 파일을 실행하고 설치 위치를 선택한다. 현재 사용자용이며 관리자 권한을 요구하지 않는다. -2. 설치 없이 사용하려면 [전체 ZIP 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.0/Java-Edu-0.3.0-x64.zip)를 **전체 해제**하고 Java Edu.exe를 실행한다. resources 등 함께 있는 파일을 보존한다. exe만 다른 폴더로 옮기면 실행되지 않는다. +1. 설치하려면 [설치 EXE 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.1/Java-Edu-Setup-0.3.1-x64.exe)를 눌러 받은 파일을 실행하고 설치 위치를 선택한다. 현재 사용자용이며 관리자 권한을 요구하지 않는다. +2. 설치 없이 사용하려면 [전체 ZIP 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.1/Java-Edu-0.3.1-x64.zip)를 **전체 해제**하고 Java Edu.exe를 실행한다. resources 등 함께 있는 파일을 보존한다. exe만 다른 폴더로 옮기면 실행되지 않는다. 3. 앱에 포함된 Java21·Maven이 자동으로 표시된다. 학습 코드 실행에 동의하고 코어 실습의 실행·제출을 사용한다. 앱 실행만으로 학습 코드가 실행되지는 않는다. 4. Spring/H2 실무는 **환경 준비 → 파일 편집 → 실행·제출** 순서다. 첫 환경 준비는 앱이 고정 의존성을 내려받으므로 인터넷이 필요하다. 별도 프로그램 설치는 필요 없으며 준비 후 학생 제출은 오프라인 모드다. 앱 재시작·도구 변경 후에는 준비 상태를 다시 확인한다. @@ -26,3 +26,5 @@ Java8/11/17/21 교재와 컴파일 대상을 제공하되 기본 실제 JVM은21 웹도 같은34단원의 이론·예제·퀴즈·메모·문법 강조 편집기를 제공한다. 실무 프로젝트는 ZIP을 내려받아 IDE에서 사용하거나 Windows 앱에서 실행한다. 프로젝트 ZIP의 부모 pom.xml과 lessons/<단원>/starter·reference·grader의 상대 위치를 유지하고 starter 또는 reference에서 mvn test를 수행한다. 웹 Java 코어 실행은 Wandbox의 실제 JVM21이며 교재 기준·컴파일 대상·실제 JVM을 구분해 표시한다. 웹 진도는 해당 브라우저에 저장되며 앱과 자동 동기화되지 않는다. Sites 웹 배포는 소유자 전용 접근을 유지한다. 과거 v0.2는 도구 별도 등록 방식이며 통합 패키지와 구분한다. + +현재 v0.3.1은 교재·레이아웃 독립 검수와 EXE·ZIP 전체 파일 대조를 통과했다. 기존 앱과 학습 기록을 보존하여 신버전 설치·기동 재검사는 수행하지 않았다. [이번 검증 범위](readability-release-qa-report.md)를 따른다. diff --git a/docs/milestones.md b/docs/milestones.md index 1df0bac..8192601 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -20,3 +20,7 @@ M4는 전체 교육 과정 완료를 뜻하지 않는다. 2026-10-12 사용자 M4.1 검증: 브라우저 고유49개를 전체47개와 추가/수정 영향 검사로 나눠 확인, 토큰 단위4개 PASS, 전체 Node31 PASS/실제 제공자1 SKIP. 선택 상태 최저 대비4.859/4.810/5.772:1. 생성 번들이 없는 작업 복사본에서 npm ci→build→Worker 검사→Node 검사 PASS. npm audit은 scoped global-agent 보완 후0건이며 실제 설치 파일 빌드는 M5에서 검사한다. 상세 범위·미확인은 docs/editor-qa-report.md를 따른다. M5–M7 출시 게이트는 이후 완료했다. 출시 소스 6ba4b8a의 CI12작업, 실제 설치 앱8/8, 설치·제거·시각 검수 PASS. [v0.2.0 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)와34수업 웹 교재를 배포했다. 선택 Redis/Kafka 실제 연동, 실기기·스크린리더·장기 부하는 미검증이며 프리뷰 범위를 유지한다. 최신 상태는 [개발 인계](development-handoff.md)를 따른다. + +## M9 — 교재 가독성·본문 확장 + +앱 소스 `6ce0428`의 CI12와 독립 정적5·읽기24·기존49·도식19검사 PASS. 34수업의 구체적 읽기 안내와 반복 정보 정리, 본문 확장을 완료했다. [v0.3.1](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) EXE·ZIP은 전체 파일·도구 무결성 대조 후 공개했다. 새 실제 설치·기동 검사는 사용자 앱·기록 보존 때문에 미실행이다. 웹 배포 결과는 배포 기록에 별도로 추가한다. diff --git a/docs/verification.md b/docs/verification.md index 715c1d2..a837453 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -73,3 +73,7 @@ Temurin21·Maven3.10.0의 공식 ZIP 체크섬, 새해제본 전체, stage604파 첫7z NSIS는604중602로 무결성FAIL이었다. 원본ARM64필터2파일이 압축에는 있지만19.00 NSIS해제기에서 누락됨을 독립진단·직접추출SHA로 확인했다. differentialPackage:false/useZip:true 최소수정독립리뷰 후 새빌드·한글/공백/&/%JAVA_HOME% 경로실제설치와604전체를 재검증했다. 미검증 파일을 누락허용하지 않았다. 첫ZIP10/10·환경guard중단2개·첫NSISFAIL 근거는보존했다. 수정전산출물은 출시하지 않는다. 최종NSIS제거exit0,등록0·바로가기0·학습데이터보존PASS. 새테스트AppData를 work로보관하고 작업전두경로부재로복원했다. userData우회나기존개인설정삭제는하지않았다. Windows11 실제제품 검사이며 Windows10·SmartScreen·OS배율·실기기·스크린리더·Redis/Kafka실제연동은 미검증이다. 기본실무 최초준비는인터넷이필요하고 실제JVM21만기본포함한다. 판정 PASS — 통합프리뷰범위. 대응JDK/Temurin빌드/Maven/제3자소스와원본고지를같은릴리스에서제공한다. + +## v0.3.1 교재·화면 개선 (2026-10-13) + +앱 소스 `6ce0428`의 CI12작업 PASS. 독립 정적5/5·읽기24/24·기존49/49·도식19/19, EXE·ZIP 전체 payload·604도구 파일·릴리스 첨부5개 무결성 PASS. 새 설치·기동은 사용자 앱과 기록을 보존하여 미실행이다. [교재 QA](readability-qa-report.md)·[패키지와 검증 경계](readability-release-qa-report.md)를 따른다. From f9d1a51f862f29c4350dd2bc8768da80064d0acd Mon Sep 17 00:00:00 2001 From: jbsim999 <116049579+jbsim999@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:22:54 +0900 Subject: [PATCH 23/29] =?UTF-8?q?docs(web):=20v0.3.1=20=EC=9B=B9=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EC=99=80=20=EC=B6=9C=EC=8B=9C=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/deployment.md | 17 +++++++++++++++++ docs/development-handoff.md | 2 ++ docs/milestones.md | 2 +- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/deployment.md b/docs/deployment.md index e7de194..783e777 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -74,3 +74,20 @@ M4.1 당시에는 Windows v0.2.0 설치 파일의 로컬 빌드만 완료됐고 ## M9 — v0.3.1 프리뷰 릴리스 2026-10-13 03:14:44UTC에 [v0.3.1](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1)을 공개했다. Release ID411215208, 태그·대상 커밋 `6ce0428a802be7a8a757a27a40853996a5b7fa04`, CI38338837092의12작업 PASS. EXE362890668바이트·ZIP362460548바이트·대응 소스2개·SHA256SUMS 총5첨부의 업로드 상태·크기·SHA256을 로컬 원본과 대조했다. 릴리스 태그도 대상 커밋과 일치한다. 전체 payload 정합성은 [이번 QA](readability-release-qa-report.md)를 따른다. 새 실제 설치·앱 기동은 미실행이며 과거 패키지의 실행 결과를 대신 사용하지 않는다. + +### M9 웹·GitHub 첫 화면 반영 + +2026-10-13 03:20:24UTC에 기존 소유자 전용 [웹 교재](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)의 배포가 `succeeded`를 반환했다. 후속 조회에서도 owner/custom, 허용 사용자1·그룹0·외부0으로 접근 범위를 유지했다. + +| 구분 | 확인값 | +| --- | --- | +| GitHub 웹·문서 원본 | `236ce0d3e461fdfea11929669981999110e1b0cf` | +| Git blob 대조 | 원본275파일 전체 일치, 복사 완료 후 빌드·Worker·Node40 PASS/1 SKIP | +| 공식 helper pushed SHA | `24cd85352707112c5875a120169a9e61e3108331` | +| Site ID | `appgprj_6acc4fec8bb48191a296197d2d55c1e3` | +| 저장 버전 | 5, `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_353fa8d7d06c819186e53ef29eb955e1` | +| 배포 ID | `appgdep_6acda3692ce48191840e4117a0011131` | +| 저장 아카이브 | 34파일, 3266560바이트, `sha256:8e789ec0b37028713897522ce8cdc1ebee754e997657f563d7c111d48564ae0a` | +| main 소개 문서 커밋 | `39ad4a8dfbe1548d672e4bc01bb2f3518efbb5e9` | + +공식 helper에 일치 확인한 소스·완료한 빌드의 배포 묶음을 전달했고, 반환된 새 pushed SHA를 저장·배포에 사용했다. 저장 버전 조회의 SHA도 일치한다. main에는 README와 미리보기 이미지 두 변경 파일만 반영했으며 구현 PR은 병합하지 않았다. GitHub GFM1280/390 독립 시각 PASS 및 실제 공개 첫 화면의 소개·이미지·라이선스 상태 PASS를 확인했다. 이 배포 완료 응답을 인증 세션의 실제 Java 제출 검증으로 표현하지 않는다. diff --git a/docs/development-handoff.md b/docs/development-handoff.md index d960f71..eb8c384 100644 --- a/docs/development-handoff.md +++ b/docs/development-handoff.md @@ -2,6 +2,8 @@ 최신 앱·릴리스 소스는 `6ce0428a802be7a8a757a27a40853996a5b7fa04`다. [v0.3.1 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1)는 2026-10-13 03:14:44UTC에 공개했다. 34수업에 상황·용어·실제 코드·값 추적·예측 해설을 넣고, 반복 안내를 펼침 항목으로 정리했다. 1440px 화면의 본문 폭은 약762px에서1092px로 넓어졌다. CI38338837092의12작업과 독립 정적5/5·읽기24/24·기존49/49·도식19/19 PASS, 새 EXE·ZIP 전체 payload 및 도구604파일 대조 PASS다. 사용 중인 기존 앱과 데이터를 보존하여 새 설치·기동 검사는 미실행이다. [이번 QA](readability-release-qa-report.md)·[설치 안내](installation.md)를 따른다. 문서 후속 커밋은 앱 패키징 소스와 구분한다. +웹은 같은 소유자 전용 URL에 34수업의 새 읽기 안내와 넓은 본문을 배포했다. 원본275파일 Git blob 대조 후 같은 소스로 빌드·Worker·Node40 PASS/1 SKIP를 확인했다. 저장 버전5의 source SHA는 `24cd85352707112c5875a120169a9e61e3108331`, 배포 `appgdep_6acda3692ce48191840e4117a0011131`은 2026-10-13 03:20:24UTC succeeded다. main에는 README와 미리보기 이미지의 문서 커밋 `39ad4a8d`만 반영했으며 PR1은 미병합이다. 이후 기록 문서 커밋은 배포된 앱·웹 소스와 구분한다. + ## 이전 v0.3.0 통합 패키지 기록 당시 앱 소스는 `0e1ff5eb38484d1594d4fd1c231d4dbe229b44e8`, 원격은 jbsim999/java-edu, 구현 브랜치는 feat/java-learning-mvp이며 PR1은 미병합이다. [v0.3 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)는 Windows x64 EXE·전체 ZIP에 Temurin21.0.12.1+1·Maven3.10.0·Electron/Node를 포함한다. 학습자의 별도 도구 설치·폴더 등록은 필요 없다. 기본 실제 JVM은21이며 컴파일 대상8·11·17·21과 구분한다. 다른 실제 JDK 등록은 고급 설정의 선택 기능이고 기존 외부 설정을 보존한다. 기본20실무 첫 준비에는 인터넷이 필요하며 Redis/Kafka 서버는 포함하지 않는다. diff --git a/docs/milestones.md b/docs/milestones.md index 8192601..3d5a81d 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -23,4 +23,4 @@ M5–M7 출시 게이트는 이후 완료했다. 출시 소스 6ba4b8a의 CI12 ## M9 — 교재 가독성·본문 확장 -앱 소스 `6ce0428`의 CI12와 독립 정적5·읽기24·기존49·도식19검사 PASS. 34수업의 구체적 읽기 안내와 반복 정보 정리, 본문 확장을 완료했다. [v0.3.1](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) EXE·ZIP은 전체 파일·도구 무결성 대조 후 공개했다. 새 실제 설치·기동 검사는 사용자 앱·기록 보존 때문에 미실행이다. 웹 배포 결과는 배포 기록에 별도로 추가한다. +앱 소스 `6ce0428`의 CI12와 독립 정적5·읽기24·기존49·도식19검사 PASS. 34수업의 구체적 읽기 안내와 반복 정보 정리, 본문 확장을 완료했다. [v0.3.1](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) EXE·ZIP은 전체 파일·도구 무결성 대조 후 공개했다. 새 실제 설치·기동 검사는 사용자 앱·기록 보존 때문에 미실행이다. 소유자 전용 웹 버전5 배포와 main README 첫 화면 반영도 완료했다. 실제 ID·소스·검증 경계는 [배포 기록](deployment.md)을 따른다. From f3fea6104f301b134a47f5c8fb63c2d49a93b72f Mon Sep 17 00:00:00 2001 From: jbsim999 Date: Tue, 13 Oct 2026 22:33:15 +0900 Subject: [PATCH 24/29] =?UTF-8?q?feat(desktop):=20Intel=EA=B3=BC=20Apple?= =?UTF-8?q?=20Silicon=20macOS=20=ED=8C=A8=ED=82=A4=EC=A7=95=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/package-macos.yml | 88 +++++ AGENTS.md | 4 + CONTRIBUTING.md | 5 +- README.md | 35 ++ desktop/build-config.cjs | 20 +- desktop/bundled-runtime.cjs | 54 ++- .../toolchain-provenance.darwin-arm64.json | 48 +++ desktop/toolchain-provenance.darwin-x64.json | 48 +++ docs/macos-installation.md | 42 +++ docs/macos-packaging-plan.md | 22 ++ docs/macos-verification.md | 37 ++ package-lock.json | 4 +- package.json | 7 +- scripts/bundle-runtime.mjs | 31 +- scripts/prepare-macos-runtime.mjs | 45 +++ scripts/verify-macos-packages.mjs | 325 +++++++++++++++++ server/local/process.mjs | 14 +- tests/local/bundled-runtime-smoke.mjs | 6 +- tests/local/bundled-runtime.test.mjs | 11 +- tests/local/mac-bundled-runtime.test.mjs | 120 +++++++ tests/local/mac-signing.test.mjs | 44 +++ tests/local/process-fixture.mjs | 6 +- tests/local/process.test.mjs | 42 +++ tests/macos-packaged.mjs | 333 ++++++++++++++++++ 24 files changed, 1350 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/package-macos.yml create mode 100644 desktop/toolchain-provenance.darwin-arm64.json create mode 100644 desktop/toolchain-provenance.darwin-x64.json create mode 100644 docs/macos-installation.md create mode 100644 docs/macos-packaging-plan.md create mode 100644 docs/macos-verification.md create mode 100644 scripts/prepare-macos-runtime.mjs create mode 100644 scripts/verify-macos-packages.mjs create mode 100644 tests/local/mac-bundled-runtime.test.mjs create mode 100644 tests/local/mac-signing.test.mjs create mode 100644 tests/macos-packaged.mjs diff --git a/.github/workflows/package-macos.yml b/.github/workflows/package-macos.yml new file mode 100644 index 0000000..2ff6736 --- /dev/null +++ b/.github/workflows/package-macos.yml @@ -0,0 +1,88 @@ +name: Package and verify macOS + +on: + push: + branches: ['codex/macos-packaging'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: macos-package-${{ github.ref }} + cancel-in-progress: false + +jobs: + windows-regression: + name: Windows execution regression + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version: '22' + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 + with: + distribution: temurin + java-version: '21' + - run: npm ci + - run: npm run check + - run: npm run test:local + - shell: pwsh + run: | + $env:JAVA_EDU_JDK_21 = $env:JAVA_HOME + node tests/local/java-smoke.mjs + + macos: + name: macOS ${{ matrix.arch }} DMG and ZIP + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15-intel + arch: x64 + - runner: macos-15 + arch: arm64 + env: + NODE_TLS_REJECT_UNAUTHORIZED: '1' + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version: '22' + cache: npm + - name: Verify native runner + run: node -e "if(process.platform!=='darwin'||process.arch!=='${{ matrix.arch }}')throw Error('Wrong native runner')" + - run: npm ci + - run: npm run check + - run: npm run test:local + - name: Verify and stage complete vendor distributions + run: npm run prepare:mac + - run: npm run check:bundle + - name: Execute included Java and Spring before packaging + run: | + task_node="$(command -v node)" + env -u JAVA_HOME -u MAVEN_HOME -u M2_HOME PATH=/usr/bin:/bin JAVA_EDU_BUNDLE_SMOKE_PROJECT=tx-proxy "$task_node" tests/local/bundled-runtime-smoke.mjs + - name: Build native DMG and ZIP + run: npm run build:desktop:mac -- --${{ matrix.arch }} + - name: Verify final packages and production app execution + run: node scripts/verify-macos-packages.mjs --arch ${{ matrix.arch }} + - name: Preserve packages and verification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: java-edu-macos-${{ matrix.arch }}-${{ github.sha }} + compression-level: 0 + retention-days: 14 + if-no-files-found: error + path: | + desktop-release/Java-Edu-*-mac-${{ matrix.arch }}.dmg + desktop-release/Java-Edu-*-mac-${{ matrix.arch }}.zip + work/macos-qa/**/*.json + work/macos-qa/**/*.png + work/macos-vendor-${{ matrix.arch }}/download-verification.json + work/bundled-runtime-smoke-*/results.json diff --git a/AGENTS.md b/AGENTS.md index 4a808eb..95499c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,10 @@ 협업 규칙의 기준 문서는 CONTRIBUTING.md이다. 파일당 쓰기 소유자를 한 명으로 정한다. 콘텐츠, 프런트엔드, 조사 근거, 테스트를 독립 작업으로 나누고 의미 있는 구현은 독립 검토를 받는다. 기존 변경을 보존하며 추가 위임은 부모가 허용한 경우에만 한다. +2026-10-13 사용자는 이 프로젝트에서 `junbo-github-conventions` 스킬을 앞으로 따르도록 지정했다. 브랜치·커밋·PR·협업 규칙 관련 작업에서는 `C:/Users/JBSIM/.codex/skills/junbo-github-conventions/SKILL.md`를 적용하고 CONTRIBUTING.md를 기준으로 한다. 스킬 채택 자체를 원격 변경 권한으로 해석하지 않는다. 현재 사용자는 검증용 `codex/macos-packaging` 브랜치의 선행 커밋·푸시와 GitHub Actions의 Intel·Apple Silicon Mac 검증을 명시적으로 승인했다. 이전 선행 푸시 보류를 해제하며, 두 환경의 실제 검증 통과 후 README에 결과를 반영하고 같은 브랜치에 커밋·푸시한다. 병합·릴리스 게시 권한은 별도로 확인한다. + +같은 날 사용자는 `junbo-programming-education-project` 스킬을 앞으로의 구현 구조와 작업 기준으로 지정했다. 교육 제품 구현·확장에서는 `C:/Users/JBSIM/.codex/skills/junbo-programming-education-project/SKILL.md`와 현재 작업에 해당하는 참고 계약을 적용한다. 기존 Java Edu 구조를 새 scaffold로 교체하지 않는다. 교육 콘텐츠는 `curriculum/`·`examples/`·`dist/data/`, 공유 화면·편집기는 `dist/`, 웹 실행은 `server/`·`worker/`, 설치 앱·로컬 실행은 `desktop/`·`server/local/`, 빌드·검사는 `scripts/`·`tests/`, 기획·상태·근거는 `docs/`에서 확장한다. 기존 기획·아키텍처·개발 인계를 먼저 확인하고 대표 수업·실행 흐름 검증 후 확장한다. 저장·실행 어댑터, 환경 준비·실행·제출·성취, 정적/모의/실제 실행/독립 검토/출시 상태를 구분한다. 콘텐츠 개정 fingerprint는 기존 구현 완료로 주장하지 않고 변경 시 별도 설계·이행 검증한다. + 역할: 총괄은 범위·통합·검증 책임; 연구자는 공식 명세와 공고 원문 대조; 교육 설계자는 교육 데이터와 독립 Java 예제; 프런트엔드 담당자는 dist의 화면·상호작용; 독립 리뷰어는 정확성·보안·회귀; QA는 실제 기능·합성 화면 검사. 역할은 전문 자격을 의미하지 않는다. 미지원 Java 버전이나 preview 문법을 정식 예제로 표시하지 않는다. 공고 관찰을 채용 통계로 만들지 않는다. 브라우저 퀴즈와 Java 실행 검증을 구분한다. 실제 실행하지 않은 테스트는 미실행으로 기록한다. 실행기 계약은 docs/architecture.md와 docs/local-execution.md를 따른다. 교재 기준·컴파일 대상·실제 실행 JVM을 분리해서 표시한다. 웹 코어 실행은 외부 Wandbox의 실제 JVM21을 사용하며 Java8/11/17 실제 JVM이라고 주장하지 않는다. Windows 설치형은 등록한 로컬 JDK를 사용하며 원격 제공자를 호출하지 않는다. 실무 교재의 정적 공개·기준 테스트 실행·설치형 통합 검증을 분리한다. Redis/Kafka 실제 연동은 선택 환경이며 미검증을 숨기지 않는다. 이 개인 IDE를 OS 보안 샌드박스로 표현하지 않는다. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53dfbe1..971d228 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,14 @@ # 협업 규칙 -이 저장소의 기본 규칙이다. Junbo GitHub Conventions의 추천안을 본 프로젝트에 맞춰 적용했다. 기존 사용자 저장소의 정책이나 GitHub 보호 설정을 확인한 것으로 해석하지 않는다. +이 저장소의 기본 규칙이다. 2026-10-13 사용자가 `junbo-github-conventions` 스킬을 이 프로젝트의 지속적인 협업 기준으로 지정했다. 스킬의 기본안을 기존 프로젝트 정책에 맞춰 적용하며, 이 문서를 프로젝트 규칙의 기준으로 삼는다. 원격 보호 설정을 변경하거나 실제 확인한 것으로 해석하지 않는다. ## 변경 단위와 검토 - 기준 브랜치 `main`, 단기 작업 브랜치 `feat/java-learning-mvp`처럼 `type/description`. 실제 이슈가 있으면 `type/number-description`; 번호를 만들지 않는다. -- 커밋과 PR 제목은 `type(scope): 변경 내용`. 타입은 feat, fix, docs, test, refactor, perf, style, build, ci, chore. scope는 web, curriculum, evidence, examples, ci 중 실제 변경 대상에 맞게 사용한다. 이 목록은 팀의 추가 규칙이다. +- 커밋과 PR 제목은 `type(scope): 변경 내용`. 타입은 feat, fix, docs, test, refactor, perf, style, build, ci, chore. scope는 web, desktop, curriculum, evidence, examples, ci 중 실제 변경 대상에 맞게 사용한다. `desktop`은 설치형 앱·포함 도구·OS별 패키징에 사용한다. 이 목록은 팀의 추가 규칙이다. - 하나의 PR은 하나의 목적을 갖는다. 구현 중에는 Draft PR을 사용한다. 중요 변경은 구현자 외 담당자의 독립 검토와 관련 검사 결과를 남긴다. 에이전트 검토를 사람의 GitHub 승인으로 꾸미지 않는다. - `main` 강제 푸시와 기존 작업 되돌리기를 금지한다. 병합과 운영 배포는 사용자 승인 범위 안에서만 수행한다. PR 준비는 자동 병합 권한을 뜻하지 않는다. +- 스킬 채택은 커밋·푸시·PR 생성·병합·릴리스 게시·보호 설정 변경 권한을 추가하지 않는다. 현재 대화에서 확인한 승인 범위만 활용하고, 자동 승인 검토에서 차단된 작업은 승인 없이 우회하지 않는다. 관련 파일만 반영하고 로컬 작성·로컬 커밋·원격 반영을 구분한다. - `[필수]`, `[제안]`, `[질문]`, `[사소]`로 리뷰 의도를 표시한다. 필수 문제 해결 후 해당 실패 조건을 다시 검사한다. ## 교육 콘텐츠 기준 diff --git a/README.md b/README.md index 2b179e6..c801eb8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교재**를 함께 만드는 한국어 교육 프로젝트입니다. 다른 언어 경험자와 현직 Java 개발자가 “메서드에 객체를 넘겼는데 왜 결과가 달라질까?”, “트랜잭션을 붙였는데 왜 데이터가 남을까?” 같은 질문을 실제 코드로 확인하도록 돕습니다. +**macOS도 확장 중입니다.** macOS 13 Ventura 이상에서 Intel x64·Apple Silicon arm64용 DMG와 ZIP에 Java·Maven을 포함하도록 구성했습니다. 실제 Mac의 최종 패키지 실행 검증은 아직 진행 전이며, 통과 전에는 설치 가능·출시 완료로 표시하지 않습니다. [Mac 설치·빌드 안내](docs/macos-installation.md) · [현재 Mac 검증 상태](docs/macos-verification.md) + 수업은 **이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩** 순서로 진행합니다. Java 언어·표준 API는 Oracle/OpenJDK 공식 문서를, 프레임워크·라이브러리는 각 프로젝트의 공식 문서를 기준으로 합니다. 실무 주제에는 출처를 기록한 채용 공고의 기술 요건도 연결합니다. > **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. [Windows v0.3.1 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1)를 설치해 학습할 수 있습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 34수업·이론 도식 12개·편집기 테마가 배포돼 있습니다. @@ -36,6 +38,16 @@ Java·Maven·Node.js가 포함돼 있어 따로 설치하거나 폴더를 등록 [자세한 설치 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) · [릴리스와 체크섬](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) +### macOS 설치 안내 — 검증 대기 + +Mac용 설치 파일은 실제 Intel·Apple Silicon 환경 검증을 통과한 뒤 제공합니다. 아직 다운로드 링크를 게시하지 않았습니다. Apple 메뉴 → 이 Mac에 관하여에서 **칩**이 Apple M 계열이면 `arm64`, **프로세서**가 Intel이면 `x64` 파일을 선택합니다. + +1. 자신의 CPU에 맞는 **DMG**를 열고 **Java Edu**를 **Applications**로 끌어 놓습니다. +2. 복사가 끝나면 디스크 이미지를 추출하고, 응용 프로그램에서 **Java Edu**를 엽니다. **ZIP**은 전체 `Java Edu.app`을 해제해 응용 프로그램으로 옮깁니다. +3. 첫 코드 실습에서 실행에 동의합니다. 포함 JDK 21·Maven 3.10.0을 사용하며, Spring의 첫 환경 준비에는 인터넷이 필요합니다. + +Mac 프리뷰는 Apple Developer ID 서명·공증을 제공하지 않습니다. 최초 실행이 차단되면 출처와 체크섬을 확인한 후 Apple의 [개별 앱 실행 안내](https://support.apple.com/102445)를 따릅니다. 실제 다운로드·Gatekeeper 흐름은 CI 실행 검증과 별도입니다. [자세한 Mac 안내](docs/macos-installation.md) + ## 학습실 미리보기 ![Java Edu 학습실: 수업 목록, 과제 설명, Java 문법 색상과 직접 코딩 편집기](docs/assets/learning-lab.png) @@ -112,6 +124,23 @@ npm run build:desktop +