사용 모델은 Sonnet 5.5 만 (wiki 규칙), 6단계 진행분: circles 모듈·CircleService
- wiki/00 최우선 규칙: 특별한 지시가 없는 한 Sonnet 5.5 만 사용 - wiki/00: 사용자 작성 질문·테스트·화면 출력 규칙 반영 - circles(순수): 가까움 순위·고리 나누기, 선배·후배, 직급 순서, 원 위 자리 / CircleService: 원형 보기 자료·단체 메시지 (시험 10개) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
e32bf36da0
commit
f860fd4db4
|
|
@ -5,3 +5,4 @@
|
||||||
- 세션을 시작하면 반드시 `wiki/홈.md` → `wiki/00 최우선 규칙.md` 를 먼저 읽고, 필요한 노트(02 결정 기록, 04 구조와 모듈, 05 빌드 · 시험 · 저장소, 가이드/…)를 읽은 뒤 일한다.
|
- 세션을 시작하면 반드시 `wiki/홈.md` → `wiki/00 최우선 규칙.md` 를 먼저 읽고, 필요한 노트(02 결정 기록, 04 구조와 모듈, 05 빌드 · 시험 · 저장소, 가이드/…)를 읽은 뒤 일한다.
|
||||||
- 새로 정한 것·한 일은 wiki 에 먼저 쓴다: `wiki/진행 로그/<날짜>.md`, `wiki/02 결정 기록.md`, 화면을 바꾸면 `wiki/06 화면 점검.md`.
|
- 새로 정한 것·한 일은 wiki 에 먼저 쓴다: `wiki/진행 로그/<날짜>.md`, `wiki/02 결정 기록.md`, 화면을 바꾸면 `wiki/06 화면 점검.md`.
|
||||||
- wiki 와 다른 문서가 다르면 wiki 가 맞다.
|
- wiki 와 다른 문서가 다르면 wiki 가 맞다.
|
||||||
|
- 사용 모델은 Sonnet 5.5 만 (wiki/00 최우선 규칙).
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export 'src/card_service.dart';
|
||||||
export 'src/career_service.dart';
|
export 'src/career_service.dart';
|
||||||
export 'src/change_bus.dart';
|
export 'src/change_bus.dart';
|
||||||
export 'src/chat_service.dart';
|
export 'src/chat_service.dart';
|
||||||
|
export 'src/circle_service.dart';
|
||||||
export 'src/contact_import_service.dart';
|
export 'src/contact_import_service.dart';
|
||||||
export 'src/display.dart';
|
export 'src/display.dart';
|
||||||
export 'src/event_service.dart';
|
export 'src/event_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<CirclePerson> top;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CircleHub {
|
||||||
|
const CircleHub(this.root, this.levels, this.closest);
|
||||||
|
final GroupView? root;
|
||||||
|
final List<CircleLevel> levels;
|
||||||
|
final List<CirclePerson> 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<Map<String, int>> _messageCounts() async {
|
||||||
|
final out = <String, int>{};
|
||||||
|
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<String>()) {
|
||||||
|
out[id] = (out[id] ?? 0) + n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _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<String?> _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<CirclePerson> _person(Person p, String kind, Map<String, int> 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<int?> _myYear() async => parseYear(await _myField(CardKinds.school, CardKeys.year));
|
||||||
|
Future<int?> _myRank() async => rankOf(await _myField(CardKinds.company, CardKeys.position));
|
||||||
|
|
||||||
|
/// [groupId] 와 그 아래 모든 사람 (가까운 순). 각 사람에게 바로 아래 하위 그룹 이름을 붙인다. 닫힌 잠긴 그룹이면 빈 목록.
|
||||||
|
Future<List<CirclePerson>> 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 = <String, String>{};
|
||||||
|
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 = <CirclePerson>[];
|
||||||
|
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<Set<String>> _deep(String groupId) async {
|
||||||
|
final out = <String>{};
|
||||||
|
Future<void> 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<CircleHub> 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 = <CircleLevel>[];
|
||||||
|
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<bool> splitView(String kind) async => (await _storage.settings.getText('$_splitKey$kind')) == 'true';
|
||||||
|
|
||||||
|
Future<void> setSplitView(String kind, bool on) async {
|
||||||
|
await _storage.settings.putText('$_splitKey$kind', on ? 'true' : 'false', _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 단체 메시지: 고른 사람 각자에게 1:1 대화로 보낸다 ([urgent] 면 긴급 호출로). 보낸 사람 수.
|
||||||
|
Future<int> broadcast(Iterable<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import 'career_service.dart';
|
||||||
import 'change_bus.dart';
|
import 'change_bus.dart';
|
||||||
import 'call_service.dart';
|
import 'call_service.dart';
|
||||||
import 'chat_service.dart';
|
import 'chat_service.dart';
|
||||||
|
import 'circle_service.dart';
|
||||||
import 'contact_import_service.dart';
|
import 'contact_import_service.dart';
|
||||||
import 'event_service.dart';
|
import 'event_service.dart';
|
||||||
import 'facts_service.dart';
|
import 'facts_service.dart';
|
||||||
|
|
@ -68,6 +69,7 @@ class FriendlyApp {
|
||||||
career = CareerService(storage, clock, groups, relate, network, bus);
|
career = CareerService(storage, clock, groups, relate, network, bus);
|
||||||
blocks = BlockService(storage, clock, friends, bus);
|
blocks = BlockService(storage, clock, friends, bus);
|
||||||
familyTree = FamilyTreeService(storage, kin, facts, events);
|
familyTree = FamilyTreeService(storage, kin, facts, events);
|
||||||
|
circles = CircleService(storage, clock, groups, calls, chats, work, bus);
|
||||||
}
|
}
|
||||||
|
|
||||||
final AppDeps deps;
|
final AppDeps deps;
|
||||||
|
|
@ -95,6 +97,7 @@ class FriendlyApp {
|
||||||
late final CareerService career;
|
late final CareerService career;
|
||||||
late final BlockService blocks;
|
late final BlockService blocks;
|
||||||
late final FamilyTreeService familyTree;
|
late final FamilyTreeService familyTree;
|
||||||
|
late final CircleService circles;
|
||||||
|
|
||||||
StreamSubscription<SyncEvent>? _events;
|
StreamSubscription<SyncEvent>? _events;
|
||||||
final Set<Future<void>> _tasks = {};
|
final Set<Future<void>> _tasks = {};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
/// 원형 보기: 가까움 순위, 선배·후배(기수), 직급 순서, 원 위의 자리 계산
|
||||||
|
///
|
||||||
|
/// 학교·회사 화면이 같이 쓰는 순수 계산이다(저장·화면 없음). 어떤 모듈도 쓰지 않는다.
|
||||||
|
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/closeness.dart';
|
||||||
|
export 'src/cohort.dart';
|
||||||
|
export 'src/rank.dart';
|
||||||
|
export 'src/ring.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<Closeness> rankByCloseness(Iterable<Closeness> 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<List<T>> rings<T>(List<T> ordered, {int first = 6, int step = 6}) {
|
||||||
|
final out = <List<T>>[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/// 회사 직급 순서. 숫자가 클수록 윗사람. 모르는 직급은 null.
|
||||||
|
const _ranks = <String, int>{
|
||||||
|
'인턴': 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;
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
@ -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<void> card(Person p, String kind, Map<String, String> 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<UrgentNote>());
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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(<int>[]), 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -20,9 +20,10 @@ ALLOW = {
|
||||||
"kinship": set(),
|
"kinship": set(),
|
||||||
"groups": {"core"},
|
"groups": {"core"},
|
||||||
"work": set(),
|
"work": set(),
|
||||||
|
"circles": set(),
|
||||||
"events": set(),
|
"events": set(),
|
||||||
"app": {"core", "platform", "storage", "sharing", "sync", "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"},
|
"ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work", "circles"},
|
||||||
}
|
}
|
||||||
# 모듈 입구가 둘 이상인 경우: 입구 파일이 모듈 이름.dart 외에 허용되는 것들.
|
# 모듈 입구가 둘 이상인 경우: 입구 파일이 모듈 이름.dart 외에 허용되는 것들.
|
||||||
EXTRA_BARRELS = {"platform": {"platform_ui.dart"}, "storage": {"storage_sqlite.dart"}}
|
EXTRA_BARRELS = {"platform": {"platform_ui.dart"}, "storage": {"storage_sqlite.dart"}}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ declare -A T=(
|
||||||
[kinship]="kinship_test.dart kinship_side_test.dart family_tree_test.dart"
|
[kinship]="kinship_test.dart kinship_side_test.dart family_tree_test.dart"
|
||||||
[groups]="groups_test.dart"
|
[groups]="groups_test.dart"
|
||||||
[work]="work_test.dart"
|
[work]="work_test.dart"
|
||||||
|
[circles]="circles_test.dart"
|
||||||
[events]="events_test.dart lunar_test.dart"
|
[events]="events_test.dart lunar_test.dart"
|
||||||
[storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart"
|
[storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart"
|
||||||
[transport]="transport_test.dart"
|
[transport]="transport_test.dart"
|
||||||
|
|
@ -18,7 +19,7 @@ declare -A T=(
|
||||||
[inference]="inference_test.dart"
|
[inference]="inference_test.dart"
|
||||||
[layout]="layout_test.dart"
|
[layout]="layout_test.dart"
|
||||||
[sharing]="sharing_service_test.dart sharing_status_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"
|
[platform_android]="df_parser_test.dart"
|
||||||
[ui]="widget_test.dart"
|
[ui]="widget_test.dart"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# 최우선 규칙 (모든 규칙보다 위)
|
# 최우선 규칙 (모든 규칙보다 위)
|
||||||
|
|
||||||
|
- **사용 모델: Claude Sonnet 5.5(`claude-sonnet-5-5`)만 쓴다.** 사용자가 따로 지시하지 않는 한 다른 모델(Opus·Haiku 등)로 바꾸거나 하위 작업에 쓰지 않는다. (2026-10-26 사용자 지시)
|
||||||
- **0순위: 모든 가이드·문서·결정·진행 기록·화면 점검은 이 wiki 보관함에 저장·관리한다.** 세션 시작 때 홈과 이 문서부터 읽고, 일할 때마다 진행 로그·결정 기록을 갱신한다.
|
- **0순위: 모든 가이드·문서·결정·진행 기록·화면 점검은 이 wiki 보관함에 저장·관리한다.** 세션 시작 때 홈과 이 문서부터 읽고, 일할 때마다 진행 로그·결정 기록을 갱신한다.
|
||||||
|
|
||||||
- **질문은 설치 가능한 APK 를 먼저 건넨 뒤에만.** 사용자가 폰에 설치해 써 보며 답할 수 있게. 캡처도 함께 보여 주고 "이렇게 하면 / 저렇게 하면" 화면 비교로만 묻는다.
|
- **질문은 설치 가능한 APK 를 먼저 건넨 뒤에만.** 사용자가 폰에 설치해 써 보며 답할 수 있게. 캡처도 함께 보여 주고 "이렇게 하면 / 저렇게 하면" 화면 비교로만 묻는다.
|
||||||
|
|
@ -28,3 +29,11 @@
|
||||||
- 출력물은 개발의 경우 실행파일 등이 실행가능해야된 이를 실행하여 보여주거나 사용자에게 직접 실행햐서 보여줄수 있어야한다.
|
- 출력물은 개발의 경우 실행파일 등이 실행가능해야된 이를 실행하여 보여주거나 사용자에게 직접 실행햐서 보여줄수 있어야한다.
|
||||||
- 산출물일경우 PPT등을 보여주고 상황 설명을 하고 지시를 받아야 한다.
|
- 산출물일경우 PPT등을 보여주고 상황 설명을 하고 지시를 받아야 한다.
|
||||||
|
|
||||||
|
## 테스트 규칙
|
||||||
|
- 위 모듈화가 정확하게 되었으면 모듈에 대한 테스트가 완료되면 2번이상 테스트 할필요가 없아야함
|
||||||
|
- 즉 2번이상 테스트 할필요 없이 모듈화가 되어야 함
|
||||||
|
|
||||||
|
## 화면 출력
|
||||||
|
- 모든 명령의 처음에는 모듈에 대한 몇 수정 릭스트
|
||||||
|
- 전체 항목애 대한 완료 유무 및 및 작업 시작시간 종료시간 표시
|
||||||
|
- 산출물 위치 (실행파일일 경우 실행파일 위치 출력물일 경우 출력물 위치 및 목록을 보여줌)
|
||||||
|
|
@ -23,3 +23,4 @@
|
||||||
| 2026-10-26 | 아래 메뉴: 채팅(첫 화면) · 관계 · 업무 · 명함 · 나. 옛 홈 타일·중복 화면 삭제 | UX 점검 결과 |
|
| 2026-10-26 | 아래 메뉴: 채팅(첫 화면) · 관계 · 업무 · 명함 · 나. 옛 홈 타일·중복 화면 삭제 | UX 점검 결과 |
|
||||||
| 2026-10-26 | 질문은 APK·화면을 먼저 보여 준 뒤에만 | 사용자 최우선 지시 |
|
| 2026-10-26 | 질문은 APK·화면을 먼저 보여 준 뒤에만 | 사용자 최우선 지시 |
|
||||||
| 2026-10-26 | 가계도: 세대 줄 방식(위 조상·옆 배우자·아래 자식), 친가 왼쪽·외가/처가 오른쪽, 부부는 남자 왼쪽, 처가 족보 = 배우자 기준으로 다시 보기 | 사용자 요구(01 제품 개요) |
|
| 2026-10-26 | 가계도: 세대 줄 방식(위 조상·옆 배우자·아래 자식), 친가 왼쪽·외가/처가 오른쪽, 부부는 남자 왼쪽, 처가 족보 = 배우자 기준으로 다시 보기 | 사용자 요구(01 제품 개요) |
|
||||||
|
| 2026-10-26 | 사용 모델은 Sonnet 5.5 만(특별한 지시가 없는 한) | 사용자 지시 |
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,4 @@
|
||||||
- README·CLAUDE.md·server/README 의 내용을 wiki 로 모두 옮기고, 세 파일에는 "무조건 wiki 참조" 지시만 남김
|
- README·CLAUDE.md·server/README 의 내용을 wiki 로 모두 옮기고, 세 파일에는 "무조건 wiki 참조" 지시만 남김
|
||||||
- wiki 를 읽고 사용자 추가 요구 4가지(가계도, 학교 원형, 회사 원형·직급 메시지, 이직 이력)와 용어 규칙(낯선 외래어·한자어 금지)을 확인 → [[03 로드맵]] 5~8단계로 등록
|
- wiki 를 읽고 사용자 추가 요구 4가지(가계도, 학교 원형, 회사 원형·직급 메시지, 이직 이력)와 용어 규칙(낯선 외래어·한자어 금지)을 확인 → [[03 로드맵]] 5~8단계로 등록
|
||||||
- 5단계 가계도 화면: kinship/tree.dart(세대 줄 계산), FamilyTreeService, FamilyTreeScreen. 시험 317개 통과 → [[06 화면 점검]]
|
- 5단계 가계도 화면: kinship/tree.dart(세대 줄 계산), FamilyTreeService, FamilyTreeScreen. 시험 317개 통과 → [[06 화면 점검]]
|
||||||
|
- 21:20 사용 모델을 Sonnet 5.5 로 고정하는 규칙 추가. 6단계는 circles 모듈·CircleService·시험까지 됨 → 다음은 학교 원형 화면(ui)부터
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue