Add sunrise Threads automation and improve capture and character interactions
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { calculateSun, sunriseAzimuth } from '../src/astronomy/sunCalculator.ts';
|
||||
import { observer } from '../src/config/observer.ts';
|
||||
|
||||
test('arrival faces the selected day’s sunrise, even when entering in the afternoon', () => {
|
||||
for (const month of ['01', '06', '11']) {
|
||||
const date = new Date(`2026-${month}-02T15:00:00+09:00`);
|
||||
const state = calculateSun(date, observer);
|
||||
const heading = sunriseAzimuth(date, observer);
|
||||
assert.equal(heading, calculateSun(state.sunrise!, observer).azimuth);
|
||||
assert.ok(heading > 45 && heading < 135);
|
||||
assert.ok(Math.abs(heading - state.azimuth) > 60);
|
||||
}
|
||||
});
|
||||
+84
-2
@@ -19,6 +19,7 @@ test('authored flashlight stays in the left grip and uses lumen power only when
|
||||
assert.equal(beam.light.intensity, FLASHLIGHT.lumens / (2 * Math.PI * (1 - Math.cos(FLASHLIGHT.halfAngle))));
|
||||
sheep.setEquipment(true, false, false, true);
|
||||
sheep.pose('idle', 0, true, 1);
|
||||
sheep.pose('idle', 0, true, 2);
|
||||
beam.update(true);
|
||||
assert.equal(torch.visible, true);
|
||||
const direction = new Vector3().subVectors(beam.light.target.position, beam.light.position).normalize();
|
||||
@@ -29,16 +30,47 @@ test('authored flashlight stays in the left grip and uses lumen power only when
|
||||
assert.ok(Math.abs(beam.light.position.x-before.x-3) < .001);
|
||||
sheep.setEquipment(false, false, false, false);
|
||||
beam.update(false);
|
||||
sheep.pose('idle', 0, false, 3);
|
||||
assert.equal(torch.visible, false);
|
||||
assert.equal(beam.light.visible, false);
|
||||
beam.dispose(); sheep.dispose();
|
||||
});
|
||||
|
||||
test('flashlight raises and lowers smoothly, retaining the prop until the arm is down', async () => {
|
||||
const [model, umbrella, raincoat, torch] = await Promise.all(['red-sheep', 'umbrella', 'raincoat', 'flashlight'].map(asset));
|
||||
const sheep = createSheep(model, { umbrella, raincoat, flashlight: torch });
|
||||
const arm = model.getObjectByName('ArmLeft')!;
|
||||
sheep.pose('idle', 0, false, 0);
|
||||
const rest = arm.quaternion.clone();
|
||||
sheep.setEquipment(false, false, false, true);
|
||||
assert.equal(sheep.pose('idle', 0, false, .1), true, 'idle rendering continues during the transition');
|
||||
const raising = arm.quaternion.clone();
|
||||
sheep.pose('idle', 0, false, 1);
|
||||
const held = arm.quaternion.clone();
|
||||
assert.ok(raising.angleTo(rest) > .01 && raising.angleTo(held) > .01);
|
||||
sheep.setEquipment(false, false, false, false);
|
||||
assert.equal(torch.visible, true, 'the prop must not vanish before the lowering animation');
|
||||
assert.equal(sheep.pose('idle', 0, false, 1.1), true);
|
||||
const lowering = arm.quaternion.clone();
|
||||
assert.ok(lowering.angleTo(held) > .01 && lowering.angleTo(rest) > .01);
|
||||
sheep.setEquipment(false, false, false, true);
|
||||
sheep.pose('idle', 0, false, 1.1);
|
||||
assert.ok(arm.quaternion.angleTo(lowering) < 1e-6, 'reversing mid-animation does not snap');
|
||||
sheep.setEquipment(false, false, false, false);
|
||||
assert.equal(sheep.pose('idle', 0, false, 2), true, 'the final lowered frame is rendered');
|
||||
assert.ok(arm.quaternion.angleTo(rest) < 1e-6);
|
||||
assert.equal(torch.visible, false);
|
||||
assert.equal(sheep.pose('idle', 0, false, 2.1), false, 'idle rendering can stop after the transition');
|
||||
sheep.dispose();
|
||||
});
|
||||
|
||||
test('rain equipment is independent of emotes, remains in snow, and clears in dry weather', () => {
|
||||
const gear = createRainGearState();
|
||||
const emote = createEmoteState();
|
||||
assert.equal(gear.toggle('umbrella'), false);
|
||||
gear.updateWeather(1, 0);
|
||||
assert.ok(gear.raincoat && gear.hood, 'rainy arrival starts with coat and hood');
|
||||
gear.toggle('raincoat');
|
||||
assert.equal(gear.toggle('hood'), false, 'hood requires the raincoat');
|
||||
assert.ok(gear.toggle('umbrella'));
|
||||
assert.ok(gear.toggle('raincoat'));
|
||||
@@ -58,6 +90,23 @@ test('rain equipment is independent of emotes, remains in snow, and clears in dr
|
||||
assert.ok(!gear.available && !gear.umbrella && !gear.raincoat);
|
||||
});
|
||||
|
||||
test('arrival equipment waits for known weather and never overrides a later manual choice', () => {
|
||||
const gear = createRainGearState();
|
||||
gear.updateWeather(null, null);
|
||||
gear.updateWeather(2, 0);
|
||||
assert.ok(gear.raincoat && gear.hood && !gear.umbrella);
|
||||
gear.toggle('hood');
|
||||
gear.updateWeather(3, 0);
|
||||
assert.ok(gear.raincoat && !gear.hood);
|
||||
gear.toggle('raincoat');
|
||||
gear.updateWeather(4, 0);
|
||||
assert.ok(!gear.raincoat && !gear.hood);
|
||||
const dryArrival = createRainGearState();
|
||||
dryArrival.updateWeather(0, 0);
|
||||
dryArrival.updateWeather(2, 0);
|
||||
assert.ok(!dryArrival.raincoat && !dryArrival.hood, 'only arrival gets the default outfit');
|
||||
});
|
||||
|
||||
async function asset(name: string) {
|
||||
const bytes = await readFile(new URL(`../public/characters/${name}.glb`, import.meta.url));
|
||||
return (await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '')).scene;
|
||||
@@ -79,16 +128,49 @@ test('Blender gear follows the sheep joints and keeps the umbrella grip through
|
||||
sheep.setEquipment(true, true, true);
|
||||
assert.ok(hood.visible && !folded.visible);
|
||||
sheep.pose('idle', 0, true, 1);
|
||||
sheep.pose('idle', 0, true, 2);
|
||||
const grip = right.quaternion.clone();
|
||||
const leftWalking = left.quaternion.clone();
|
||||
sheep.pose('wave', .2, false, 1);
|
||||
sheep.pose('wave', .2, false, 2);
|
||||
assert.ok(right.quaternion.angleTo(grip) < 1e-6);
|
||||
assert.ok(left.quaternion.angleTo(leftWalking) > .5);
|
||||
sheep.pose('pray', .2, false, 1);
|
||||
sheep.pose('pray', .2, false, 2);
|
||||
assert.ok(right.quaternion.angleTo(grip) < 1e-6);
|
||||
assert.ok(umbrella.visible && raincoat.visible && sleeve.visible);
|
||||
sheep.setEquipment(false, false);
|
||||
sheep.pose('idle', 0, false, 3);
|
||||
assert.ok(!umbrella.visible && !raincoat.visible && !sleeve.visible);
|
||||
assert.equal(hood.visible, false);
|
||||
sheep.dispose();
|
||||
});
|
||||
|
||||
test('umbrella follows the right hand through raising, reversal and lowering before hiding', async () => {
|
||||
const [model, umbrella, raincoat] = await Promise.all(['red-sheep', 'umbrella', 'raincoat'].map(asset));
|
||||
const sheep = createSheep(model, { umbrella, raincoat });
|
||||
const arm = model.getObjectByName('ArmRight')!;
|
||||
assert.ok(umbrella.parent === arm, 'umbrella is attached to the right hand');
|
||||
sheep.pose('idle', 0, false, 0);
|
||||
const rest = arm.quaternion.clone();
|
||||
sheep.setEquipment(true, false);
|
||||
assert.equal(sheep.pose('idle', 0, false, .225), true);
|
||||
const halfway = arm.quaternion.clone();
|
||||
const canopyHalfway = umbrella.getWorldPosition(new Vector3());
|
||||
sheep.setEquipment(true, false); // Repeated network updates must not restart the transition.
|
||||
sheep.pose('idle', 0, false, .45);
|
||||
const held = arm.quaternion.clone();
|
||||
assert.ok(halfway.angleTo(rest) > .01 && halfway.angleTo(held) > .01);
|
||||
assert.ok(canopyHalfway.distanceTo(umbrella.getWorldPosition(new Vector3())) > .01);
|
||||
sheep.setEquipment(false, false);
|
||||
assert.equal(umbrella.visible, true);
|
||||
assert.equal(sheep.pose('idle', 0, false, .675), true);
|
||||
assert.ok(arm.quaternion.angleTo(halfway) < 1e-6);
|
||||
sheep.setEquipment(true, false);
|
||||
sheep.pose('idle', 0, false, .675);
|
||||
assert.ok(arm.quaternion.angleTo(halfway) < 1e-6, 'reversal preserves the current pose');
|
||||
sheep.setEquipment(false, false);
|
||||
assert.equal(sheep.pose('idle', 0, false, 1.2), true);
|
||||
assert.ok(arm.quaternion.angleTo(rest) < 1e-6);
|
||||
assert.equal(umbrella.visible, false);
|
||||
assert.equal(sheep.pose('idle', 0, false, 1.3), false);
|
||||
sheep.dispose();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { loadThreadsConfig } from '../server/threads/config.ts';
|
||||
|
||||
test('Threads JSON sample loads with relative paths and no embedded token', () => {
|
||||
const config = loadThreadsConfig(new URL('../.env.threads.sample.json', import.meta.url));
|
||||
assert.equal(config.accessToken, '');
|
||||
assert.equal(config.siteUrl, 'https://sunrise.horoli.kr');
|
||||
assert.equal(config.imageDir, 'dist/threads-captures');
|
||||
});
|
||||
|
||||
test('missing configuration is permitted only for capture preview', () => {
|
||||
const missing = new URL('../.tmp/threads-config-does-not-exist.json', import.meta.url);
|
||||
assert.throws(() => loadThreadsConfig(missing), /copy .env.threads.sample.json/);
|
||||
assert.equal(loadThreadsConfig(missing, true).accessToken, '');
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { calculateSun } from '../src/astronomy/sunCalculator.ts';
|
||||
import { observer } from '../src/config/observer.ts';
|
||||
import { dailySchedule, publishImage, publishCarousel } from '../server/threads/publisher.ts';
|
||||
|
||||
test('schedule uses the Korean date and existing SunCalc sunrise', () => {
|
||||
const now = new Date('2026-11-01T18:00:00Z');
|
||||
const schedule = dailySchedule(now);
|
||||
assert.equal(schedule.date, '2026-11-02');
|
||||
assert.equal(schedule.sunrise.getTime(), calculateSun(now, observer).sunrise!.getTime());
|
||||
assert.equal(+schedule.sunrise - +schedule.prepareAt, 300_000);
|
||||
});
|
||||
|
||||
test('publishing waits for ready media and persists intent before sending', async () => {
|
||||
const events: string[] = [];
|
||||
const replies = [{ id: 'container' }, { status: 'IN_PROGRESS' }, { status: 'FINISHED' }, { id: 'post' }];
|
||||
const result = await publishImage('secret', 'https://example.com/image.jpg', 'caption', async () => { events.push('persist'); },
|
||||
async (url, init) => {
|
||||
events.push(String(url).includes('threads_publish') ? 'publish' : 'request');
|
||||
assert.equal(new Headers(init?.headers).get('Authorization'), 'Bearer secret');
|
||||
return new Response(JSON.stringify(replies.shift()));
|
||||
}, async () => {});
|
||||
assert.equal(result, 'post');
|
||||
assert.deepEqual(events.slice(-2), ['persist', 'publish']);
|
||||
});
|
||||
|
||||
test('failed media never reaches publish', async () => {
|
||||
const replies = [{ id: 'container' }, { status: 'ERROR' }];
|
||||
await assert.rejects(publishImage('secret', 'https://example.com/image.jpg', 'caption', async () => {
|
||||
assert.fail('must not publish');
|
||||
}, async () => new Response(JSON.stringify(replies.shift())), async () => {}), /ERROR/);
|
||||
});
|
||||
|
||||
test('another publisher owning the daily record prevents the publish request', async () => {
|
||||
let requests = 0;
|
||||
const replies = [{ id: 'container' }, { status: 'FINISHED' }];
|
||||
await assert.rejects(publishImage('secret', 'https://example.com/image.jpg', 'caption', async () => {
|
||||
throw new Error('EEXIST');
|
||||
}, async () => { requests++; return new Response(JSON.stringify(replies.shift())); }, async () => {}), /EEXIST/);
|
||||
assert.equal(requests, 2);
|
||||
});
|
||||
|
||||
test('ambiguous publish response is never automatically retried', async () => {
|
||||
let requests = 0;
|
||||
let claimed = false;
|
||||
const replies = [{ id: 'container' }, { status: 'FINISHED' }];
|
||||
await assert.rejects(publishImage('secret', 'https://example.com/image.jpg', 'caption', async () => {
|
||||
claimed = true;
|
||||
}, async () => {
|
||||
requests++;
|
||||
if (requests === 3) throw new Error('Connection lost');
|
||||
return new Response(JSON.stringify(replies.shift()));
|
||||
}, async () => {}), /Connection lost/);
|
||||
assert.equal(claimed, true);
|
||||
assert.equal(requests, 3);
|
||||
});
|
||||
|
||||
test('carousel preserves photo order, waits for children and publishes only the parent', async () => {
|
||||
const posts: URLSearchParams[] = [];
|
||||
const replies = [{ id: 'back' }, { status: 'FINISHED' }, { id: 'front' },
|
||||
{ status: 'IN_PROGRESS' }, { status: 'FINISHED' }, { id: 'album' }, { status: 'FINISHED' }, { id: 'post' }];
|
||||
let claimed = '';
|
||||
const result = await publishCarousel('secret', ['https://example.com/back.jpg', 'https://example.com/front.jpg'], 'caption',
|
||||
async id => { claimed = id; }, async (_url, init) => {
|
||||
if (init?.method === 'POST') posts.push(init.body as URLSearchParams);
|
||||
return new Response(JSON.stringify(replies.shift()));
|
||||
}, async () => {});
|
||||
assert.equal(result, 'post');
|
||||
assert.equal(posts[0].get('image_url'), 'https://example.com/back.jpg');
|
||||
assert.equal(posts[1].get('image_url'), 'https://example.com/front.jpg');
|
||||
assert.equal(posts[0].get('is_carousel_item'), 'true');
|
||||
assert.equal(posts[1].get('is_carousel_item'), 'true');
|
||||
assert.equal(posts[2].get('media_type'), 'CAROUSEL');
|
||||
assert.equal(posts[2].get('children'), 'back,front');
|
||||
assert.equal(posts[2].get('text'), 'caption');
|
||||
assert.equal(claimed, 'album');
|
||||
assert.equal(posts[3].get('creation_id'), 'album');
|
||||
assert.equal(posts.length, 4);
|
||||
});
|
||||
|
||||
test('a failed second image prevents a partial carousel publication', async () => {
|
||||
const replies = [{ id: 'back' }, { status: 'FINISHED' }, { id: 'front' }, { status: 'ERROR' }];
|
||||
await assert.rejects(publishCarousel('secret', ['https://example.com/back.jpg', 'https://example.com/front.jpg'], 'caption',
|
||||
async () => assert.fail('must not publish'), async () => new Response(JSON.stringify(replies.shift())), async () => {}), /ERROR/);
|
||||
});
|
||||
Reference in New Issue
Block a user