Simplify Threads captions and capture five minutes after sunrise
This commit is contained in:
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"template": ["{message}", "", "{url}"],
|
"template": ["오늘의 관악산 일출이에요", "{dateDots} · 일출 {sunrise}", "날씨: {message}", "{hostname}"],
|
||||||
"testPrefix": "[테스트] ",
|
"testPrefix": "[테스트] ",
|
||||||
"thresholds": { "fogVisibilityMeters": 1000, "cloudyPercent": 80, "partlyCloudyPercent": 30 },
|
"thresholds": { "fogVisibilityMeters": 1000, "cloudyPercent": 80, "partlyCloudyPercent": 30 },
|
||||||
"messages": {
|
"messages": {
|
||||||
"clear": ["오늘은 하늘이 맑아. 여기서 같이 해 기다릴래? 🌅"],
|
"clear": ["맑음"],
|
||||||
"partlyCloudy": ["구름 사이로 아침이 오는 중이야. 조금만 더 기다려 볼까? 🐑"],
|
"partlyCloudy": ["구름 조금"],
|
||||||
"cloudy": ["오늘은 구름이 잔뜩이네. 해가 숨어 있어도 난 여기 있어 ☁️"],
|
"cloudy": ["흐림"],
|
||||||
"fog": ["오늘은 멀리까지 잘 안 보여. 천천히 하늘을 기다리는 중이야 🐑"],
|
"fog": ["낮은 시정"],
|
||||||
"rain": ["비 오는 관악산에서 해를 기다렸어. 오늘은 빗소리도 같이 듣자 ☔"],
|
"rain": ["비"],
|
||||||
"snow": ["눈 오는 관악산에 왔어. 오늘도 여기서 해를 기다릴게 ❄️"],
|
"snow": ["눈"],
|
||||||
"unknown": ["오늘도 관악산에 자리 잡았어. 같이 하늘 보고 갈래? 🐑"]
|
"unknown": ["정보 없음"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## 동작
|
## 동작
|
||||||
|
|
||||||
PM2가 상시 실행하는 워커가 매일 **한국 시간 03:00**에 작업을 시작합니다. 기존 `calculateSun` / SunCalc 2.x로 당일 관악산 일출을 계산하고, 5분 전에 Chromium을 실행합니다. 배포 페이지의 실제 관측 위치·고도로 계산한 일출 시각까지 기다린 뒤, UI 없는 **1080×1728 세로 JPEG 두 장**을 같은 시각의 장면에서 촬영하고 **한 개의 Threads 캐러셀 게시물**로 올립니다. 게시 문구는 별도 JSON 파일에서 수정하며, 촬영에 사용된 날씨에 따라 선택합니다. 날씨는 페이지의 기존 기상자료를 사용합니다.
|
PM2가 상시 실행하는 워커가 매일 **한국 시간 03:00**에 작업을 시작합니다. 기존 `calculateSun` / SunCalc 2.x로 당일 관악산 일출을 계산하고, 일출 5분 전에 Chromium을 실행합니다. 배포 페이지의 실제 관측 위치·고도로 계산한 일출 시각에서 5분 더 기다린 뒤, UI 없는 **1080×1728 세로 JPEG 두 장**을 같은 시각의 장면에서 촬영하고 **한 개의 Threads 캐러셀 게시물**로 올립니다. 게시 문구는 별도 JSON 파일에서 수정하며, 촬영에 사용된 날씨에 따라 선택합니다. 날씨는 페이지의 기존 기상자료를 사용합니다.
|
||||||
|
|
||||||
사진 순서는 ① 일출 방향을 바라보는 캐릭터 뒷모습과 풍경 ② 캐릭터 얼굴과 하늘입니다. 캐릭터는 일출을 향한 자세를 유지하고 두 번째 카메라만 방위각 +200°, 위쪽 20°로 회전합니다. 기존 3인칭 카메라의 지형 충돌 보정을 사용합니다. 구도와 해상도는 `src/constants/threads.ts`의 `angles`, `viewport`에서 조정합니다. 두 사진 모두 준비된 뒤 캐러셀을 만들며, 한 장이라도 실패하면 일부만 게시하지 않습니다.
|
사진 순서는 ① 일출 방향을 바라보는 캐릭터 뒷모습과 풍경 ② 캐릭터 얼굴과 하늘입니다. 캐릭터는 일출을 향한 자세를 유지하고 두 번째 카메라만 방위각 +200°, 위쪽 20°로 회전합니다. 기존 3인칭 카메라의 지형 충돌 보정을 사용합니다. 구도와 해상도는 `src/constants/threads.ts`의 `angles`, `viewport`에서 조정합니다. 두 사진 모두 준비된 뒤 캐러셀을 만들며, 한 장이라도 실패하면 일부만 게시하지 않습니다.
|
||||||
|
|
||||||
이전 단일 사진 버전의 `.capture.json` 기록은 재사용하지 않습니다. 해당 기록이 남아 있다면 보관 위치로 옮긴 뒤 다음 일출부터 실행하세요. 이미 게시했거나 게시 결과가 불명확한 날짜의 `.json` 게시 기록은 유지해야 중복을 방지할 수 있습니다.
|
이전 단일 사진 버전의 `.capture.json` 기록은 재사용하지 않습니다. 해당 기록이 남아 있다면 보관 위치로 옮긴 뒤 다음 일출부터 실행하세요. 이미 게시했거나 게시 결과가 불명확한 날짜의 `.json` 게시 기록은 유지해야 중복을 방지할 수 있습니다.
|
||||||
|
|
||||||
SunCalc의 일출은 관측 고도를 반영한 천문학적 일출입니다. 주변 산 능선 뒤에서 태양이 실제로 드러나는 시각과 다를 수 있습니다. 서버 시계 동기화(NTP)를 켜 두세요. 캡처 후 업로드·Meta 처리 때문에 게시 시각은 일출보다 늦습니다.
|
SunCalc의 일출은 관측 고도를 반영한 천문학적 일출입니다. 주변 산 능선 뒤에서 태양이 실제로 드러나는 시각과 다를 수 있습니다. 서버 시계 동기화(NTP)를 켜 두세요. 문구에는 계산된 일출 시각을 표시하고 촬영은 그로부터 5분 뒤에 합니다(예: 일출 06:53 → 촬영 06:58). 캡처 후 업로드·Meta 처리 시간이 추가됩니다. 촬영 지연은 src/constants/threads.ts의 captureDelayMs에서 조정합니다. 즉시 테스트 게시와 preview는 이 지연 없이 바로 촬영합니다.
|
||||||
|
|
||||||
변경된 프런트엔드를 먼저 배포해야 합니다. 작업은 `?mode=solo&site=gwanaksan&capture=threads`로 열며, 해당 모드에서만 브라우저 내 촬영 인터페이스가 노출됩니다. 인증 토큰은 브라우저로 전달하지 않습니다.
|
변경된 프런트엔드를 먼저 배포해야 합니다. 작업은 `?mode=solo&site=gwanaksan&capture=threads`로 열며, 해당 모드에서만 브라우저 내 촬영 인터페이스가 노출됩니다. 인증 토큰은 브라우저로 전달하지 않습니다.
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ npm run threads:post-now
|
|||||||
|
|
||||||
## 5. 문구 수정과 날씨 조건
|
## 5. 문구 수정과 날씨 조건
|
||||||
|
|
||||||
프로젝트 루트에서 샘플을 복사합니다. 실제 문구 파일은 Git에서 제외되므로 배포로 덮어쓰지 않습니다. 파일이 없으면 샘플을 사용합니다.
|
프로젝트 루트에서 샘플을 복사합니다. 실제 문구 파일은 Git에서 제외되므로 배포로 덮어쓰지 않습니다. 파일이 없으면 샘플을 사용합니다. 기존 `.threads-captions.json`은 자동으로 덮어쓰지 않습니다. 새 기본 문구로 바꾸려면 기존 파일을 백업한 뒤 `.threads-captions.sample.json` 내용으로 교체하세요.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -n .threads-captions.sample.json .threads-captions.json
|
cp -n .threads-captions.sample.json .threads-captions.json
|
||||||
@@ -84,9 +84,9 @@ npm run threads:captions
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `messages`: 날씨별 문구입니다. 배열의 각 항목은 한 줄이며, 여러 줄을 쓰면 모두 순서대로 표시됩니다(무작위 선택 아님).
|
- `messages`: 날씨별 문구입니다. 배열의 각 항목은 한 줄이며, 여러 줄을 쓰면 모두 순서대로 표시됩니다(무작위 선택 아님).
|
||||||
- `template`: 전체 게시글의 줄 구성입니다. 기본은 문구, 빈 줄, 사이트 링크입니다.
|
- `template`: 전체 게시글의 줄 구성입니다. 기본은 “오늘의 관악산 일출이에요”, 날짜·일출 시각, 날씨, 사이트 주소의 네 줄입니다.
|
||||||
- `testPrefix`: 즉시 테스트 게시 앞에 붙일 표시입니다.
|
- `testPrefix`: 즉시 테스트 게시 앞에 붙일 표시입니다.
|
||||||
- 치환값: `{message}`, `{date}`(한국 날짜), `{time}`(촬영 HH:mm), `{sunrise}`(일출 HH:mm), `{url}`.
|
- 치환값: `{message}`, `{date}`(한국 날짜 YYYY-MM-DD), `{dateDots}`(YYYY.MM.DD), `{time}`(촬영 HH:mm), `{sunrise}`(일출 HH:mm), `{url}`(전체 URL), `{hostname}`(도메인만).
|
||||||
- `thresholds`: 낮은 시정 기준(m), 흐림·구름 조금 운량 기준(%)입니다.
|
- `thresholds`: 낮은 시정 기준(m), 흐림·구름 조금 운량 기준(%)입니다.
|
||||||
|
|
||||||
날씨 우선순위는 눈 → 비 → 낮은 시정 → 흐림 → 구름 조금 → 맑음입니다. 기본 시정 기준은 1000m 미만, 흐림은 운량 80% 이상, 구름 조금은 30% 이상입니다. 강수 정보가 없거나 날씨 로딩/조회가 실패하면 `unknown`을 사용합니다. 낮은 시정은 안개로 단정하지 않도록 기본 문구에 표현했습니다. 웹페이지에 적용된 기상 데이터를 캡처 기록에 함께 저장하며, 재시도도 그 사진의 날씨를 사용합니다.
|
날씨 우선순위는 눈 → 비 → 낮은 시정 → 흐림 → 구름 조금 → 맑음입니다. 기본 시정 기준은 1000m 미만, 흐림은 운량 80% 이상, 구름 조금은 30% 이상입니다. 강수 정보가 없거나 날씨 로딩/조회가 실패하면 `unknown`을 사용합니다. 낮은 시정은 안개로 단정하지 않도록 기본 문구에 표현했습니다. 웹페이지에 적용된 기상 데이터를 캡처 기록에 함께 저장하며, 재시도도 그 사진의 날씨를 사용합니다.
|
||||||
@@ -112,7 +112,7 @@ pm2 status
|
|||||||
pm2 logs sunrise-threads --lines 100
|
pm2 logs sunrise-threads --lines 100
|
||||||
```
|
```
|
||||||
|
|
||||||
설정은 프로젝트 위치를 자동으로 계산하고 워커를 단일 fork 프로세스로 실행합니다. 기존 웹 서버 PM2 프로세스는 건드리지 않습니다. 날짜 계산은 서버 시간대와 무관하게 Asia/Seoul 기준이며, 03:00 이후 새로 시작하면 오늘 작업을 바로 확인합니다. 이미 게시했다면 중복 게시하지 않고, 일출을 놓쳤고 저장된 사진도 없다면 다음 날짜를 기다립니다. 실행 중에는 실제 촬영 5분 전에만 Chromium을 엽니다.
|
설정은 프로젝트 위치를 자동으로 계산하고 워커를 단일 fork 프로세스로 실행합니다. 기존 웹 서버 PM2 프로세스는 건드리지 않습니다. 날짜 계산은 서버 시간대와 무관하게 Asia/Seoul 기준이며, 03:00 이후 새로 시작하면 오늘 작업을 바로 확인합니다. 이미 게시했다면 중복 게시하지 않고, 일출+5분 촬영 시간을 놓쳤고 저장된 사진도 없다면 다음 날짜를 기다립니다. 실행 중에는 일출 5분 전에 Chromium을 열고 일출 5분 뒤 촬영합니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pm2 restart sunrise-threads # 코드/실행 설정 변경 후
|
pm2 restart sunrise-threads # 코드/실행 설정 변경 후
|
||||||
@@ -132,6 +132,6 @@ pm2 save # 변경된 목록 저장
|
|||||||
|
|
||||||
`publishing` 상태가 남았다면 요청이 성공했지만 응답이 유실됐을 수 있습니다. 이때 자동으로 재게시하지 않습니다. 계정과 기록의 컨테이너 ID를 확인한 뒤 수동으로 처리하세요. 기존 기록을 무조건 지우면 중복 게시될 수 있습니다.
|
`publishing` 상태가 남았다면 요청이 성공했지만 응답이 유실됐을 수 있습니다. 이때 자동으로 재게시하지 않습니다. 계정과 기록의 컨테이너 ID를 확인한 뒤 수동으로 처리하세요. 기존 기록을 무조건 지우면 중복 게시될 수 있습니다.
|
||||||
|
|
||||||
촬영 성공 시 별도 `YYYY-MM-DD.capture.json`을 저장합니다. 토큰 갱신·공개 URL 확인·컨테이너 준비가 실패하면 워커가 60초 뒤 재시도합니다(하루 작업 최대 5회 시도). 같은 날 재실행은 저장된 일출 사진을 재사용하므로 게시 준비 중 오류가 나도 촬영을 잃지 않습니다. 촬영 자체를 놓쳤다면 그날은 건너뜁니다. 다음 날짜에 전날 사진을 자동 게시하지 않습니다.
|
촬영 성공 시 별도 `YYYY-MM-DD.capture.json`을 저장합니다. 토큰 갱신·공개 URL 확인·컨테이너 준비가 실패하면 워커가 60초 뒤 재시도합니다(하루 작업 최대 5회 시도). 같은 날 재실행은 저장된 일출 사진을 재사용하므로 게시 준비 중 오류가 나도 촬영을 잃지 않습니다. 일출+5분의 촬영 시간을 놓쳤다면 그날은 건너뜁니다. 다음 날짜에 전날 사진을 자동 게시하지 않습니다.
|
||||||
|
|
||||||
정지: `pm2 stop sunrise-threads`. 게시 결과가 불명확한 작업은 재실행 전 계정과 기록을 확인하세요.
|
정지: `pm2 stop sunrise-threads`. 게시 결과가 불명확한 작업은 재실행 전 계정과 기록을 확인하세요.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function renderCaption(config: Captions, capture: {
|
|||||||
const date = new Date(capture.capturedAt);
|
const date = new Date(capture.capturedAt);
|
||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
message: config.messages[condition].join('\n'), date: kstFields(date).date,
|
message: config.messages[condition].join('\n'), date: kstFields(date).date,
|
||||||
|
dateDots: kstFields(date).date.replaceAll('-', '.'), hostname: new URL(capture.url).hostname,
|
||||||
time: kstClock(date).slice(0, 5), sunrise: kstClock(new Date(capture.sunrise)).slice(0, 5), url: capture.url,
|
time: kstClock(date).slice(0, 5), sunrise: kstClock(new Date(capture.sunrise)).slice(0, 5), url: capture.url,
|
||||||
};
|
};
|
||||||
const text = (capture.test ? config.testPrefix : '') + config.template.join('\n').replace(/\{(\w+)\}/g, (_, key: string) => {
|
const text = (capture.test ? config.testPrefix : '') + config.template.join('\n').replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { THREADS } from '../../src/constants/threads.ts';
|
|||||||
export function dailySchedule(now: Date) {
|
export function dailySchedule(now: Date) {
|
||||||
const sunrise = calculateSun(now, observer).sunrise;
|
const sunrise = calculateSun(now, observer).sunrise;
|
||||||
if (!sunrise || !Number.isFinite(+sunrise)) throw new Error('No sunrise for this date');
|
if (!sunrise || !Number.isFinite(+sunrise)) throw new Error('No sunrise for this date');
|
||||||
return { date: kstFields(now).date, sunrise, prepareAt: new Date(+sunrise - THREADS.prepareMs) };
|
return { date: kstFields(now).date, sunrise, captureAt: sunriseCaptureTime(sunrise), prepareAt: new Date(+sunrise - THREADS.prepareMs) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sunriseCaptureTime(sunrise: Date): Date {
|
||||||
|
return new Date(+sunrise + THREADS.captureDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function threadsRequest(token: string, path: string, params: Record<string, string>, method = 'GET', request: typeof fetch = fetch) {
|
export async function threadsRequest(token: string, path: string, params: Record<string, string>, method = 'GET', request: typeof fetch = fetch) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
import { THREADS } from '../../src/constants/threads.ts';
|
import { THREADS } from '../../src/constants/threads.ts';
|
||||||
import { kstClock, kstFields } from '../../src/ui/time.ts';
|
import { kstClock, kstFields } from '../../src/ui/time.ts';
|
||||||
import { dailySchedule, publishCarousel, threadsRequest } from './publisher.ts';
|
import { dailySchedule, publishCarousel, threadsRequest, sunriseCaptureTime } from './publisher.ts';
|
||||||
import { loadThreadsConfig } from './config.ts';
|
import { loadThreadsConfig } from './config.ts';
|
||||||
import { cleanupPublished } from './cleanup.ts';
|
import { cleanupPublished } from './cleanup.ts';
|
||||||
import { loadCaptions, renderCaption } from './captions.ts';
|
import { loadCaptions, renderCaption } from './captions.ts';
|
||||||
@@ -79,8 +79,8 @@ async function main() {
|
|||||||
const immediate = postNow || (dryRun && args.includes('--now'));
|
const immediate = postNow || (dryRun && args.includes('--now'));
|
||||||
const capturePath = join(stateDir, `${runId}.capture.json`);
|
const capturePath = join(stateDir, `${runId}.capture.json`);
|
||||||
const savedCapture = dryRun ? null : await readJson(capturePath);
|
const savedCapture = dryRun ? null : await readJson(capturePath);
|
||||||
if (!savedCapture && !immediate && Date.now() > +schedule.sunrise + THREADS.maxLatenessMs) {
|
if (!savedCapture && !immediate && Date.now() > +schedule.captureAt + THREADS.maxLatenessMs) {
|
||||||
console.log(`${schedule.date}: sunrise has passed; skipping late capture`);
|
console.log(`${schedule.date}: sunrise +5 minute capture window has passed; skipping late capture`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const captured = savedCapture ?? await captureSunrise(schedule, immediate);
|
const captured = savedCapture ?? await captureSunrise(schedule, immediate);
|
||||||
@@ -142,14 +142,16 @@ async function captureSunrise(schedule: ReturnType<typeof dailySchedule>, immedi
|
|||||||
sunrise = new Date(await page.evaluate(() => window.sunriseThreads!.sunrise()));
|
sunrise = new Date(await page.evaluate(() => window.sunriseThreads!.sunrise()));
|
||||||
if (!Number.isFinite(+sunrise) || kstFields(sunrise).date !== schedule.date) throw new Error('Unexpected page sunrise date');
|
if (!Number.isFinite(+sunrise) || kstFields(sunrise).date !== schedule.date) throw new Error('Unexpected page sunrise date');
|
||||||
console.log(`Page sunrise: ${sunrise.toISOString()} (${kstClock(sunrise)} KST)`);
|
console.log(`Page sunrise: ${sunrise.toISOString()} (${kstClock(sunrise)} KST)`);
|
||||||
|
const captureAt = sunriseCaptureTime(sunrise);
|
||||||
|
console.log(`Capture target: ${captureAt.toISOString()} (${kstClock(captureAt)} KST)`);
|
||||||
if (!immediate) {
|
if (!immediate) {
|
||||||
if (Date.now() > +sunrise + THREADS.maxLatenessMs) throw new Error('Page loaded after capture deadline');
|
if (Date.now() > +captureAt + THREADS.maxLatenessMs) throw new Error('Page loaded after capture deadline');
|
||||||
await waitUntil(sunrise);
|
await waitUntil(captureAt);
|
||||||
}
|
}
|
||||||
const capture = await page.evaluate(() => window.sunriseThreads!.capture());
|
const capture = await page.evaluate(() => window.sunriseThreads!.capture());
|
||||||
capturedAt = capture.capturedAt;
|
capturedAt = capture.capturedAt;
|
||||||
weather = capture.weather ?? null;
|
weather = capture.weather ?? null;
|
||||||
if (!immediate && Math.abs(Date.parse(capturedAt) - +sunrise) > THREADS.maxLatenessMs) throw new Error('Capture missed sunrise deadline');
|
if (!immediate && Math.abs(Date.parse(capturedAt) - +captureAt) > THREADS.maxLatenessMs) throw new Error('Capture missed sunrise +5 minute deadline');
|
||||||
if (capture.images?.length !== THREADS.angles.length) throw new Error('Deploy the two-angle capture frontend first');
|
if (capture.images?.length !== THREADS.angles.length) throw new Error('Deploy the two-angle capture frontend first');
|
||||||
for (const [index, shot] of capture.images.entries()) {
|
for (const [index, shot] of capture.images.entries()) {
|
||||||
if (shot.name !== THREADS.angles[index].name) throw new Error('Unexpected capture angle order');
|
if (shot.name !== THREADS.angles[index].name) throw new Error('Unexpected capture angle order');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const THREADS = {
|
|||||||
siteUrl: 'https://sunrise.horoli.kr',
|
siteUrl: 'https://sunrise.horoli.kr',
|
||||||
apiUrl: 'https://graph.threads.net',
|
apiUrl: 'https://graph.threads.net',
|
||||||
prepareMs: 5 * 60_000,
|
prepareMs: 5 * 60_000,
|
||||||
|
captureDelayMs: 5 * 60_000,
|
||||||
maxLatenessMs: 30_000,
|
maxLatenessMs: 30_000,
|
||||||
requestTimeoutMs: 30_000,
|
requestTimeoutMs: 30_000,
|
||||||
mediaPollMs: 5_000,
|
mediaPollMs: 5_000,
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import assert from 'node:assert/strict';
|
|||||||
import { loadCaptions, weatherKey, renderCaption } from '../server/threads/captions.ts';
|
import { loadCaptions, weatherKey, renderCaption } from '../server/threads/captions.ts';
|
||||||
|
|
||||||
const sample = { cloudCover: 10, visibility: 10000, rain: 0, snowfall: 0 };
|
const sample = { cloudCover: 10, visibility: 10000, rain: 0, snowfall: 0 };
|
||||||
|
test('default post shows sunrise time rather than the five-minute-later capture time', () => {
|
||||||
|
const config = loadCaptions(new URL('../.threads-captions.sample.json', import.meta.url));
|
||||||
|
const result = renderCaption(config, { weather: { ...sample, cloudCover: 90 }, capturedAt: '2026-11-01T21:58:00Z',
|
||||||
|
sunrise: '2026-11-01T21:53:00Z', url: 'https://sunrise.horoli.kr', test: false });
|
||||||
|
assert.equal(result.text, '오늘의 관악산 일출이에요\n2026.11.02 · 일출 06:53\n날씨: 흐림\nsunrise.horoli.kr');
|
||||||
|
});
|
||||||
test('caption weather uses precipitation first and does not assume missing data means clear', () => {
|
test('caption weather uses precipitation first and does not assume missing data means clear', () => {
|
||||||
const config = loadCaptions();
|
const config = loadCaptions();
|
||||||
assert.equal(weatherKey({ ...sample, rain: 1, snowfall: 1 }, config), 'snow');
|
assert.equal(weatherKey({ ...sample, rain: 1, snowfall: 1 }, config), 'snow');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ test('schedule uses the Korean date and existing SunCalc sunrise', () => {
|
|||||||
assert.equal(schedule.date, '2026-11-02');
|
assert.equal(schedule.date, '2026-11-02');
|
||||||
assert.equal(schedule.sunrise.getTime(), calculateSun(now, observer).sunrise!.getTime());
|
assert.equal(schedule.sunrise.getTime(), calculateSun(now, observer).sunrise!.getTime());
|
||||||
assert.equal(+schedule.sunrise - +schedule.prepareAt, 300_000);
|
assert.equal(+schedule.sunrise - +schedule.prepareAt, 300_000);
|
||||||
|
assert.equal(+schedule.captureAt - +schedule.sunrise, 300_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('publishing waits for ready media and persists intent before sending', async () => {
|
test('publishing waits for ready media and persists intent before sending', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user