From f860fd4db4911b4429d176b7fc5fe84172fe553b Mon Sep 17 00:00:00 2001 From: diadmin Date: Mon, 26 Oct 2026 21:20:10 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EC=9D=80=20Sonnet=205.5=20=EB=A7=8C=20(wiki=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99),=206=EB=8B=A8=EA=B3=84=20=EC=A7=84=ED=96=89=EB=B6=84?= =?UTF-8?q?:=20circles=20=EB=AA=A8=EB=93=88=C2=B7CircleService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wiki/00 최우선 규칙: 특별한 지시가 없는 한 Sonnet 5.5 만 사용 - wiki/00: 사용자 작성 질문·테스트·화면 출력 규칙 반영 - circles(순수): 가까움 순위·고리 나누기, 선배·후배, 직급 순서, 원 위 자리 / CircleService: 원형 보기 자료·단체 메시지 (시험 10개) Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + lib/modules/app/app.dart | 1 + lib/modules/app/src/circle_service.dart | 180 ++++++++++++++++++++++++ lib/modules/app/src/friendly_app.dart | 3 + lib/modules/circles/circles.dart | 10 ++ lib/modules/circles/src/closeness.dart | 31 ++++ lib/modules/circles/src/cohort.dart | 26 ++++ lib/modules/circles/src/rank.dart | 25 ++++ lib/modules/circles/src/ring.dart | 10 ++ test/circle_service_test.dart | 82 +++++++++++ test/circles_test.dart | 45 ++++++ tool/check_modules.py | 5 +- tool/test_module.sh | 3 +- wiki/00 최우선 규칙.md | 9 ++ wiki/02 결정 기록.md | 1 + wiki/진행 로그/2026-10-26.md | 1 + 16 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 lib/modules/app/src/circle_service.dart create mode 100644 lib/modules/circles/circles.dart create mode 100644 lib/modules/circles/src/closeness.dart create mode 100644 lib/modules/circles/src/cohort.dart create mode 100644 lib/modules/circles/src/rank.dart create mode 100644 lib/modules/circles/src/ring.dart create mode 100644 test/circle_service_test.dart create mode 100644 test/circles_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 807878c..107505b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,3 +5,4 @@ - 세션을 시작하면 반드시 `wiki/홈.md` → `wiki/00 최우선 규칙.md` 를 먼저 읽고, 필요한 노트(02 결정 기록, 04 구조와 모듈, 05 빌드 · 시험 · 저장소, 가이드/…)를 읽은 뒤 일한다. - 새로 정한 것·한 일은 wiki 에 먼저 쓴다: `wiki/진행 로그/<날짜>.md`, `wiki/02 결정 기록.md`, 화면을 바꾸면 `wiki/06 화면 점검.md`. - wiki 와 다른 문서가 다르면 wiki 가 맞다. +- 사용 모델은 Sonnet 5.5 만 (wiki/00 최우선 규칙). diff --git a/lib/modules/app/app.dart b/lib/modules/app/app.dart index 8974716..d8ecf23 100644 --- a/lib/modules/app/app.dart +++ b/lib/modules/app/app.dart @@ -11,6 +11,7 @@ export 'src/card_service.dart'; export 'src/career_service.dart'; export 'src/change_bus.dart'; export 'src/chat_service.dart'; +export 'src/circle_service.dart'; export 'src/contact_import_service.dart'; export 'src/display.dart'; export 'src/event_service.dart'; diff --git a/lib/modules/app/src/circle_service.dart b/lib/modules/app/src/circle_service.dart new file mode 100644 index 0000000..5b15d76 --- /dev/null +++ b/lib/modules/app/src/circle_service.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; + +import 'package:friendly_map/modules/circles/circles.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import 'call_service.dart'; +import 'change_bus.dart'; +import 'chat_service.dart'; +import 'fields.dart'; +import 'group_service.dart'; +import 'work_service.dart'; + +/// 원형 보기의 한 사람: 가까움 점수, 선배·후배(학교), 직급 관계(회사), 속한 하위 그룹. +class CirclePerson { + const CirclePerson(this.person, {this.score = 0, this.subgroup, this.year, this.cohortDiff, this.position, this.rank, this.rankRelation = RankRelation.unknown}); + final Person person; + final int score; + final String? subgroup; // 보는 그룹 바로 아래 하위 그룹 이름 (반·팀 등) + final int? year; + final int? cohortDiff; // 음수 선배, 0 동기, 양수 후배 + final String? position; + final int? rank; + final RankRelation rankRelation; + + String get cohort => cohortLabel(cohortDiff); +} + +/// 둘레의 한 칸 (초등학교, 한빛전자 …): 사람 수와 가까운 몇 명. +class CircleLevel { + const CircleLevel(this.view, this.count, this.top); + final GroupView view; + final int count; + final List top; +} + +class CircleHub { + const CircleHub(this.root, this.levels, this.closest); + final GroupView? root; + final List levels; + final List closest; // 이 종류 전체에서 가까운 순 +} + +/// 학교·회사 원형 보기 자료와 단체 메시지. +/// +/// 가까움 = 그 사람이 있는 대화방의 대화 수 + 통화 수 × 3. 선배·후배는 받은 학교 명함의 연도와 내 학교 명함 연도, +/// 직급은 받은 회사 명함의 직책과 내 회사 명함 직책으로 정한다. 모두 이 기기 안에서 계산한다. +class CircleService { + CircleService(this._storage, this._clock, this._groups, this._calls, this._chats, this._work, this._bus); + + final Storage _storage; + final Clock _clock; + final GroupService _groups; + final CallService _calls; + final ChatService _chats; + final WorkService _work; + final ChangeBus _bus; + + Future> _messageCounts() async { + final out = {}; + for (final r in await _storage.rooms.all()) { + final n = (await _storage.messages.page(r.id, limit: 1000)).length; + for (final id in (jsonDecode(r.membersJson) as List).whereType()) { + out[id] = (out[id] ?? 0) + n; + } + } + return out; + } + + Future _myField(String kind, String key) async { + for (final c in await _storage.myCards.byKind(kind)) { + final v = decodeFields(c.fieldsJson)[key]; + if (v != null && v.trim().isNotEmpty) return v; + } + return null; + } + + Future _theirField(String personId, String kind, String key) async { + for (final c in await _storage.receivedCards.byPerson(personId)) { + if (c.kind != kind) continue; + final v = decodeFields(c.fieldsJson)[key]; + if (v != null && v.trim().isNotEmpty) return v; + } + return null; + } + + Future _person(Person p, String kind, Map msgs, {String? subgroup, int? myYear, int? myRank}) async { + final calls = _calls.canReadLog ? (await _calls.summary(p.id)).total : 0; + final score = Closeness(p.id, messages: msgs[p.id] ?? 0, calls: calls).score; + if (kind == CardKinds.school) { + final y = parseYear(await _theirField(p.id, CardKinds.school, CardKeys.year)); + return CirclePerson(p, score: score, subgroup: subgroup, year: y, cohortDiff: yearsApart(myYear, y)); + } + if (kind == CardKinds.company) { + final pos = await _theirField(p.id, CardKinds.company, CardKeys.position); + final r = rankOf(pos); + return CirclePerson(p, score: score, subgroup: subgroup, position: pos, rank: r, rankRelation: compareRank(myRank, r)); + } + return CirclePerson(p, score: score, subgroup: subgroup); + } + + Future _myYear() async => parseYear(await _myField(CardKinds.school, CardKeys.year)); + Future _myRank() async => rankOf(await _myField(CardKinds.company, CardKeys.position)); + + /// [groupId] 와 그 아래 모든 사람 (가까운 순). 각 사람에게 바로 아래 하위 그룹 이름을 붙인다. 닫힌 잠긴 그룹이면 빈 목록. + Future> members(String groupId) async { + final v = await _groups.view(groupId); + if (v == null || !v.open) return const []; + final kind = v.group.kind; + final msgs = await _messageCounts(); + final myYear = await _myYear(), myRank = await _myRank(); + final sub = {}; + for (final c in await _groups.children(groupId)) { + for (final p in await _deep(c.group.id)) { + sub.putIfAbsent(p, () => c.group.name); + } + } + final out = []; + for (final id in await _deep(groupId)) { + final p = await _storage.people.get(id); + if (p != null) out.add(await _person(p, kind, msgs, subgroup: sub[id], myYear: myYear, myRank: myRank)); + } + out.sort((a, b) => b.score != a.score ? b.score.compareTo(a.score) : a.person.name.compareTo(b.person.name)); + return out; + } + + /// 그룹과 열린 하위 그룹의 구성원 id. + Future> _deep(String groupId) async { + final out = {}; + Future walk(String id) async { + final ms = await _groups.members(id); + if (ms == null) return; + out.addAll(ms.map((p) => p.id)); + for (final c in await _groups.children(id)) { + await walk(c.group.id); + } + } + + await walk(groupId); + return out; + } + + /// 원형 보기 가운데: [rootId](학교·회사) 아래 칸들과 전체에서 가까운 사람. + Future hub(String rootId, {int topPerLevel = 3, int closest = 8}) async { + final root = await _groups.view(rootId); + if (root == null || !root.open) return CircleHub(root, const [], const []); + final levels = []; + for (final c in await _groups.children(rootId)) { + final ms = await members(c.group.id); + levels.add(CircleLevel(c, ms.length, ms.take(topPerLevel).toList())); + } + final all = await members(rootId); + return CircleHub(root, levels, all.take(closest).toList()); + } + + static const _splitKey = 'view.split.'; + + /// 하위 그룹(반·팀)별로 나눠 볼지 (종류마다 따로, 기본 합쳐 보기). + Future splitView(String kind) async => (await _storage.settings.getText('$_splitKey$kind')) == 'true'; + + Future setSplitView(String kind, bool on) async { + await _storage.settings.putText('$_splitKey$kind', on ? 'true' : 'false', _clock.now()); + _bus.notify(); + } + + /// 단체 메시지: 고른 사람 각자에게 1:1 대화로 보낸다 ([urgent] 면 긴급 호출로). 보낸 사람 수. + Future broadcast(Iterable personIds, String text, {bool urgent = false}) async { + if (text.trim().isEmpty && !urgent) return 0; + var n = 0; + for (final id in personIds.toSet()) { + final p = await _storage.people.get(id); + if (p == null || p.phone.startsWith('local:')) continue; + final room = await _chats.openDirect(id, CardKinds.personal); + if (room == null) continue; + final m = urgent ? await _work.urgent(room.id, text) : await _chats.send(room.id, text); + if (m != null) n++; + } + return n; + } +} diff --git a/lib/modules/app/src/friendly_app.dart b/lib/modules/app/src/friendly_app.dart index 7c0a42c..b64eaeb 100644 --- a/lib/modules/app/src/friendly_app.dart +++ b/lib/modules/app/src/friendly_app.dart @@ -10,6 +10,7 @@ import 'career_service.dart'; import 'change_bus.dart'; import 'call_service.dart'; import 'chat_service.dart'; +import 'circle_service.dart'; import 'contact_import_service.dart'; import 'event_service.dart'; import 'facts_service.dart'; @@ -68,6 +69,7 @@ class FriendlyApp { career = CareerService(storage, clock, groups, relate, network, bus); blocks = BlockService(storage, clock, friends, bus); familyTree = FamilyTreeService(storage, kin, facts, events); + circles = CircleService(storage, clock, groups, calls, chats, work, bus); } final AppDeps deps; @@ -95,6 +97,7 @@ class FriendlyApp { late final CareerService career; late final BlockService blocks; late final FamilyTreeService familyTree; + late final CircleService circles; StreamSubscription? _events; final Set> _tasks = {}; diff --git a/lib/modules/circles/circles.dart b/lib/modules/circles/circles.dart new file mode 100644 index 0000000..9ab0933 --- /dev/null +++ b/lib/modules/circles/circles.dart @@ -0,0 +1,10 @@ +/// 원형 보기: 가까움 순위, 선배·후배(기수), 직급 순서, 원 위의 자리 계산 +/// +/// 학교·회사 화면이 같이 쓰는 순수 계산이다(저장·화면 없음). 어떤 모듈도 쓰지 않는다. +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/closeness.dart'; +export 'src/cohort.dart'; +export 'src/rank.dart'; +export 'src/ring.dart'; diff --git a/lib/modules/circles/src/closeness.dart b/lib/modules/circles/src/closeness.dart new file mode 100644 index 0000000..250cc6b --- /dev/null +++ b/lib/modules/circles/src/closeness.dart @@ -0,0 +1,31 @@ +/// 한 사람과 얼마나 가까운지: 나눈 대화 수와 통화 수. 통화는 대화보다 무겁게 친다. +class Closeness { + const Closeness(this.id, {this.messages = 0, this.calls = 0}); + final String id; + final int messages; + final int calls; + + static const callWeight = 3; + + int get score => messages + calls * callWeight; +} + +/// 가까운 순서 (점수가 같으면 id 순으로 늘 같은 순서). +List rankByCloseness(Iterable list) => list.toList() + ..sort((a, b) { + final s = b.score.compareTo(a.score); + return s != 0 ? s : a.id.compareTo(b.id); + }); + +/// 원 안쪽부터 바깥쪽으로 나눈다: 첫 고리 [first] 명, 다음 고리마다 [step] 명씩 더. +List> rings(List ordered, {int first = 6, int step = 6}) { + final out = >[]; + var i = 0, size = first; + while (i < ordered.length) { + final end = (i + size).clamp(0, ordered.length); + out.add(ordered.sublist(i, end)); + i = end; + size += step; + } + return out; +} diff --git a/lib/modules/circles/src/cohort.dart b/lib/modules/circles/src/cohort.dart new file mode 100644 index 0000000..7951b28 --- /dev/null +++ b/lib/modules/circles/src/cohort.dart @@ -0,0 +1,26 @@ +/// 같은 학교에서 나와 몇 년 차이인지. 연도는 입학(또는 졸업) 연도로, 먼저인 쪽이 선배다. +/// 음수면 선배, 0 이면 동기, 양수면 후배. 둘 중 하나라도 모르면 null. +int? yearsApart(int? mine, int? theirs) => (mine == null || theirs == null) ? null : theirs - mine; + +/// "3년 선배", "동기", "1년 후배", "연도 모름" +String cohortLabel(int? diff) { + if (diff == null) return '연도 모름'; + if (diff == 0) return '동기'; + return diff < 0 ? '${-diff}년 선배' : '$diff년 후배'; +} + +/// 연차별 묶음 순서: 선배(먼 쪽부터) → 동기 → 후배 → 연도 모름. +int cohortOrder(int? diff) => diff == null ? 1 << 20 : diff; + +/// 글자에서 연도를 읽는다 ("2009", "09학번", "27회" 는 회차라 못 읽음). 못 읽으면 null. +int? parseYear(String? text) { + if (text == null) return null; + final four = RegExp(r'(19|20)\d{2}').firstMatch(text); + if (four != null) return int.parse(four.group(0)!); + final two = RegExp(r'(\d{2})\s*학번').firstMatch(text); + if (two != null) { + final y = int.parse(two.group(1)!); + return y >= 50 ? 1900 + y : 2000 + y; + } + return null; +} diff --git a/lib/modules/circles/src/rank.dart b/lib/modules/circles/src/rank.dart new file mode 100644 index 0000000..f8ea556 --- /dev/null +++ b/lib/modules/circles/src/rank.dart @@ -0,0 +1,25 @@ +/// 회사 직급 순서. 숫자가 클수록 윗사람. 모르는 직급은 null. +const _ranks = { + '인턴': 0, '사원': 1, '주임': 2, '선임': 2, '대리': 3, '과장': 4, '책임': 4, '차장': 5, '수석': 5, + '팀장': 6, '부장': 6, '실장': 7, '이사': 8, '상무': 9, '전무': 10, '부사장': 11, '사장': 12, '대표': 13, '부회장': 13, '회장': 14, +}; + +/// 직책 글자에서 직급을 찾는다 ("결제팀 과장" → 4). 여러 개면 가장 높은 것. +int? rankOf(String? position) { + if (position == null || position.trim().isEmpty) return null; + int? best; + for (final e in _ranks.entries) { + if (position.contains(e.key) && (best == null || e.value > best)) best = e.value; + } + return best; +} + +enum RankRelation { above, same, below, unknown } + +/// [mine] 직급에서 본 [theirs]: 윗사람·같은 급·아래 직원. +RankRelation compareRank(int? mine, int? theirs) { + if (mine == null || theirs == null) return RankRelation.unknown; + if (theirs > mine) return RankRelation.above; + if (theirs < mine) return RankRelation.below; + return RankRelation.same; +} diff --git a/lib/modules/circles/src/ring.dart b/lib/modules/circles/src/ring.dart new file mode 100644 index 0000000..ce703b9 --- /dev/null +++ b/lib/modules/circles/src/ring.dart @@ -0,0 +1,10 @@ +import 'dart:math'; + +/// 원 위에 [n] 개를 고르게 놓는 자리 (가운데가 0,0). 12시 방향부터 시계 방향. +List<(double, double)> ringPositions(int n, double radius, {double startDeg = -90}) => [ + for (var i = 0; i < n; i++) + ( + radius * cos((startDeg + 360 * i / max(n, 1)) * pi / 180), + radius * sin((startDeg + 360 * i / max(n, 1)) * pi / 180), + ), + ]; diff --git a/test/circle_service_test.dart b/test/circle_service_test.dart new file mode 100644 index 0000000..fe58f68 --- /dev/null +++ b/test/circle_service_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/circles/circles.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; +import 'package:friendly_map/modules/work/work.dart'; + +import 'support/app_device.dart'; + +void main() { + late AppDevice me; + late FriendlyApp app; + late Person a, b, c; + + Future card(Person p, String kind, Map f) => app.deps.storage.receivedCards.put( + ReceivedCard(id: 'rc-${p.id}-$kind', personId: p.id, kind: kind, fieldsJson: encodeFields(f), receivedAt: 1, updatedAt: 1)); + + setUp(() async { + me = await AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '나'); + app = me.app; + a = (await app.friends.add('+821033334444', '김선배'))!; + b = (await app.friends.add('+821055556666', '이동기'))!; + c = (await app.friends.add('+821077778888', '박후배'))!; + final mine = (await app.cards.mine()); + await app.cards.edit(mine.firstWhere((x) => x.kind == CardKinds.school).id, fields: {'name': '나', 'school': '가람고', 'year': '2006'}); + await app.cards.edit(mine.firstWhere((x) => x.kind == CardKinds.company).id, fields: {'name': '나', 'company': '한빛', 'position': '과장'}); + await card(a, CardKinds.school, {'year': '2004'}); + await card(b, CardKinds.school, {'year': '2006'}); + await card(c, CardKinds.school, {'year': '2007년 입학'}); + final cls = (await app.groups.add('27회 3반', CardKinds.school, parent: 'school.high'))!; + await app.groups.addMembers('school.high', [b.id, c.id]); + await app.groups.addMembers(cls.id, [a.id]); + final room = (await app.chats.openDirect(c.id, CardKinds.personal))!; + for (var i = 0; i < 3; i++) { + await app.chats.send(room.id, '안녕 $i'); + } + }); + + test('구성원은 가까운 순(대화 많은 사람 먼저), 선배·동기·후배와 하위 그룹(반)이 붙는다', () async { + final ms = await app.circles.members('school.high'); + expect(ms.first.person.id, c.id, reason: '대화 3번'); + final by = {for (final m in ms) m.person.name: m}; + expect(by['김선배']!.cohort, '2년 선배'); + expect(by['이동기']!.cohort, '동기'); + expect(by['박후배']!.cohort, '1년 후배'); + expect(by['김선배']!.subgroup, '27회 3반'); + expect(by['이동기']!.subgroup, isNull); + }); + + test('학교 가운데: 초·중·고·대·대학원 칸과 사람 수, 전체에서 가까운 사람', () async { + final hub = await app.circles.hub(CardKinds.school); + expect(hub.levels.map((l) => l.view.group.name), ['초등학교', '중학교', '고등학교', '대학교', '대학원']); + expect(hub.levels.firstWhere((l) => l.view.group.name == '고등학교').count, 3); + expect(hub.closest.first.person.id, c.id); + }); + + test('회사: 직책으로 윗사람·같은 급·아래 직원', () async { + await card(a, CardKinds.company, {'position': '부장'}); + await card(b, CardKinds.company, {'position': '과장'}); + await card(c, CardKinds.company, {'position': '사원'}); + final hb = (await app.groups.add('한빛', CardKinds.company, parent: CardKinds.company))!; + await app.groups.addMembers(hb.id, [a.id, b.id, c.id]); + final rel = {for (final m in await app.circles.members(hb.id)) m.person.name: m.rankRelation}; + expect(rel, {'김선배': RankRelation.above, '이동기': RankRelation.same, '박후배': RankRelation.below}); + }); + + test('단체 메시지: 고른 사람 각자에게, 긴급이면 긴급 호출로', () async { + expect(await app.circles.broadcast([a.id, b.id, a.id], '동창회 오늘 7시'), 2); + expect(await app.circles.broadcast([b.id], '모임 장소 바뀜', urgent: true), 1); + final rooms = await app.chats.summaries(); + final last = rooms.firstWhere((r) => r.room.title == '이동기').last!; + expect(WorkNote.parse(last.body), isA()); + }); + + test('나눠 보기 설정은 종류마다 따로', () async { + expect(await app.circles.splitView(CardKinds.school), isFalse); + await app.circles.setSplitView(CardKinds.school, true); + expect(await app.circles.splitView(CardKinds.school), isTrue); + expect(await app.circles.splitView(CardKinds.company), isFalse); + }); +} diff --git a/test/circles_test.dart b/test/circles_test.dart new file mode 100644 index 0000000..9964e4a --- /dev/null +++ b/test/circles_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/circles/circles.dart'; + +void main() { + test('가까움: 통화는 대화의 3배, 같은 점수는 id 순', () { + final r = rankByCloseness(const [Closeness('a', messages: 5), Closeness('b', calls: 2), Closeness('c', messages: 6), Closeness('d')]); + expect(r.map((c) => c.id), ['b', 'c', 'a', 'd']); + expect(const Closeness('b', calls: 2).score, 6); + }); + + test('고리 나누기: 6명, 12명, 18명 …', () { + final g = rings(List.generate(20, (i) => i)); + expect(g.map((x) => x.length), [6, 12, 2]); + expect(rings([]), isEmpty); + }); + + test('선배·동기·후배와 연도 읽기', () { + expect(cohortLabel(yearsApart(2006, 2004)), '2년 선배'); + expect(cohortLabel(yearsApart(2006, 2006)), '동기'); + expect(cohortLabel(yearsApart(2006, 2007)), '1년 후배'); + expect(cohortLabel(yearsApart(null, 2007)), '연도 모름'); + expect(parseYear('2009년 입학'), 2009); + expect(parseYear('09학번'), 2009); + expect(parseYear('98학번'), 1998); + expect(parseYear('27회'), isNull); + expect([3, null, -2, 0].map(cohortOrder).toList()..sort(), [-2, 0, 3, 1 << 20]); + }); + + test('직급: 글자에서 찾고 윗사람·아래 직원 가르기', () { + expect(rankOf('결제팀 과장'), 4); + expect(rankOf('팀장 겸 부장'), 6); + expect(rankOf('개발자'), isNull); + expect(compareRank(4, 6), RankRelation.above); + expect(compareRank(4, 3), RankRelation.below); + expect(compareRank(4, 4), RankRelation.same); + expect(compareRank(null, 4), RankRelation.unknown); + }); + + test('원 위 자리: 12시부터 시계 방향', () { + final p = ringPositions(4, 10); + expect(p[0].$1, closeTo(0, 1e-9)); + expect(p[0].$2, closeTo(-10, 1e-9)); + expect(p[1].$1, closeTo(10, 1e-9)); + }); +} diff --git a/tool/check_modules.py b/tool/check_modules.py index 26805f3..cf66de8 100644 --- a/tool/check_modules.py +++ b/tool/check_modules.py @@ -20,9 +20,10 @@ ALLOW = { "kinship": set(), "groups": {"core"}, "work": set(), + "circles": set(), "events": set(), - "app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work"}, - "ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work"}, + "app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work", "circles"}, + "ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work", "circles"}, } # 모듈 입구가 둘 이상인 경우: 입구 파일이 모듈 이름.dart 외에 허용되는 것들. EXTRA_BARRELS = {"platform": {"platform_ui.dart"}, "storage": {"storage_sqlite.dart"}} diff --git a/tool/test_module.sh b/tool/test_module.sh index a50e500..1b1321d 100644 --- a/tool/test_module.sh +++ b/tool/test_module.sh @@ -9,6 +9,7 @@ declare -A T=( [kinship]="kinship_test.dart kinship_side_test.dart family_tree_test.dart" [groups]="groups_test.dart" [work]="work_test.dart" + [circles]="circles_test.dart" [events]="events_test.dart lunar_test.dart" [storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart" [transport]="transport_test.dart" @@ -18,7 +19,7 @@ declare -A T=( [inference]="inference_test.dart" [layout]="layout_test.dart" [sharing]="sharing_service_test.dart sharing_status_test.dart" - [app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart relate_service_test.dart work_service_test.dart career_service_test.dart reconnect_display_test.dart" + [app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart relate_service_test.dart work_service_test.dart career_service_test.dart reconnect_display_test.dart circle_service_test.dart" [platform_android]="df_parser_test.dart" [ui]="widget_test.dart" ) diff --git a/wiki/00 최우선 규칙.md b/wiki/00 최우선 규칙.md index 94f3ced..fa5692f 100644 --- a/wiki/00 최우선 규칙.md +++ b/wiki/00 최우선 규칙.md @@ -1,5 +1,6 @@ # 최우선 규칙 (모든 규칙보다 위) +- **사용 모델: Claude Sonnet 5.5(`claude-sonnet-5-5`)만 쓴다.** 사용자가 따로 지시하지 않는 한 다른 모델(Opus·Haiku 등)로 바꾸거나 하위 작업에 쓰지 않는다. (2026-10-26 사용자 지시) - **0순위: 모든 가이드·문서·결정·진행 기록·화면 점검은 이 wiki 보관함에 저장·관리한다.** 세션 시작 때 홈과 이 문서부터 읽고, 일할 때마다 진행 로그·결정 기록을 갱신한다. - **질문은 설치 가능한 APK 를 먼저 건넨 뒤에만.** 사용자가 폰에 설치해 써 보며 답할 수 있게. 캡처도 함께 보여 주고 "이렇게 하면 / 저렇게 하면" 화면 비교로만 묻는다. @@ -28,3 +29,11 @@ - 출력물은 개발의 경우 실행파일 등이 실행가능해야된 이를 실행하여 보여주거나 사용자에게 직접 실행햐서 보여줄수 있어야한다. - 산출물일경우 PPT등을 보여주고 상황 설명을 하고 지시를 받아야 한다. +## 테스트 규칙 +- 위 모듈화가 정확하게 되었으면 모듈에 대한 테스트가 완료되면 2번이상 테스트 할필요가 없아야함 +- 즉 2번이상 테스트 할필요 없이 모듈화가 되어야 함 + +## 화면 출력 +- 모든 명령의 처음에는 모듈에 대한 몇 수정 릭스트 +- 전체 항목애 대한 완료 유무 및 및 작업 시작시간 종료시간 표시 +- 산출물 위치 (실행파일일 경우 실행파일 위치 출력물일 경우 출력물 위치 및 목록을 보여줌) \ No newline at end of file diff --git a/wiki/02 결정 기록.md b/wiki/02 결정 기록.md index e5cc19c..299477b 100644 --- a/wiki/02 결정 기록.md +++ b/wiki/02 결정 기록.md @@ -23,3 +23,4 @@ | 2026-10-26 | 아래 메뉴: 채팅(첫 화면) · 관계 · 업무 · 명함 · 나. 옛 홈 타일·중복 화면 삭제 | UX 점검 결과 | | 2026-10-26 | 질문은 APK·화면을 먼저 보여 준 뒤에만 | 사용자 최우선 지시 | | 2026-10-26 | 가계도: 세대 줄 방식(위 조상·옆 배우자·아래 자식), 친가 왼쪽·외가/처가 오른쪽, 부부는 남자 왼쪽, 처가 족보 = 배우자 기준으로 다시 보기 | 사용자 요구(01 제품 개요) | +| 2026-10-26 | 사용 모델은 Sonnet 5.5 만(특별한 지시가 없는 한) | 사용자 지시 | diff --git a/wiki/진행 로그/2026-10-26.md b/wiki/진행 로그/2026-10-26.md index 41b77ca..e3acab3 100644 --- a/wiki/진행 로그/2026-10-26.md +++ b/wiki/진행 로그/2026-10-26.md @@ -16,3 +16,4 @@ - README·CLAUDE.md·server/README 의 내용을 wiki 로 모두 옮기고, 세 파일에는 "무조건 wiki 참조" 지시만 남김 - wiki 를 읽고 사용자 추가 요구 4가지(가계도, 학교 원형, 회사 원형·직급 메시지, 이직 이력)와 용어 규칙(낯선 외래어·한자어 금지)을 확인 → [[03 로드맵]] 5~8단계로 등록 - 5단계 가계도 화면: kinship/tree.dart(세대 줄 계산), FamilyTreeService, FamilyTreeScreen. 시험 317개 통과 → [[06 화면 점검]] +- 21:20 사용 모델을 Sonnet 5.5 로 고정하는 규칙 추가. 6단계는 circles 모듈·CircleService·시험까지 됨 → 다음은 학교 원형 화면(ui)부터