앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산
This commit is contained in:
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# 퀘스트 참여 실왕복 — 에뮬레이터 세 개를 물고 기기에서 한 바퀴 돈다.
|
||||
#
|
||||
# `firebase emulators:exec` 가 이 스크립트를 실행한다. 먼저 시드를 넣고(보안 규칙이
|
||||
# `quests` 쓰기를 마스터로 막아 앱이 만들 수 없다) 통합 테스트를 돌린다.
|
||||
#
|
||||
# cd firebase
|
||||
# JAVA_HOME=... test/node_modules/.bin/firebase emulators:exec \
|
||||
# --only auth,firestore,functions --project demo-fanuzu \
|
||||
# "bash ../tool/emulator_quest_roundtrip.sh"
|
||||
set -euo pipefail
|
||||
|
||||
DEVICE="${DEVICE:-emulator-5554}"
|
||||
HOST="${FANUZU_EMULATOR_HOST:-127.0.0.1}"
|
||||
# 앱이 쓰는 프로젝트 ID. 설정 파일이 들어간 빌드는 `[DEFAULT]` 앱을 그대로 쓰므로
|
||||
# 실 프로젝트 ID 로 붙는다. 에뮬레이터는 프로젝트별로 칸이 나뉘어 시드도 같은 ID 에 넣는다.
|
||||
PROJECT="${FANUZU_EMULATOR_PROJECT:-fanuzu-dev-b020d}"
|
||||
BASE="http://127.0.0.1:8080/v1/projects/${PROJECT}/databases/(default)/documents"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
echo "▸ 시드 — 퀘스트 정의와 운영값 (${PROJECT})"
|
||||
# 에뮬레이터는 `Bearer owner` 토큰을 관리자로 본다. 이 헤더가 없으면 보안 규칙이
|
||||
# 그대로 적용돼 `quests` 쓰기가 막히고(마스터 전용) 시드가 조용히 사라진다.
|
||||
AUTH=(-H 'Authorization: Bearer owner' -H 'Content-Type: application/json')
|
||||
curl -sf -X PATCH "${BASE}/quests/daily-checkin" "${AUTH[@]}" -d '{
|
||||
"fields": {
|
||||
"type": {"stringValue": "attend"},
|
||||
"title": {"stringValue": "출석 체크"},
|
||||
"rewardPop": {"integerValue": "10"},
|
||||
"dailyLimit": {"integerValue": "1"},
|
||||
"active": {"booleanValue": true}
|
||||
}
|
||||
}' > /dev/null
|
||||
curl -sf -X PATCH "${BASE}/config/policy" "${AUTH[@]}" -d '{
|
||||
"fields": {
|
||||
"attendPop": {"integerValue": "10"},
|
||||
"stepTiers": {"arrayValue": {"values": [
|
||||
{"mapValue": {"fields": {"steps": {"integerValue": "1000"}, "pop": {"integerValue": "5"}}}},
|
||||
{"mapValue": {"fields": {"steps": {"integerValue": "5000"}, "pop": {"integerValue": "6"}}}},
|
||||
{"mapValue": {"fields": {"steps": {"integerValue": "10000"}, "pop": {"integerValue": "7"}}}},
|
||||
{"mapValue": {"fields": {"steps": {"integerValue": "15000"}, "pop": {"integerValue": "8"}}}}
|
||||
]}}
|
||||
}
|
||||
}' > /dev/null
|
||||
# 시드가 실제로 섰는지 본다. 없으면 여기서 끊는다 — 목록이 빈 채로 테스트가 돌면
|
||||
# 「참여 버튼이 없다」로만 보여 원인을 찾는 데 시간이 든다.
|
||||
curl -sf "${BASE}/quests/daily-checkin" "${AUTH[@]}" | grep -q '"attend"' \
|
||||
|| { echo "✗ 시드 실패 — quests/daily-checkin 이 없습니다"; exit 1; }
|
||||
echo "✓ 시드 확인"
|
||||
|
||||
echo "▸ 기기에서 왕복 (${DEVICE} · host ${HOST})"
|
||||
cd "$ROOT"
|
||||
exec fvm flutter test integration_test/quest_callable_emulator_test.dart \
|
||||
-d "$DEVICE" --dart-define="FANUZU_EMULATOR_HOST=${HOST}"
|
||||
@@ -0,0 +1,70 @@
|
||||
// 원격 배포 다국어 덮어쓰기 층 생성기 — 요구사항 FR-I18N-01 「언어 리소스를 원격 배포 방식으로 갱신」.
|
||||
//
|
||||
// `flutter gen-l10n` 이 만드는 AppLocalizations 는 문자열이 컴파일 상수라, 재배포 없이
|
||||
// 문구를 고칠 길이 없다. 여기서 그 클래스를 감싸는 [RemoteAppLocalizations] 를 만든다.
|
||||
// 게터마다 Firestore `i18n/{locale}` 문서의 같은 키를 먼저 보고, 없으면 번들 문자열을 쓴다.
|
||||
//
|
||||
// 실행: `dart run tool/gen_l10n_remote.dart` (app/ 에서). arb 키를 더하거나 바꾸면 다시 돌린다.
|
||||
// 생성물이 arb 와 어긋나면 test/i18n_test.dart 가 실패한다.
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
const templatePath = 'lib/l10n/app_ko.arb';
|
||||
const outputPath = 'lib/l10n/remote_app_localizations.dart';
|
||||
|
||||
void main() {
|
||||
final arb =
|
||||
jsonDecode(File(templatePath).readAsStringSync()) as Map<String, dynamic>;
|
||||
File(outputPath).writeAsStringSync(generateRemoteL10n(arb));
|
||||
stdout.writeln('wrote $outputPath');
|
||||
}
|
||||
|
||||
String generateRemoteL10n(Map<String, dynamic> arb) {
|
||||
final out = StringBuffer()
|
||||
..writeln('// GENERATED by tool/gen_l10n_remote.dart — 직접 고치지 말 것.')
|
||||
..writeln("import 'app_localizations.dart';")
|
||||
..writeln()
|
||||
..writeln('/// 번들 문자열 위에 원격 문자열(`i18n/{locale}` 문서)을 덮는다.')
|
||||
..writeln('/// 원격에 없는 키는 번들 → 번들에도 없으면 ko(template) 순으로 폴백한다.')
|
||||
..writeln('class RemoteAppLocalizations extends AppLocalizations {')
|
||||
..writeln(
|
||||
' RemoteAppLocalizations(this._base, this._remote) : super(_base.localeName);',
|
||||
)
|
||||
..writeln()
|
||||
..writeln(' final AppLocalizations _base;')
|
||||
..writeln(' final Map<String, String> _remote;')
|
||||
..writeln()
|
||||
..writeln(' /// 자리표시자를 한 번에 치환한다 — 인자 값 안의 `{name}` 은 다시 바꾸지 않는다.')
|
||||
..writeln(' String? _fill(String key, Map<String, String> args) =>')
|
||||
..writeln(' _remote[key]?.replaceAllMapped(')
|
||||
..writeln(" RegExp(r'\\{(\\w+)\\}'),")
|
||||
..writeln(' (m) => args[m[1]] ?? m[0]!,')
|
||||
..writeln(' );');
|
||||
for (final key
|
||||
in arb.keys.where((k) => !k.startsWith('@')).toList()..sort()) {
|
||||
final message = arb[key] as String;
|
||||
final meta = arb['@$key'] as Map<String, dynamic>?;
|
||||
final names = ((meta?['placeholders'] as Map<String, dynamic>?) ?? const {})
|
||||
.keys
|
||||
.toList();
|
||||
out.writeln();
|
||||
out.writeln(' @override');
|
||||
if (names.isEmpty) {
|
||||
out.writeln(" String get $key => _remote['$key'] ?? _base.$key;");
|
||||
continue;
|
||||
}
|
||||
final params = names.map((n) => 'String $n').join(', ');
|
||||
final args = names.join(', ');
|
||||
// ICU select·plural 은 원격 문자열로 해석하지 않는다 — 번들만 쓴다.
|
||||
if (message.contains(', select,') || message.contains(', plural,')) {
|
||||
out.writeln(' String $key($params) => _base.$key($args);');
|
||||
continue;
|
||||
}
|
||||
final map = names.map((n) => "'$n': $n").join(', ');
|
||||
out.writeln(
|
||||
" String $key($params) => _fill('$key', {$map}) ?? _base.$key($args);",
|
||||
);
|
||||
}
|
||||
out.writeln('}');
|
||||
return out.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user