v7.0.0-beta.2 release

This commit is contained in:
HeuJung
2026-04-21 12:50:07 +09:00
parent fa8eba7a0d
commit 5d4890d6fb
30 changed files with 1508 additions and 136 deletions
+6
View File
@@ -399,8 +399,14 @@ Added/Changed/Fixed 내 항목이 10개를 초과하면 `####` 서브 헤딩으
기존 테스트 있음 → 변경사항 반영하여 수정 후 실행
기능 구현 시 관련된 모든 계층(백엔드+프론트엔드+레이아웃 렌더링) 테스트 필수
주의: 모듈/플러그인 프론트엔드 테스트는 독립 vitest.config.ts 사용 (루트 config 포함 금지)
필수: 도메인 매트릭스로 테스트 유형 분류 (Pure Logic/CRUD/Hook/Migration)
필수: 버그 수정은 먼저 실패하는 회귀 테스트 → fail 확인 → 수정 → green 4단계
필수: 테스트 중 발견한 무관 에러도 같은 세션에서 처리 (stale test 또는 로직 수정)
필수: 릴리스 전 composer test-smoke 통과
```
> 상세: [docs/testing-guide.md](docs/testing-guide.md) — 도메인 매트릭스, Pre-release Smoke Suite, 회귀 테스트 4단계, 무관 에러 처리
### 그누보드7 레이아웃 렌더링 테스트
```text
+8
View File
@@ -72,6 +72,14 @@
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"test-smoke": [
"@php artisan config:clear --ansi",
"@php vendor/bin/phpunit --testsuite=Installation"
],
"test-smoke-ci": [
"@php artisan config:clear --ansi",
"@php vendor/bin/phpunit --testsuite=Installation --stop-on-failure"
]
},
"extra": {
+17
View File
@@ -34,6 +34,7 @@
```text
확장 버전 업 시 CHANGELOG.md에 변경사항 기록 필수
미기록 시 버전 업 작업 불완전으로 간주
릴리스 태깅 전 composer test-smoke 통과 필수 (Installation 스위트)
```
- **형식**: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 표준 준수
@@ -42,6 +43,22 @@
- **날짜 형식**: YYYY-MM-DD (ISO 8601)
- **최신 버전**: 파일 상단에 위치 (역순)
### 릴리스 전 Smoke Suite 통과
코어 또는 번들 확장의 버전 bump 시 다음을 충족해야 합니다:
```bash
# 릴리스 전 필수 검증
composer test-smoke
# 첫 실패에서 중단 (CI 모드)
composer test-smoke-ci
```
- `composer test-smoke`는 `tests/Feature/Installation/` 및 각 번들 확장의 `tests/Feature/Installation/` 디렉토리를 실행합니다.
- 스모크 통과 없이 릴리스 태깅을 진행하면 beta.2 #12 유형(마이그레이션–Repository 결합 누락) 회귀를 놓칠 수 있습니다.
- 상세: [testing-guide.md#pre-release-smoke-suite](../testing-guide.md#pre-release-smoke-suite)
### 버전 섹션 형식
```markdown
+1
View File
@@ -9,6 +9,7 @@
- 개별/일괄 검증: {core|module|plugin}:vendor-verify, vendor-bundle:verify-all
- 모드: VendorMode = auto | composer | bundled
- 결정 순서: 명시 → DB 이전 모드 → config → 환경감지(composer 가능 시 우선)
- composer.json/lock 수정 후 재빌드 필수 — scripts 섹션 같은 런타임 무관 변경도 파일 전체 SHA256 변경 → VendorIntegrityChecker 실패
```
## 1. 개요
+207 -5
View File
@@ -8,17 +8,21 @@
```text
1. 테스트 통과 = 작업 완료 (작성만으로 불충분!)
2. 기존 테스트 있음 → 변경사항 반영하여 수정 후 실행
3. 기존 테스트 없음 → 새 테스트 작성 후 실행
4. 백엔드: php artisan test --filter=TestName
5. _bundled 확장 테스트: 활성 디렉토리 복사 불필요 → _bundled에서 직접 실행
6. 모듈/플러그인 프론트엔드 테스트는 해당 디렉토리에서 독립 실행
2. 도메인 매트릭스(§도메인별 테스트 전략)로 테스트 유형 결정 — OrderCalc 수준 전면 적용 금지
3. 버그 수정은 "먼저 실패하는 회귀 테스트 작성 → fail 확인 → 수정 → green" 4단계 필수
4. 테스트 실행 중 발견한 무관 에러도 같은 세션에서 함께 처리 (stale test 수정 or 로직 수정)
5. 릴리스 전 composer test-smoke 통과 필수 (Installation 스위트)
6. 백엔드: php artisan test --filter=TestName / _bundled 확장은 _bundled에서 직접 실행
```
---
## 목차
- [도메인별 테스트 전략 매트릭스](#도메인별-테스트-전략-매트릭스)
- [Pre-release Smoke Suite](#pre-release-smoke-suite)
- [버그 수정 시 회귀 테스트 의무](#버그-수정-시-회귀-테스트-의무)
- [테스트 실행 중 발견한 무관 에러 처리](#테스트-실행-중-발견한-무관-에러-처리)
- [필수 원칙](#필수-원칙)
- [모듈/플러그인 프론트엔드 테스트 독립성](#모듈플러그인-프론트엔드-테스트-독립성)
- [DDL 트랜잭션 격리 문제](#ddl-트랜잭션-격리-문제)
@@ -181,6 +185,204 @@ beforeRefreshingDatabase()는 RefreshDatabase 트레이트가 부모 클래스
---
## 도메인별 테스트 전략 매트릭스
```text
필수: 테스트 작성 전 대상 파일의 도메인을 먼저 분류할 것
금지: 모든 코드에 OrderCalculationService 수준(테이블 드리븐 조합 폭발)을 획일적 적용
이유: 도메인 성격에 맞지 않는 테스트는 가치 낮은 mock 재작성만 양산 (회귀 방지 효과 없음)
```
### 4개 도메인 분류
| 도메인 | 파일명 패턴 | 성격 | 필수 테스트 유형 | 참고 모범사례 |
|--------|-------------|------|------------------|---------------|
| **Pure Logic** | `*Calculator`, `*Policy`, `*Rule`, `*PricingService`, `*CalculationService` | 입력→출력 결정론 | 테이블 드리븐 + 경계값 + 조합 폭발 전수 (OrderCalc 수준) | `modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCalculationServiceTest.php` |
| **CRUD/Repository** | `*Repository`, `*Service` (CRUD 위주) | DB 상태 변이 | 마이그레이션 실행 후 Feature 테스트 1회전 (golden path + 권한 경계) | `modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardManagementTest.php` |
| **Hook/Event** | `*Listener`, `*Subscriber`, Hook 발행 Service | 사이드이펙트 | Event 발행 → Listener 실행 → 관찰 가능한 상태 변화 검증 (mock 금지, 실제 훅 체인) | `modules/_bundled/sirsoft-board/tests/Unit/Listeners/CountSyncListenerTest.php` |
| **Migration** | `database/migrations/*`, 모듈/플러그인 migrations | 스키마 변이 | up → down → up 왕복 + 영향받는 Repository/Service 호출처 1회전 | `tests/Feature/Installation/BoardFreshInstallTest.php` |
### 분류 결정 순서
1. **파일명 패턴 매칭** — 위 표의 패턴과 일치하면 그 도메인으로 분류
2. **불명확한 경우** — "테스트로 검증할 불변식(invariant)"을 기준으로 판단:
- 입력→출력 결정론 ⇒ Pure Logic
- DB/파일 상태 변이 ⇒ CRUD/Repository
- 이벤트/훅 기반 사이드이펙트 ⇒ Hook/Event
3. **하나의 서비스가 여러 도메인에 걸칠 때** — 메서드 단위로 분류해서 각각에 맞는 테스트 유형 적용
### 도메인별 작성 지침
**Pure Logic**:
- 모든 분기/경계값을 DataProvider로 전수 커버
- 외부 의존성 0 (DB, HTTP, 파일시스템 호출 금지)
- assert는 반환값 + 예외 타입까지 명시
**CRUD/Repository**:
- `RefreshDatabase` 트레이트 + 실제 마이그레이션 실행
- golden path 1개 + 권한 실패 1개 + 유효성 실패 1개가 최소 세트
- DB 스키마 가정을 assertion에 포함 (`assertDatabaseHas` 등)
**Hook/Event**:
- mock이 아닌 실제 이벤트 발행(`event(new XxxEvent(...))`)
- Listener 동작 후 DB/캐시/상태 변화를 직접 조회하여 검증
- 훅 체인이 끊어지면 회귀하는 카운터, 권한, 정합성 지표를 검증 대상으로
**Migration**:
- `migrate` → `migrate:rollback` → `migrate` 왕복 안전성 테스트
- 기존 스키마를 변경(drop/rename)하는 마이그레이션은 영향받는 Repository 호출 1회전 필수 포함
- 스키마 변경이 데이터 유실을 동반한다면 별도 데이터 보존 테스트 필요
---
## Pre-release Smoke Suite
```text
필수: 릴리스 태깅 전 composer test-smoke 통과 필수
목적: 마이그레이션 + 모듈 설치 + 핵심 사용자 여정 end-to-end 검증
대상: Clean install, 업그레이드 경로, 번들 모듈 핵심 시나리오
```
### 구성
```text
tests/Feature/Installation/
├── FreshInstallSmokeTest.php # 마이그레이션 전수 실행 + 코어 헬스체크
├── BoardFreshInstallTest.php # 게시판 create → post → comment 1회전
├── EcommerceFreshInstallTest.php # 상품 생성 → 주문 → 결제 mock 1회전
└── UpgradeSmokeTest.php # 이전 릴리스 시드 → 최신 마이그레이션 → 기존 데이터 접근 1회전
```
### 각 테스트 최소 요구사항
- `RefreshDatabase` + 실제 migrations 전수 실행 (mock DB 금지)
- 모듈/플러그인 install artisan 커맨드 호출
- 해당 도메인의 사용자 여정 1개를 end-to-end로 실행 (HTTP 레이어까지)
- 각 단계에 명확한 assertion (`assertTrue(true)` 같은 무의미 assert 금지)
### 실행
```bash
# 전체 스모크 스위트
composer test-smoke
# CI 모드 (첫 실패에서 중단)
composer test-smoke-ci
# 특정 스모크 테스트만
php artisan test tests/Feature/Installation/BoardFreshInstallTest.php
```
### 릴리스 전 체크리스트
- [ ] `composer test-smoke` 통과
- [ ] `config/app.php` 버전 bump와 동시에 CHANGELOG 기록
- [ ] Installation 테스트에서 새 마이그레이션 경로가 커버되는지 확인
---
## 버그 수정 시 회귀 테스트 의무
```text
필수: 버그 수정 작업은 4단계 순서 준수 (스킵 불가)
금지: 회귀 테스트 없이 로직만 수정하고 완료 선언
이유: 테스트 없는 수정은 동일 회귀를 반복 발생시킴 (beta.2 #11 사례)
```
### 4단계 절차
```text
1. 재현 → 먼저 실패하는 회귀 테스트 작성 (fail 확인 필수)
2. 그 테스트가 수정 대상 파일의 도메인에 맞는 유형인지 확인 (§도메인별 테스트 전략 매트릭스)
3. 버그 수정 → 테스트 green 전환
4. 완료 보고에 "회귀 테스트 파일 경로 + 커밋 전 fail 상태 증빙" 포함
```
### 도메인별 회귀 테스트 위치
| 버그 유형 | 회귀 테스트 작성 위치 |
|----------|----------------------|
| 순수 로직 버그 | `tests/Unit/Services/*Test.php` 또는 확장의 동등 위치 |
| CRUD/API 버그 | `tests/Feature/*Test.php` |
| 훅/카운터 동기화 버그 | `tests/Unit/Listeners/*Test.php` |
| 마이그레이션–Repository 결합 버그 | `tests/Feature/Installation/*Test.php` (스모크 스위트 포함) |
### 예시 — #11 회귀 테스트 패턴
```php
// ❌ 버그 수정만 하고 끝내는 패턴 (금지)
// BoardPostService::incrementCommentCount() 수정하고 완료 선언
// ✅ 회귀 테스트 먼저 작성
#[Test]
public function comment_count_updates_after_comment_creation(): void
{
$post = BoardPost::factory()->create(['comment_count' => 0]);
// 댓글 생성 (실제 HTTP 호출 or Service 직접 호출)
$this->postJson("/api/boards/{$post->board_id}/posts/{$post->id}/comments", [
'content' => '테스트 댓글',
])->assertStatus(201);
// ⚠️ 수정 전에는 이 assertion이 실패해야 함
$this->assertSame(1, $post->fresh()->comment_count);
}
```
---
## 테스트 실행 중 발견한 무관 에러 처리
```text
필수: 테스트 실행 중 확인된 FAIL/ERROR는 현재 작업과 무관해도 같은 세션에서 함께 해결
금지: "별건이라 나중에 수정"하고 세션 종료
이유: 세션을 넘기면 휘발되는 정보. 발견 즉시 처리가 가장 효율적
```
### 의사결정 분기
```text
테스트 실행 → 실패/경고 감지
│
├─ 실패 원인이 "테스트 코드가 현행 로직을 따라가지 못함"
│ → 테스트 코드 수정 (stale test 정비)
│ → 판정 근거: git blame + 해당 로직 변경 시점 비교
│
├─ 실패 원인이 "실제 로직의 버그"
│ → 로직 수정 + 회귀 테스트 보강 (§버그 수정 시 회귀 테스트 의무의 4단계 적용)
│
└─ 판별 불가
→ PO 보고 후 지시 대기 (현재 세션 내 보고 의무, 다음 세션 이월 금지)
```
### 제외 범위 (함께 수정하지 않아도 되는 경우)
- 실패가 **외부 환경 의존**(네트워크 미연결, 빌드 캐시, 서버 인프라)인 경우
- CLAUDE.md 디버깅 프로토콜 "고려대상에서 절대 제외" 항목과 일치
- 실패가 **설계 변경 진행 중**이라 의도적으로 깨진 상태
- 단, 기존 todo/계획서에 명시된 상태여야 함 (사후 정당화 금지)
### 테스트 결과 보고 필수 필드
`/run-tests` 실행 결과에서 FAIL/ERROR/RISKY가 1건 이상이면 각 항목에 대해 다음 필드를 기록:
| 필드 | 값 예시 | 설명 |
|------|---------|------|
| 연관 여부 | YES / NO / UNCLEAR | 현재 작업 스코프와의 연관성 |
| 판정 근거 | 커밋 해시, 파일 경로, 변경 시점 | git blame/log 기반 증거 |
| 조치 분기 | 테스트 수정 / 로직 수정 / PO 확인 요청 | 의사결정 분기 결과 |
### 범위
- 현재 작업 디렉토리에서 실행한 테스트 스위트 내 FAIL만 대상
- 다른 모듈/디렉토리까지 강제로 확장하지 않음 (작업 부하 관리)
---
## 필수 원칙
```
+4
View File
@@ -17,6 +17,10 @@
<testsuite name="Plugin">
<directory>plugins/_bundled/*/tests</directory>
</testsuite>
<testsuite name="Installation">
<directory>tests/Feature/Installation</directory>
<directory>modules/_bundled/*/tests/Feature/Installation</directory>
</testsuite>
</testsuites>
<source>
<include>
+17
View File
@@ -8,6 +8,23 @@ import { vi } from 'vitest';
// 전역 mocks
global.fetch = vi.fn();
/**
* IntersectionObserver 폴리필 — jsdom은 IntersectionObserver를 기본 제공하지 않으므로
* 컴포넌트가 무한스크롤/가시성 추적을 위해 사용하는 경우를 대비해 no-op 폴리필을 전역 제공.
* 개별 테스트에서 세부 동작 제어가 필요하면 `vi.stubGlobal('IntersectionObserver', ...)` 으로 덮어쓴다.
*/
class NoopIntersectionObserver {
root: Element | null = null;
rootMargin = '';
thresholds: number[] = [];
observe(): void {}
unobserve(): void {}
disconnect(): void {}
takeRecords(): IntersectionObserverEntry[] { return []; }
}
(global as any).IntersectionObserver = NoopIntersectionObserver;
(window as any).IntersectionObserver = NoopIntersectionObserver;
/**
* window.matchMedia 모킹
* 테스트 환경에서 CSS 미디어 쿼리를 지원하기 위함
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.3] - 2026-04-21
### Fixed
- 알림 레이어를 닫을 때 목록이 읽음 상태로 갱신되지 않아 재토글 시 읽음 처리가 누락되던 문제 수정
- 알림 레이어 무한 스크롤 시 "안 읽은 알림만" 필터가 유지되지 않아 읽은 알림이 섞여 노출되던 문제 수정
- 알림 레이어 무한 스크롤 시 동일 페이지 API 가 중복 호출되던 문제 수정
- 안 읽은 알림이 없는데도 알림 레이어를 닫을 때 읽음 처리 API 가 불필요하게 호출되던 문제 수정
- 알림 드롭다운이 화면 경계를 벗어나지 않도록 자동으로 좌/우 정렬 전환 및 최대 너비 제한
## [1.0.0-beta.2] - 2026-04-20
### Added
@@ -0,0 +1,147 @@
/**
* @file admin-base-notification-sync.test.tsx
* @description 관리자 알림 레이어 동기화 회귀 테스트
*
* 회귀 방지 대상:
* 1. onClose(read-batch) 이후 notifications 데이터소스가 refetch되어야 함
* → 이전: notification_unread_count만 refetch되어 다음 토글 시 marked_count: 0
* 2. onLoadMore 무한 스크롤 요청에 read 파라미터가 포함되어야 함
* → 이전: page/per_page만 포함되어 읽은 알림이 섞여서 로드됨
*
* 데스크톱 헤더와 모바일 헤더 양쪽에 동일 구조가 존재하므로 둘 다 검증한다.
* 참고: gnuboard/g7#14 (공개 제보)
*/
import { describe, it, expect } from 'vitest';
import adminBase from '../../layouts/_admin_base.json';
type ActionNode = {
event?: string;
handler?: string;
params?: Record<string, any>;
onSuccess?: ActionNode[];
actions?: ActionNode[];
target?: string;
};
type LayoutNode = {
actions?: ActionNode[];
children?: LayoutNode[];
slots?: Record<string, LayoutNode[]>;
[key: string]: unknown;
};
function walk(input: LayoutNode | LayoutNode[] | undefined, visit: (node: LayoutNode) => void) {
if (!input) return;
const nodes = Array.isArray(input) ? input : [input];
for (const node of nodes) {
visit(node);
if (node.slots) {
for (const key of Object.keys(node.slots)) {
walk(node.slots[key], visit);
}
}
if (node.children) {
walk(node.children, visit);
}
}
}
function collectActionsByEvent(layout: any, eventName: string): ActionNode[] {
const roots = Array.isArray(layout.components) ? layout.components : [layout];
const matched: ActionNode[] = [];
walk(roots, (node) => {
if (!Array.isArray(node.actions)) return;
for (const action of node.actions) {
if (action && action.event === eventName) {
matched.push(action);
}
}
});
return matched;
}
function flattenSequenceActions(action: ActionNode): ActionNode[] {
if (action.handler === 'sequence' && Array.isArray(action.params?.actions)) {
return action.params!.actions as ActionNode[];
}
return [action];
}
describe('관리자 알림 레이어 동기화 (gnuboard/g7#14)', () => {
describe('Bug 1: onClose 후 notifications 데이터소스 refetch', () => {
const closeActions = collectActionsByEvent(adminBase, 'onClose');
it('데스크톱/모바일 양쪽에 onClose 핸들러가 존재해야 한다', () => {
expect(closeActions.length).toBeGreaterThanOrEqual(2);
});
it.each(closeActions.map((a, i) => [i, a] as const))(
'onClose[%i] sequence는 read-batch apiCall을 포함한다',
(_, close) => {
const steps = flattenSequenceActions(close);
const apiCall = steps.find(
(s) => s.handler === 'apiCall' && typeof s.target === 'string' && s.target.endsWith('/notifications/read-batch'),
);
expect(apiCall).toBeDefined();
},
);
it.each(closeActions.map((a, i) => [i, a] as const))(
'onClose[%i] read-batch onSuccess에 notifications refetch가 포함되어야 한다',
(_, close) => {
const steps = flattenSequenceActions(close);
const apiCall = steps.find(
(s) => s.handler === 'apiCall' && typeof s.target === 'string' && s.target.endsWith('/notifications/read-batch'),
)!;
const successSteps = (apiCall.onSuccess ?? []) as ActionNode[];
const refetchIds = successSteps
.filter((s) => s.handler === 'refetchDataSource')
.map((s) => s.params?.dataSourceId);
expect(refetchIds).toContain('notifications');
expect(refetchIds).toContain('notification_unread_count');
},
);
it.each(closeActions.map((a, i) => [i, a] as const))(
'onClose[%i] onSuccess에 페이지네이션 리셋 setState가 포함되어야 한다',
(_, close) => {
const steps = flattenSequenceActions(close);
const apiCall = steps.find(
(s) => s.handler === 'apiCall' && typeof s.target === 'string' && s.target.endsWith('/notifications/read-batch'),
)!;
const successSteps = (apiCall.onSuccess ?? []) as ActionNode[];
const setState = successSteps.find((s) => s.handler === 'setState');
expect(setState).toBeDefined();
expect(setState!.params?.target).toBe('global');
expect(setState!.params?.notificationsCurrentPage).toBe(1);
expect(setState!.params?.notificationsHasMore).toBeNull();
},
);
});
describe('Bug 2: onLoadMore query에 read 파라미터 유지', () => {
const loadMoreActions = collectActionsByEvent(adminBase, 'onLoadMore');
it('데스크톱/모바일 양쪽에 onLoadMore 핸들러가 존재해야 한다', () => {
expect(loadMoreActions.length).toBeGreaterThanOrEqual(2);
});
it.each(loadMoreActions.map((a, i) => [i, a] as const))(
'onLoadMore[%i] apiCall query에 read 파라미터가 포함되어야 한다',
(_, loadMore) => {
const steps = flattenSequenceActions(loadMore);
const apiCall = steps.find(
(s) => s.handler === 'apiCall' && typeof s.target === 'string' && s.target.endsWith('/admin/notifications'),
);
expect(apiCall).toBeDefined();
const query = apiCall!.params?.query ?? {};
expect(query).toHaveProperty('page');
expect(query).toHaveProperty('per_page');
expect(query).toHaveProperty('read');
expect(String(query.read)).toContain('notificationsUnreadOnly');
},
);
});
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -285,6 +285,19 @@
}
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"notificationsCurrentPage": 1,
"notificationsHasMore": null,
"notificationsLoadingMore": false
}
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "notifications" }
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "notification_unread_count" }
@@ -425,7 +438,8 @@
"method": "GET",
"query": {
"page": "{{(_global.notificationsCurrentPage ?? notifications?.data?.pagination?.current_page ?? 1) + 1}}",
"per_page": 15
"per_page": 15,
"read": "{{(_global.notificationsUnreadOnly ?? true) ? 'unread' : 'all'}}"
}
},
"onSuccess": [
@@ -681,6 +695,19 @@
}
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"notificationsCurrentPage": 1,
"notificationsHasMore": null,
"notificationsLoadingMore": false
}
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "notifications" }
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "notification_unread_count" }
@@ -821,7 +848,8 @@
"method": "GET",
"query": {
"page": "{{(_global.notificationsCurrentPage ?? notifications?.data?.pagination?.current_page ?? 1) + 1}}",
"per_page": 15
"per_page": 15,
"read": "{{(_global.notificationsUnreadOnly ?? true) ? 'unread' : 'all'}}"
}
},
"onSuccess": [
@@ -1,6 +1,6 @@
{
"name": "sirsoft-admin_basic",
"version": "1.0.0-beta.2",
"version": "1.0.0-beta.3",
"description": "Gnuboard7 Basic Admin Template Components",
"type": "module",
"main": "dist/components.js",
@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
import { Div } from '../basic/Div';
import { Button } from '../basic/Button';
import { Span } from '../basic/Span';
@@ -105,28 +105,55 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
}) => {
const [showNotifications, setShowNotifications] = useState(false);
const notificationRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const visibleUnreadIdsRef = useRef<Set<string | number>>(new Set());
// 무한스크롤 관련 최신 값 참조 (observer 재등록 없이 최신 prop 접근)
const onLoadMoreRef = useRef(onLoadMore);
const hasMoreRef = useRef(hasMore);
const loadingRef = useRef(loading);
const notificationsCountRef = useRef(notifications.length);
// 마지막 onLoadMore 발행 시점의 notifications.length — 동일 길이에서 중복 발행 가드
const lastRequestedCountRef = useRef<number>(-1);
useEffect(() => {
onLoadMoreRef.current = onLoadMore;
hasMoreRef.current = hasMore;
loadingRef.current = loading;
notificationsCountRef.current = notifications.length;
});
/**
* 무한스크롤 — IntersectionObserver
*
* observer는 드롭다운 열림/닫힘 전환 시에만 재등록한다.
* 목록이 append되거나 loading/hasMore가 바뀌어도 observer를 새로 만들지 않으므로,
* sentinel이 이미 뷰포트에 있는 상태에서 재등록 시 즉시 intersecting callback이
* 중복 발행되던 문제를 방지한다.
*/
useEffect(() => {
if (!sentinelRef.current || !scrollRef.current || !hasMore || loading || !showNotifications) return;
if (!sentinelRef.current || !scrollRef.current || !showNotifications) return;
// 드롭다운이 새로 열릴 때마다 중복 가드 초기화
lastRequestedCountRef.current = -1;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
onLoadMore?.();
}
if (!entry.isIntersecting) return;
if (loadingRef.current || !hasMoreRef.current) return;
// B안 가드: 같은 length에서 이미 한 번 발행했다면 skip
if (lastRequestedCountRef.current === notificationsCountRef.current) return;
lastRequestedCountRef.current = notificationsCountRef.current;
onLoadMoreRef.current?.();
},
{ root: scrollRef.current, threshold: 0.1 }
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasMore, loading, showNotifications, onLoadMore, notifications.length]);
}, [showNotifications]);
/**
* 뷰포트 내 미읽음 알림 추적 — IntersectionObserver
@@ -156,19 +183,34 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
return () => observer.disconnect();
}, [showNotifications, notifications]);
/**
* 뱃지에 표시할 카운트 (handleClose 보다 먼저 선언 — deps 참조 순서)
*/
const displayCount = unreadCount > 0 ? unreadCount : notifications.filter((n) => !n.read).length;
/**
* 드롭다운 닫기 — 뷰포트에 들어온 미읽음 알림 ID 전달
*
* 불필요한 read-batch 호출 방지:
* 1) displayCount 가 0 이면 이미 모두 읽음/삭제 처리된 상태 → 전체 skip
* 2) 뷰포트에 나타났던 ID 중 현재 notifications 기준 여전히 unread 인 것만 전달
* ("모두 읽음" onSuccess 로 notifications 가 갱신되면 필터 결과가 빈 배열이 됨)
*/
const handleClose = useCallback(() => {
if (showNotifications) {
const ids = Array.from(visibleUnreadIdsRef.current);
if (ids.length > 0) {
onClose?.(ids);
if (displayCount > 0) {
const unreadIds = Array.from(visibleUnreadIdsRef.current).filter((id) => {
const n = notifications.find((item) => String(item.id) === String(id));
return n && !n.read;
});
if (unreadIds.length > 0) {
onClose?.(unreadIds);
}
}
visibleUnreadIdsRef.current.clear();
setShowNotifications(false);
}
}, [showNotifications, onClose]);
}, [showNotifications, onClose, displayCount, notifications]);
/**
* 외부 클릭 감지 — 드롭다운이 열려 있을 때만 리스너 등록
@@ -203,9 +245,36 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
}, [showNotifications, handleClose]);
/**
* 뱃지에 표시할 카운트
* 드롭다운 열릴 때 뷰포트 경계 검사 후 이탈량만큼 translateX 로 보정
*
* class 기반 left-0/right-0 전환은 부모 컨테이너 기준이라 부모가 뷰포트 가장자리에 있을 때
* 여전히 이탈할 수 있다. 따라서 초기 렌더 후 드롭다운의 실제 뷰포트 좌표를 측정하여
* 이탈한 픽셀만큼 반대 방향으로 이동시키는 방식을 사용한다.
*/
const displayCount = unreadCount > 0 ? unreadCount : notifications.filter((n) => !n.read).length;
useLayoutEffect(() => {
const dropdown = dropdownRef.current;
if (!showNotifications || !dropdown) return;
// 측정 전 기존 transform 초기화 (재측정 시 누적 방지)
dropdown.style.transform = '';
const rect = dropdown.getBoundingClientRect();
const vw = typeof window !== 'undefined' ? window.innerWidth : 0;
// layout 미측정(jsdom 등)에서 rect 가 0 으로 반환되는 경우 보정 스킵
if (rect.width === 0 && rect.height === 0) return;
const margin = 8;
let shift = 0;
if (rect.right > vw - margin) {
shift = -(rect.right - (vw - margin));
} else if (rect.left < margin) {
shift = margin - rect.left;
}
if (shift !== 0) {
dropdown.style.transform = `translateX(${shift}px)`;
}
}, [showNotifications, dropdownAlign, notifications.length]);
return (
<Div ref={notificationRef} className={`relative ${className}`}>
@@ -224,7 +293,7 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
{/* 알림 드롭다운 */}
{showNotifications && (
<Div className={`absolute ${dropdownAlign === 'left' ? 'left-0' : 'right-0'} mt-2 w-80 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50`}>
<Div ref={dropdownRef} className={`absolute ${dropdownAlign === 'left' ? 'left-0' : 'right-0'} mt-2 w-80 max-w-[calc(100vw-1rem)] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50`}>
<Div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between gap-2">
<Div className="flex items-center gap-3 min-w-0">
<Span className="font-semibold text-gray-900 dark:text-white">{titleText}</Span>
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NotificationCenter, NotificationItem } from '../NotificationCenter';
@@ -55,8 +55,8 @@ describe('NotificationCenter', () => {
expect(screen.getByText('2')).toBeInTheDocument(); // 읽지 않은 알림 2개
});
it('읽지 않은 알림이 9개 초과 시 9+로 표시됨', () => {
const manyNotifications: NotificationItem[] = Array.from({ length: 15 }, (_, i) => ({
it('읽지 않은 알림이 99개 초과 시 99+로 표시됨', () => {
const manyNotifications: NotificationItem[] = Array.from({ length: 150 }, (_, i) => ({
id: i,
title: `Notification ${i}`,
message: 'Message',
@@ -65,7 +65,7 @@ describe('NotificationCenter', () => {
}));
render(<NotificationCenter notifications={manyNotifications} />);
expect(screen.getByText('9+')).toBeInTheDocument();
expect(screen.getByText('99+')).toBeInTheDocument();
});
});
@@ -109,9 +109,9 @@ describe('NotificationCenter', () => {
const notificationButton = screen.getByRole('button');
await user.click(notificationButton);
// 읽지 않은 알림 찾기
const unreadNotification = screen.getByText('New Comment').closest('button');
expect(unreadNotification).toHaveClass('bg-blue-50');
// 읽지 않은 알림 찾기 — 카드는 data-notification-id 를 가진 Div (투명도 변형 bg-blue-50/70 적용)
const unreadNotification = screen.getByText('New Comment').closest('[data-notification-id]');
expect(unreadNotification?.className).toMatch(/bg-blue-50/);
});
it('읽은 알림에는 파란색 배경이 없음', async () => {
@@ -122,8 +122,8 @@ describe('NotificationCenter', () => {
await user.click(notificationButton);
// 읽은 알림 찾기
const readNotification = screen.getByText('System Update').closest('button');
expect(readNotification).not.toHaveClass('bg-blue-50');
const readNotification = screen.getByText('System Update').closest('[data-notification-id]');
expect(readNotification?.className).not.toMatch(/bg-blue-50/);
});
it('읽지 않은 알림에 파란색 점 표시가 있음', async () => {
@@ -196,10 +196,10 @@ describe('NotificationCenter', () => {
const notificationButton = screen.getByRole('button');
await user.click(notificationButton);
const notification = screen.getByText('New Comment').closest('button');
await user.click(notification!);
const notification = screen.getByText('New Comment').closest('[data-notification-id]');
await user.click(notification as HTMLElement);
expect(onNotificationClick).toHaveBeenCalledWith(1);
expect(onNotificationClick).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }));
expect(onNotificationClick).toHaveBeenCalledTimes(1);
});
@@ -221,8 +221,8 @@ describe('NotificationCenter', () => {
const notificationButton = screen.getByRole('button');
await user.click(notificationButton);
const notification = screen.getByText('Test').closest('button');
await user.click(notification!);
const notification = screen.getByText('Test').closest('[data-notification-id]');
await user.click(notification as HTMLElement);
expect(onClick).toHaveBeenCalledTimes(1);
});
@@ -240,8 +240,8 @@ describe('NotificationCenter', () => {
const notificationButton = screen.getByRole('button');
await user.click(notificationButton);
const notification = screen.getByText('New Comment').closest('button');
await user.click(notification!);
const notification = screen.getByText('New Comment').closest('[data-notification-id]');
await user.click(notification as HTMLElement);
// 드롭다운이 닫혔는지 확인
expect(screen.queryByText('System Update')).not.toBeInTheDocument();
@@ -352,15 +352,16 @@ describe('NotificationCenter', () => {
expect(button).toBeInTheDocument();
});
it('알림 아이템들이 버튼으로 접근 가능함', async () => {
it('알림 아이템들이 카드 단위로 렌더링되어 접근 가능함', async () => {
const user = userEvent.setup();
render(<NotificationCenter notifications={mockNotifications} />);
const { container } = render(<NotificationCenter notifications={mockNotifications} />);
const notificationButton = screen.getByRole('button');
await user.click(notificationButton);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(4); // 메인 버튼 + 알림 3개
// button-in-button 회피를 위해 카드는 Div 로 렌더링됨 → data-notification-id 로 접근
const cards = container.querySelectorAll('[data-notification-id]');
expect(cards.length).toBe(mockNotifications.length);
});
});
@@ -396,15 +397,15 @@ describe('NotificationCenter', () => {
// 첫 번째 알림 클릭
await user.click(screen.getByRole('button'));
await user.click(screen.getByText('New Comment').closest('button')!);
expect(onNotificationClick).toHaveBeenCalledWith(1);
await user.click(screen.getByText('New Comment').closest('[data-notification-id]') as HTMLElement);
expect(onNotificationClick).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }));
// 드롭다운 다시 열기
await user.click(screen.getByRole('button'));
// 두 번째 알림 클릭
await user.click(screen.getByText('System Update').closest('button')!);
expect(onNotificationClick).toHaveBeenCalledWith(2);
await user.click(screen.getByText('System Update').closest('[data-notification-id]') as HTMLElement);
expect(onNotificationClick).toHaveBeenCalledWith(expect.objectContaining({ id: 2 }));
expect(onNotificationClick).toHaveBeenCalledTimes(2);
});
@@ -448,4 +449,302 @@ describe('NotificationCenter', () => {
expect(scrollableArea).toBeInTheDocument();
});
});
describe('[회귀] 무한스크롤 중복 호출 방지 (gnuboard/g7#14 관련)', () => {
// 첫 번째로 생성되는 observer 만 무한스크롤용으로 취급. 미읽음 추적 observer 의
// disconnect 가 무한스크롤 callback 을 교란하지 않도록 인스턴스 메서드로 격리한다.
let infiniteScrollInstance: any = null;
beforeEach(() => {
infiniteScrollInstance = null;
vi.stubGlobal(
'IntersectionObserver',
class MockIO {
root: Element | null = null;
rootMargin = '';
thresholds: number[] = [];
callback: IntersectionObserverCallback | null;
target: Element | null = null;
constructor(cb: IntersectionObserverCallback) {
this.callback = cb;
if (!infiniteScrollInstance) infiniteScrollInstance = this;
}
observe(target: Element) {
if (!this.target) this.target = target;
}
disconnect() {
this.callback = null;
this.target = null;
if (infiniteScrollInstance === this) infiniteScrollInstance = null;
}
unobserve() {}
takeRecords() { return []; }
},
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const fireIntersection = () => {
infiniteScrollInstance?.callback?.(
[{ isIntersecting: true, target: infiniteScrollInstance.target } as unknown as IntersectionObserverEntry],
{} as IntersectionObserver,
);
};
const makeNotifications = (count: number): NotificationItem[] =>
Array.from({ length: count }, (_, i) => ({
id: i + 1,
title: `t${i}`,
message: `m${i}`,
time: '1m',
read: false,
}));
it('같은 notifications.length 에서 sentinel 이 재교차해도 onLoadMore 는 한 번만 호출된다', async () => {
const user = userEvent.setup();
const onLoadMore = vi.fn();
render(
<NotificationCenter
notifications={makeNotifications(15)}
hasMore={true}
loading={false}
onLoadMore={onLoadMore}
/>,
);
await user.click(screen.getByRole('button'));
fireIntersection();
fireIntersection();
fireIntersection();
expect(onLoadMore).toHaveBeenCalledTimes(1);
});
it('loading=true 일 때는 onLoadMore 를 발행하지 않는다', async () => {
const user = userEvent.setup();
const onLoadMore = vi.fn();
render(
<NotificationCenter
notifications={makeNotifications(15)}
hasMore={true}
loading={true}
onLoadMore={onLoadMore}
/>,
);
await user.click(screen.getByRole('button'));
fireIntersection();
expect(onLoadMore).not.toHaveBeenCalled();
});
it('hasMore=false 일 때는 onLoadMore 를 발행하지 않는다', async () => {
const user = userEvent.setup();
const onLoadMore = vi.fn();
render(
<NotificationCenter
notifications={makeNotifications(15)}
hasMore={false}
loading={false}
onLoadMore={onLoadMore}
/>,
);
await user.click(screen.getByRole('button'));
fireIntersection();
expect(onLoadMore).not.toHaveBeenCalled();
});
it('notifications.length 가 증가한 뒤 재교차하면 onLoadMore 를 다시 발행한다', async () => {
const user = userEvent.setup();
const onLoadMore = vi.fn();
const { rerender } = render(
<NotificationCenter
notifications={makeNotifications(15)}
hasMore={true}
loading={false}
onLoadMore={onLoadMore}
/>,
);
await user.click(screen.getByRole('button'));
fireIntersection();
expect(onLoadMore).toHaveBeenCalledTimes(1);
// 서버 응답이 와서 목록이 늘어난 상황
rerender(
<NotificationCenter
notifications={makeNotifications(30)}
hasMore={true}
loading={false}
onLoadMore={onLoadMore}
/>,
);
fireIntersection();
expect(onLoadMore).toHaveBeenCalledTimes(2);
// 같은 length 에서 재교차 → 다시 무시
fireIntersection();
expect(onLoadMore).toHaveBeenCalledTimes(2);
});
});
describe('[회귀] 닫기 시 read-batch 불필요 호출 방지', () => {
it('unreadCount=0 + 모든 알림 read=true 상태에서 닫으면 onClose 를 호출하지 않는다', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const allRead: NotificationItem[] = mockNotifications.map((n) => ({ ...n, read: true }));
render(
<NotificationCenter
notifications={allRead}
unreadCount={0}
onClose={onClose}
/>,
);
// 레이어 열고 닫기
const button = screen.getByRole('button');
await user.click(button);
await user.click(button);
expect(onClose).not.toHaveBeenCalled();
});
it('뷰포트에 노출됐던 ID 라도 현재 notifications 에서 read=true 로 바뀐 것은 onClose 인자에서 제외된다', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
// mockNotifications: id 1(unread), id 2(read), id 3(unread)
const { rerender, container } = render(
<NotificationCenter notifications={mockNotifications} unreadCount={2} onClose={onClose} />,
);
const button = screen.getByRole('button');
await user.click(button);
// data-notification-id 요소에 data-unread 속성을 주입하여 Observer가 ID를 수집할 수 있게 시뮬레이션
container.querySelectorAll('[data-notification-id]').forEach((el) => {
const id = el.getAttribute('data-notification-id');
const notification = mockNotifications.find((n) => String(n.id) === String(id));
el.setAttribute('data-unread', notification?.read ? 'false' : 'true');
});
// rerender: 모든 알림이 read=true 로 갱신된 상황 (모두 읽음 onSuccess 반영)
rerender(
<NotificationCenter
notifications={mockNotifications.map((n) => ({ ...n, read: true }))}
unreadCount={0}
onClose={onClose}
/>,
);
// 레이어 닫기
await user.click(screen.getByRole('button'));
expect(onClose).not.toHaveBeenCalled();
});
});
describe('[회귀] 드롭다운 뷰포트 이탈 방지', () => {
let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect;
beforeEach(() => {
originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
});
afterEach(() => {
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
});
it('드롭다운에 max-w-[calc(100vw-1rem)] 클래스가 적용된다', async () => {
const user = userEvent.setup();
const { container } = render(<NotificationCenter notifications={mockNotifications} />);
await user.click(screen.getByRole('button'));
const dropdown = container.querySelector('.absolute.w-80');
expect(dropdown?.className).toContain('max-w-[calc(100vw-1rem)]');
});
it('드롭다운이 뷰포트 오른쪽을 이탈하면 translateX 로 왼쪽으로 보정된다', async () => {
// rect.right 가 vw 를 초과하도록 mock → 이탈량만큼 음의 translateX 기대
Element.prototype.getBoundingClientRect = vi.fn(() => ({
left: 100,
right: 1100,
top: 0,
bottom: 100,
width: 320,
height: 100,
x: 100,
y: 0,
toJSON: () => ({}),
}) as DOMRect);
Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true });
const user = userEvent.setup();
const { container } = render(
<NotificationCenter notifications={mockNotifications} dropdownAlign="left" />,
);
await user.click(screen.getByRole('button'));
const dropdown = container.querySelector<HTMLElement>('.absolute.w-80');
// 1100 - (1024 - 8) = 84 → translateX(-84px)
expect(dropdown?.style.transform).toBe('translateX(-84px)');
});
it('드롭다운이 뷰포트 왼쪽을 이탈하면 translateX 로 오른쪽으로 보정된다', async () => {
Element.prototype.getBoundingClientRect = vi.fn(() => ({
left: -50,
right: 270,
top: 0,
bottom: 100,
width: 320,
height: 100,
x: -50,
y: 0,
toJSON: () => ({}),
}) as DOMRect);
Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true });
const user = userEvent.setup();
const { container } = render(
<NotificationCenter notifications={mockNotifications} dropdownAlign="right" />,
);
await user.click(screen.getByRole('button'));
const dropdown = container.querySelector<HTMLElement>('.absolute.w-80');
// margin(8) - left(-50) = 58 → translateX(58px)
expect(dropdown?.style.transform).toBe('translateX(58px)');
});
it('드롭다운이 뷰포트 안에 있으면 transform 이 적용되지 않는다', async () => {
Element.prototype.getBoundingClientRect = vi.fn(() => ({
left: 100,
right: 420,
top: 0,
bottom: 100,
width: 320,
height: 100,
x: 100,
y: 0,
toJSON: () => ({}),
}) as DOMRect);
Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true });
const user = userEvent.setup();
const { container } = render(
<NotificationCenter notifications={mockNotifications} dropdownAlign="right" />,
);
await user.click(screen.getByRole('button'));
const dropdown = container.querySelector<HTMLElement>('.absolute.w-80');
expect(dropdown?.style.transform).toBe('');
});
});
});
@@ -5,7 +5,7 @@
"ko": "Admin Basic",
"en": "Admin Basic"
},
"version": "1.0.0-beta.2",
"version": "1.0.0-beta.3",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 관리자 템플릿",
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.3] - 2026-04-21
### Fixed
- 알림 레이어를 닫을 때 목록이 읽음 상태로 갱신되지 않아 재토글 시 읽음 처리가 누락되던 문제 수정
- 알림 레이어 무한 스크롤 시 "안 읽은 알림만" 필터가 유지되지 않아 읽은 알림이 섞여 노출되던 문제 수정
- 알림 레이어 무한 스크롤 시 동일 페이지 API 가 중복 호출되던 문제 수정
- 안 읽은 알림이 없는데도 알림 레이어를 닫을 때 읽음 처리 API 가 불필요하게 호출되던 문제 수정
- 알림 드롭다운이 화면 경계를 벗어나지 않도록 자동으로 좌/우 정렬 전환 및 최대 너비 제한
## [1.0.0-beta.2] - 2026-04-20
### Added
@@ -0,0 +1,134 @@
/**
* @file user-base-notification-sync.test.tsx
* @description 사용자 알림 레이어 동기화 회귀 테스트
*
* 회귀 방지 대상:
* 1. onNotificationClose(read-batch) 이후 notifications 데이터소스가 refetch되어야 함
* → 이전: notification_unread_count만 refetch되어 다음 토글 시 marked_count: 0
* 2. onNotificationLoadMore 무한 스크롤 요청에 read 파라미터가 포함되어야 함
* → 이전: page/per_page만 포함되어 읽은 알림이 섞여서 로드됨
*
* 참고: gnuboard/g7#14 (공개 제보)
*/
import { describe, it, expect } from 'vitest';
import userBase from '../../layouts/_user_base.json';
type ActionNode = {
event?: string;
handler?: string;
params?: Record<string, any>;
onSuccess?: ActionNode[];
actions?: ActionNode[];
target?: string;
};
type LayoutNode = {
actions?: ActionNode[];
children?: LayoutNode[];
slots?: Record<string, LayoutNode[]>;
[key: string]: unknown;
};
function walk(input: LayoutNode | LayoutNode[] | undefined, visit: (node: LayoutNode) => void) {
if (!input) return;
const nodes = Array.isArray(input) ? input : [input];
for (const node of nodes) {
visit(node);
if (node.slots) {
for (const key of Object.keys(node.slots)) {
walk(node.slots[key], visit);
}
}
if (node.children) {
walk(node.children, visit);
}
}
}
function collectActionsByEvent(layout: any, eventName: string): ActionNode[] {
const roots = Array.isArray(layout.components) ? layout.components : [layout];
const matched: ActionNode[] = [];
walk(roots, (node) => {
if (!Array.isArray(node.actions)) return;
for (const action of node.actions) {
if (action && action.event === eventName) {
matched.push(action);
}
}
});
return matched;
}
function flattenSequenceActions(action: ActionNode): ActionNode[] {
if (action.handler === 'sequence' && Array.isArray(action.params?.actions)) {
return action.params!.actions as ActionNode[];
}
return [action];
}
describe('사용자 알림 레이어 동기화 (gnuboard/g7#14)', () => {
describe('Bug 1: onNotificationClose 후 notifications 데이터소스 refetch', () => {
const closeActions = collectActionsByEvent(userBase, 'onNotificationClose');
it('onNotificationClose 핸들러가 존재해야 한다', () => {
expect(closeActions.length).toBeGreaterThanOrEqual(1);
});
it('read-batch apiCall을 포함해야 한다', () => {
const steps = flattenSequenceActions(closeActions[0]);
const apiCall = steps.find(
(s) => s.handler === 'apiCall' && typeof s.target === 'string' && s.target.endsWith('/notifications/read-batch'),
);
expect(apiCall).toBeDefined();
});
it('read-batch onSuccess에 notifications + notification_unread_count refetch가 포함되어야 한다', () => {
const steps = flattenSequenceActions(closeActions[0]);
const apiCall = steps.find(
(s) => s.handler === 'apiCall' && typeof s.target === 'string' && s.target.endsWith('/notifications/read-batch'),
)!;
const successSteps = (apiCall.onSuccess ?? []) as ActionNode[];
const refetchIds = successSteps
.filter((s) => s.handler === 'refetchDataSource')
.map((s) => s.params?.dataSourceId);
expect(refetchIds).toContain('notifications');
expect(refetchIds).toContain('notification_unread_count');
});
it('onSuccess에 페이지네이션 리셋 setState가 포함되어야 한다', () => {
const steps = flattenSequenceActions(closeActions[0]);
const apiCall = steps.find(
(s) => s.handler === 'apiCall' && typeof s.target === 'string' && s.target.endsWith('/notifications/read-batch'),
)!;
const successSteps = (apiCall.onSuccess ?? []) as ActionNode[];
const setState = successSteps.find((s) => s.handler === 'setState');
expect(setState).toBeDefined();
expect(setState!.params?.target).toBe('global');
expect(setState!.params?.notificationsCurrentPage).toBe(1);
expect(setState!.params?.notificationsHasMore).toBeNull();
});
});
describe('Bug 2: onNotificationLoadMore query에 read 파라미터 유지', () => {
const loadMoreActions = collectActionsByEvent(userBase, 'onNotificationLoadMore');
it('onNotificationLoadMore 핸들러가 존재해야 한다', () => {
expect(loadMoreActions.length).toBeGreaterThanOrEqual(1);
});
it('apiCall query에 read 파라미터가 포함되어야 한다', () => {
const steps = flattenSequenceActions(loadMoreActions[0]);
const apiCall = steps.find(
(s) => s.handler === 'apiCall' && typeof s.target === 'string' && s.target.endsWith('/user/notifications'),
);
expect(apiCall).toBeDefined();
const query = apiCall!.params?.query ?? {};
expect(query).toHaveProperty('page');
expect(query).toHaveProperty('per_page');
expect(query).toHaveProperty('read');
expect(String(query.read)).toContain('notificationsUnreadOnly');
});
});
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1704,6 +1704,19 @@
"body": { "ids": "{{$args[0]}}" }
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"notificationsCurrentPage": 1,
"notificationsHasMore": null,
"notificationsLoadingMore": false
}
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "notifications" }
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "notification_unread_count" }
@@ -1843,7 +1856,8 @@
"method": "GET",
"query": {
"page": "{{(_global.notificationsCurrentPage ?? notifications?.data?.pagination?.current_page ?? 1) + 1}}",
"per_page": 15
"per_page": 15,
"read": "{{(_global.notificationsUnreadOnly ?? true) ? 'unread' : 'all'}}"
}
},
"onSuccess": [
@@ -1,6 +1,6 @@
{
"name": "sirsoft-basic",
"version": "1.0.0-beta.2",
"version": "1.0.0-beta.3",
"description": "Gnuboard7 Basic User Template Components - Nexibase Style",
"type": "module",
"main": "dist/components.js",
@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
import { Div } from '../basic/Div';
import { Button } from '../basic/Button';
import { Span } from '../basic/Span';
@@ -105,28 +105,55 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
}) => {
const [showNotifications, setShowNotifications] = useState(false);
const notificationRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const visibleUnreadIdsRef = useRef<Set<string | number>>(new Set());
// 무한스크롤 관련 최신 값 참조 (observer 재등록 없이 최신 prop 접근)
const onLoadMoreRef = useRef(onLoadMore);
const hasMoreRef = useRef(hasMore);
const loadingRef = useRef(loading);
const notificationsCountRef = useRef(notifications.length);
// 마지막 onLoadMore 발행 시점의 notifications.length — 동일 길이에서 중복 발행 가드
const lastRequestedCountRef = useRef<number>(-1);
useEffect(() => {
onLoadMoreRef.current = onLoadMore;
hasMoreRef.current = hasMore;
loadingRef.current = loading;
notificationsCountRef.current = notifications.length;
});
/**
* 무한스크롤 — IntersectionObserver
*
* observer는 드롭다운 열림/닫힘 전환 시에만 재등록한다.
* 목록이 append되거나 loading/hasMore가 바뀌어도 observer를 새로 만들지 않으므로,
* sentinel이 이미 뷰포트에 있는 상태에서 재등록 시 즉시 intersecting callback이
* 중복 발행되던 문제를 방지한다.
*/
useEffect(() => {
if (!sentinelRef.current || !scrollRef.current || !hasMore || loading || !showNotifications) return;
if (!sentinelRef.current || !scrollRef.current || !showNotifications) return;
// 드롭다운이 새로 열릴 때마다 중복 가드 초기화
lastRequestedCountRef.current = -1;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
onLoadMore?.();
}
if (!entry.isIntersecting) return;
if (loadingRef.current || !hasMoreRef.current) return;
// B안 가드: 같은 length에서 이미 한 번 발행했다면 skip
if (lastRequestedCountRef.current === notificationsCountRef.current) return;
lastRequestedCountRef.current = notificationsCountRef.current;
onLoadMoreRef.current?.();
},
{ root: scrollRef.current, threshold: 0.1 }
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasMore, loading, showNotifications, onLoadMore, notifications.length]);
}, [showNotifications]);
/**
* 뷰포트 내 미읽음 알림 추적 — IntersectionObserver
@@ -156,19 +183,34 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
return () => observer.disconnect();
}, [showNotifications, notifications]);
/**
* 뱃지에 표시할 카운트 (handleClose 보다 먼저 선언 — deps 참조 순서)
*/
const displayCount = unreadCount > 0 ? unreadCount : notifications.filter((n) => !n.read).length;
/**
* 드롭다운 닫기 — 뷰포트에 들어온 미읽음 알림 ID 전달
*
* 불필요한 read-batch 호출 방지:
* 1) displayCount 가 0 이면 이미 모두 읽음/삭제 처리된 상태 → 전체 skip
* 2) 뷰포트에 나타났던 ID 중 현재 notifications 기준 여전히 unread 인 것만 전달
* ("모두 읽음" onSuccess 로 notifications 가 갱신되면 필터 결과가 빈 배열이 됨)
*/
const handleClose = useCallback(() => {
if (showNotifications) {
const ids = Array.from(visibleUnreadIdsRef.current);
if (ids.length > 0) {
onClose?.(ids);
if (displayCount > 0) {
const unreadIds = Array.from(visibleUnreadIdsRef.current).filter((id) => {
const n = notifications.find((item) => String(item.id) === String(id));
return n && !n.read;
});
if (unreadIds.length > 0) {
onClose?.(unreadIds);
}
}
visibleUnreadIdsRef.current.clear();
setShowNotifications(false);
}
}, [showNotifications, onClose]);
}, [showNotifications, onClose, displayCount, notifications]);
/**
* 외부 클릭 감지 — 드롭다운이 열려 있을 때만 리스너 등록
@@ -203,9 +245,36 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
}, [showNotifications, handleClose]);
/**
* 뱃지에 표시할 카운트
* 드롭다운 열릴 때 뷰포트 경계 검사 후 이탈량만큼 translateX 로 보정
*
* class 기반 left-0/right-0 전환은 부모 컨테이너 기준이라 부모가 뷰포트 가장자리에 있을 때
* 여전히 이탈할 수 있다. 따라서 초기 렌더 후 드롭다운의 실제 뷰포트 좌표를 측정하여
* 이탈한 픽셀만큼 반대 방향으로 이동시키는 방식을 사용한다.
*/
const displayCount = unreadCount > 0 ? unreadCount : notifications.filter((n) => !n.read).length;
useLayoutEffect(() => {
const dropdown = dropdownRef.current;
if (!showNotifications || !dropdown) return;
// 측정 전 기존 transform 초기화 (재측정 시 누적 방지)
dropdown.style.transform = '';
const rect = dropdown.getBoundingClientRect();
const vw = typeof window !== 'undefined' ? window.innerWidth : 0;
// layout 미측정(jsdom 등)에서 rect 가 0 으로 반환되는 경우 보정 스킵
if (rect.width === 0 && rect.height === 0) return;
const margin = 8;
let shift = 0;
if (rect.right > vw - margin) {
shift = -(rect.right - (vw - margin));
} else if (rect.left < margin) {
shift = margin - rect.left;
}
if (shift !== 0) {
dropdown.style.transform = `translateX(${shift}px)`;
}
}, [showNotifications, dropdownAlign, notifications.length]);
return (
<Div ref={notificationRef} className={`relative ${className}`}>
@@ -224,7 +293,7 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
{/* 알림 드롭다운 */}
{showNotifications && (
<Div className={`absolute ${dropdownAlign === 'left' ? 'left-0' : 'right-0'} mt-2 w-80 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50`}>
<Div ref={dropdownRef} className={`absolute ${dropdownAlign === 'left' ? 'left-0' : 'right-0'} mt-2 w-80 max-w-[calc(100vw-1rem)] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50`}>
<Div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between gap-2">
<Div className="flex items-center gap-3 min-w-0">
<Span className="font-semibold text-gray-900 dark:text-white">{titleText}</Span>
@@ -237,4 +237,279 @@ describe('NotificationCenter (sirsoft-basic)', () => {
expect(read?.className).toMatch(/opacity-70/);
});
});
describe('[회귀] 무한스크롤 중복 호출 방지 (gnuboard/g7#14 관련)', () => {
// 첫 번째로 생성되는 observer 만 무한스크롤용으로 취급. 미읽음 추적 observer 의
// disconnect 가 무한스크롤 callback 을 교란하지 않도록 인스턴스 메서드로 격리한다.
let infiniteScrollInstance: any = null;
beforeEach(() => {
infiniteScrollInstance = null;
(window as any).IntersectionObserver = class MockIO {
root: Element | null = null;
rootMargin = '';
thresholds: number[] = [];
callback: IntersectionObserverCallback | null;
target: Element | null = null;
constructor(cb: IntersectionObserverCallback) {
this.callback = cb;
if (!infiniteScrollInstance) infiniteScrollInstance = this;
}
observe(target: Element) {
if (!this.target) this.target = target;
}
disconnect() {
this.callback = null;
this.target = null;
if (infiniteScrollInstance === this) infiniteScrollInstance = null;
}
unobserve() {}
takeRecords() { return []; }
};
});
const fireIntersection = () => {
infiniteScrollInstance?.callback?.(
[{ isIntersecting: true, target: infiniteScrollInstance.target } as unknown as IntersectionObserverEntry],
{} as IntersectionObserver,
);
};
const makeNotifications = (count: number): NotificationItem[] =>
Array.from({ length: count }, (_, i) => ({
id: i + 1,
title: `t${i}`,
message: `m${i}`,
time: '1m',
read: false,
}));
it('같은 notifications.length 에서 sentinel 이 재교차해도 onLoadMore 는 한 번만 호출된다', () => {
const onLoadMore = vi.fn();
render(
<NotificationCenter
notifications={makeNotifications(15)}
hasMore={true}
loading={false}
onLoadMore={onLoadMore}
/>,
);
fireEvent.click(screen.getByLabelText('알림'));
fireIntersection();
fireIntersection();
fireIntersection();
expect(onLoadMore).toHaveBeenCalledTimes(1);
});
it('loading=true 일 때는 onLoadMore 를 발행하지 않는다', () => {
const onLoadMore = vi.fn();
render(
<NotificationCenter
notifications={makeNotifications(15)}
hasMore={true}
loading={true}
onLoadMore={onLoadMore}
/>,
);
fireEvent.click(screen.getByLabelText('알림'));
fireIntersection();
expect(onLoadMore).not.toHaveBeenCalled();
});
it('hasMore=false 일 때는 onLoadMore 를 발행하지 않는다', () => {
const onLoadMore = vi.fn();
render(
<NotificationCenter
notifications={makeNotifications(15)}
hasMore={false}
loading={false}
onLoadMore={onLoadMore}
/>,
);
fireEvent.click(screen.getByLabelText('알림'));
fireIntersection();
expect(onLoadMore).not.toHaveBeenCalled();
});
it('notifications.length 가 증가한 뒤 재교차하면 onLoadMore 를 다시 발행한다', () => {
const onLoadMore = vi.fn();
const { rerender } = render(
<NotificationCenter
notifications={makeNotifications(15)}
hasMore={true}
loading={false}
onLoadMore={onLoadMore}
/>,
);
fireEvent.click(screen.getByLabelText('알림'));
fireIntersection();
expect(onLoadMore).toHaveBeenCalledTimes(1);
rerender(
<NotificationCenter
notifications={makeNotifications(30)}
hasMore={true}
loading={false}
onLoadMore={onLoadMore}
/>,
);
fireIntersection();
expect(onLoadMore).toHaveBeenCalledTimes(2);
fireIntersection();
expect(onLoadMore).toHaveBeenCalledTimes(2);
});
});
describe('[회귀] 닫기 시 read-batch 불필요 호출 방지', () => {
it('unreadCount=0 + 모든 알림 read=true 상태에서 닫으면 onClose 를 호출하지 않는다', () => {
const onClose = vi.fn();
const allRead: NotificationItem[] = mockNotifications.map((n) => ({ ...n, read: true }));
render(
<NotificationCenter
notifications={allRead}
unreadCount={0}
onClose={onClose}
/>,
);
// 레이어 열고 닫기
const button = screen.getByLabelText('알림');
fireEvent.click(button);
fireEvent.click(button);
expect(onClose).not.toHaveBeenCalled();
});
it('뷰포트에 노출됐던 ID 라도 현재 notifications 에서 read=true 로 바뀐 것은 onClose 인자에서 제외된다', () => {
const onClose = vi.fn();
const { rerender, container } = render(
<NotificationCenter notifications={mockNotifications} unreadCount={1} onClose={onClose} />,
);
const button = screen.getByLabelText('알림');
fireEvent.click(button);
// data-unread 속성 시뮬레이션
container.querySelectorAll('[data-notification-id]').forEach((el) => {
const id = el.getAttribute('data-notification-id');
const n = mockNotifications.find((item) => String(item.id) === String(id));
el.setAttribute('data-unread', n?.read ? 'false' : 'true');
});
// 모두 읽음 처리된 상태로 rerender
rerender(
<NotificationCenter
notifications={mockNotifications.map((n) => ({ ...n, read: true }))}
unreadCount={0}
onClose={onClose}
/>,
);
// 레이어 닫기
fireEvent.click(screen.getByLabelText('알림'));
expect(onClose).not.toHaveBeenCalled();
});
});
describe('[회귀] 드롭다운 뷰포트 이탈 방지', () => {
let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect;
beforeEach(() => {
originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
});
it('드롭다운에 max-w-[calc(100vw-1rem)] 클래스가 적용된다', () => {
const { container } = render(<NotificationCenter notifications={mockNotifications} />);
fireEvent.click(screen.getByLabelText('알림'));
const dropdown = container.querySelector('.absolute.w-80');
expect(dropdown?.className).toContain('max-w-[calc(100vw-1rem)]');
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
});
it('드롭다운이 뷰포트 오른쪽을 이탈하면 translateX 로 왼쪽으로 보정된다', () => {
Element.prototype.getBoundingClientRect = vi.fn(() => ({
left: 100,
right: 1100,
top: 0,
bottom: 100,
width: 320,
height: 100,
x: 100,
y: 0,
toJSON: () => ({}),
}) as DOMRect);
Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true });
const { container } = render(
<NotificationCenter notifications={mockNotifications} dropdownAlign="left" />,
);
fireEvent.click(screen.getByLabelText('알림'));
const dropdown = container.querySelector<HTMLElement>('.absolute.w-80');
expect(dropdown?.style.transform).toBe('translateX(-84px)');
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
});
it('드롭다운이 뷰포트 왼쪽을 이탈하면 translateX 로 오른쪽으로 보정된다', () => {
Element.prototype.getBoundingClientRect = vi.fn(() => ({
left: -50,
right: 270,
top: 0,
bottom: 100,
width: 320,
height: 100,
x: -50,
y: 0,
toJSON: () => ({}),
}) as DOMRect);
Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true });
const { container } = render(
<NotificationCenter notifications={mockNotifications} dropdownAlign="right" />,
);
fireEvent.click(screen.getByLabelText('알림'));
const dropdown = container.querySelector<HTMLElement>('.absolute.w-80');
expect(dropdown?.style.transform).toBe('translateX(58px)');
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
});
it('드롭다운이 뷰포트 안에 있으면 transform 이 적용되지 않는다', () => {
Element.prototype.getBoundingClientRect = vi.fn(() => ({
left: 100,
right: 420,
top: 0,
bottom: 100,
width: 320,
height: 100,
x: 100,
y: 0,
toJSON: () => ({}),
}) as DOMRect);
Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true });
const { container } = render(
<NotificationCenter notifications={mockNotifications} dropdownAlign="right" />,
);
fireEvent.click(screen.getByLabelText('알림'));
const dropdown = container.querySelector<HTMLElement>('.absolute.w-80');
expect(dropdown?.style.transform).toBe('');
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
});
});
});
@@ -5,7 +5,7 @@
"ko": "Basic",
"en": "Basic"
},
"version": "1.0.0-beta.2",
"version": "1.0.0-beta.3",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 사용자 템플릿",
+62
View File
@@ -0,0 +1,62 @@
# Pre-release Smoke Suite
> 릴리스 전 clean-install/업그레이드 경로 전수 검증용 스위트
## 목적
- 마이그레이션 실행 후 핵심 사용자 여정 1회전 검증 (end-to-end)
- 마이그레이션–Repository/Service 결합 회귀 방지 (beta.2 #12 유형)
- 업그레이드 경로에서 기존 데이터 호환성 검증
## 실행
```bash
# 전체 스모크 스위트
composer test-smoke
# 첫 실패에서 중단 (CI 모드)
composer test-smoke-ci
# 특정 테스트만
php artisan test tests/Feature/Installation/FreshInstallSmokeTest.php
```
## 스코프
phpunit testsuite `Installation`은 다음 2개 경로를 커버합니다:
```xml
<testsuite name="Installation">
<directory>tests/Feature/Installation</directory>
<directory>modules/_bundled/*/tests/Feature/Installation</directory>
</testsuite>
```
| 위치 | 담당 범위 | 예시 |
|------|-----------|------|
| `tests/Feature/Installation/` (이 디렉토리) | 코어·크로스 모듈 스모크 | `FreshInstallSmokeTest.php`, `UpgradeSmokeTest.php` |
| `modules/_bundled/{id}/tests/Feature/Installation/` | 모듈별 설치·회귀 | `BoardFreshInstallTest.php`, `EcommerceFreshInstallTest.php` |
## 작성 대상 (TODO — 담당자 배정 대기)
### 코어·크로스 모듈 (이 디렉토리)
- [ ] `FreshInstallSmokeTest.php` — 마이그레이션 전수 실행 + 코어 헬스체크 (users/roles/modules 테이블 생성, 기본 역할/권한 시드)
- [ ] `UpgradeSmokeTest.php` — 이전 릴리스 시드 → 최신 마이그레이션 → 기존 데이터 접근 1회전
### 모듈별 (각 모듈 `tests/Feature/Installation/` 아래)
- [ ] `modules/_bundled/sirsoft-board/tests/Feature/Installation/BoardFreshInstallTest.php`
- `#12` 회귀: fresh install 후 `BoardService::createBoard()` 성공 검증 (`ensureBoardPartitions()` 우회 사용 금지)
- `#11` 회귀: 댓글 생성 → `board_posts.comments_count` 증가 검증 (HTTP 레이어 포함)
- [ ] `modules/_bundled/sirsoft-ecommerce/tests/Feature/Installation/EcommerceFreshInstallTest.php`
- 상품 생성 → 주문 → 결제 mock 1회전
## 작성 규칙
- `RefreshDatabase` + 실제 마이그레이션 전수 실행 (mock DB 금지)
- 모듈/플러그인 install artisan 커맨드 호출 경로 포함
- 각 단계에 명확한 assertion (`assertTrue(true)` 같은 무의미 assert 금지)
- 사용자 여정 1개를 end-to-end로 (HTTP 레이어까지)
> 상세: [docs/testing-guide.md — Pre-release Smoke Suite](../../../docs/testing-guide.md#pre-release-smoke-suite)
+4 -4
View File
@@ -1,12 +1,12 @@
{
"schema_version": "1.0",
"generated_at": "2026-04-17T06:45:38+00:00",
"generated_at": "2026-04-21T03:32:02+00:00",
"generator": "g7 vendor-bundle:build",
"target": "core",
"composer_json_sha256": "6a27c1c9bd2f03d19ad95976c929401929f7301227685116d8bfd008168305de",
"composer_json_sha256": "b22b62e1c059171ffd45c7b92e63e707ee883171fb6189a94944d617dcac881b",
"composer_lock_sha256": "c178a79aa8a1e34e5b256a536a3ce9924f277f0138af12aac8100132c9f75c18",
"zip_sha256": "9264bed8162cc0bd709e631ffb79905a8b7de3ce25318dc613e61ad28adf172e",
"zip_size": 14372947,
"zip_sha256": "2279a3be51eabeb6d9e47c901fed8113d64baea2e94de3413f1eb97e77316cd7",
"zip_size": 14372946,
"package_count": 104,
"php_requirement": "^8.2",
"g7_version": "7.0.0-beta.2",
BIN
View File
Binary file not shown.