Files
MatchMaster/tools/test-daily-task-state-animation.cjs
2026-10-27 13:28:12 +08:00

521 lines
30 KiB
JavaScript

const { installCountFrames, countText } = require('./daily-count-test-utils.cjs');
const { animationModule, contentModule } = require('./popup-animation-test-support.cjs');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { stripTypeScriptTypes } = require('node:module');
const root = path.resolve(__dirname, '..');
const prefab = JSON.parse(fs.readFileSync(path.join(root, 'assets/action_bundle/prefab/dailyQuests.prefab'), 'utf8'));
const source = fs.readFileSync(path.join(root, 'assets/action_bundle/script/DailyQuests.ts'), 'utf8')
.replace(/^import .*;\r?$/gm, '').replace(/^\s*@(?:ccclass|property[^\r\n]*)\r?$/gm, '')
.replace('export default class DailyQuests', 'class DailyQuests');
const trackerSource = fs.readFileSync(path.join(root, 'assets/Script/module/Task/DailyTaskProgressTracker.ts'), 'utf8')
.replace(/^export type /gm, 'type ').replace('export default class', 'class');
const code = stripTypeScriptTypes(trackerSource + '\n' + source) + '\nglobalThis.DailyQuests = DailyQuests;';
class Node {
constructor(data) {
if (typeof data === 'string') data = {_name:data,_active:true,_opacity:255,_contentSize:{width:0,height:0},_trs:{array:[0,0,0,0,0,0,1,1,1]}};
this.name = data._name; this.active = data._active; this.opacity = data._opacity;
this.width = data._contentSize.width; this.height = data._contentSize.height;
this.x = data._trs.array[0]; this.y = data._trs.array[1];
this.scaleX = data._trs.array[7]; this.scaleY = data._trs.array[8];
this.children = []; this.components = {}; this.listeners = [];
}
set scale(value) { this.scaleX = this.scaleY = value; }
get scale() { return this.scaleX; }
get activeInHierarchy() { return !this.destroyed && this.active && (!this.parent || this.parent.activeInHierarchy); }
getChildByName(name) { return this.children.find(child => child.name === name); }
addComponent(type) { return this.components[String(type)] = {}; }
getComponent(type) { return this.components[String(type)] || null; }
on(type, handler, target, capture) { this.listeners.push({ type, handler, target, capture }); }
off(type, handler) { this.listeners = this.listeners.filter(listener => listener.type !== type || listener.handler !== handler); }
addChild(child) { child.removeFromParent(); child.parent = this; this.children.push(child); }
removeFromParent() { if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this); this.parent = null; }
destroy() { this.destroyed = true; this.removeFromParent(); }
setSiblingIndex() {}
setPosition(x, y) { this.x = x; this.y = y; }
setScale(x, y = x) { this.scaleX = x; this.scaleY = y; }
setContentSize(width, height) { this.width = width; this.height = height; }
}
Node.EventType = { TOUCH_START: 'touchstart' };
function installComponent(node, component) {
const copy = { ...component };
if (copy.clickEvents) copy.clickEvents = copy.clickEvents.map(event => ({ ...event }));
if (copy.__type__ === 'cc.Layout') copy.updateLayout = () => {};
if (copy.__type__ === 'cc.Widget') {
copy.enabled = component.enabled ?? component._enabled;
copy.alignments = 0;
copy.updateAlignment = () => { copy.alignments++; };
}
node.components[copy.__type__] = copy;
}
function build(id) {
const data = prefab[id], node = new Node(data);
for (const ref of data._components) {
const component = structuredClone(prefab[ref.__id__]);
if (component.clickEvents) component.clickEvents = component.clickEvents.map(event => event.__id__ == null ? event : structuredClone(prefab[event.__id__]));
installComponent(node, component);
}
for (const ref of data._children) node.addChild(build(ref.__id__));
return node;
}
function clone(node) {
const copy = Object.assign(Object.create(Node.prototype), node, { parent: null, children: [], components: {}, listeners: [] });
for (const component of Object.values(node.components)) installComponent(copy, component);
for (const child of node.children) copy.addChild(clone(child));
return copy;
}
function setup(states = [0, 1, 1, 2], options = {}) {
const tweens = [], requests = [], timers = [], grants = [], flights = [];
const storage = new Map();
// Advance real durations without waiting on a wall clock; callbacks run only
// after their preceding segment, and stopped tweens cannot change nodes.
class Tween {
constructor(target) { this.target = target; this.steps = []; this.index = 0; this.elapsed = 0; this.cancelled = false; this.completed = false; }
to(duration, properties, easing) { this.steps.push({ duration, properties, easing }); return this; }
delay(duration) { this.steps.push({ duration }); return this; }
call(callback) { this.steps.push({ callback }); return this; }
start() { tweens.push(this); return this; }
stop() { this.cancelled = true; return this; }
advance(seconds) {
if (this.cancelled || this.completed) return;
while (this.index < this.steps.length && !this.cancelled) {
const step = this.steps[this.index];
if (step.callback) { this.index++; step.callback(); continue; }
if (!this.initial) this.initial = Object.fromEntries(Object.keys(step.properties || {}).map(key => [key, this.target[key]]));
const consumed = Math.min(seconds, step.duration - this.elapsed);
this.elapsed += consumed; seconds -= consumed;
const progress = Math.min(1, this.elapsed / step.duration);
for (const [key, end] of Object.entries(step.properties || {})) this.target[key] = this.initial[key] + (end - this.initial[key]) * progress;
if (step.easing?.onUpdate) step.easing.onUpdate(this.target, progress);
if (this.elapsed + 1e-9 < step.duration) return;
if (step.properties) Object.assign(this.target, step.properties);
this.index++; this.elapsed = 0; this.initial = null;
}
this.completed = this.index === this.steps.length;
}
static stopAllByTarget(target) { tweens.filter(tween => tween.target === target).forEach(tween => tween.stop()); }
}
const cc = { Component: class {}, Node, Tween, tween: target => new Tween(target), _decorator: {},
Label: {toString:()=> 'cc.Label',Overflow:{SHRINK:2},HorizontalAlign:{CENTER:1},VerticalAlign:{CENTER:1}}, RichText: 'cc.RichText', LabelOutline: 'cc.LabelOutline', Sprite: { toString: () => 'cc.Sprite', SizeMode: { TRIMMED: 1, CUSTOM: 0 } }, Button: 'cc.Button', ProgressBar: 'cc.ProgressBar',
Layout: 'cc.Layout', Widget: 'cc.Widget', instantiate: clone, isValid: node => !!node && !node.destroyed, find: () => null,
fx: { GameConfig: { GM_INFO: { sceneValue: 'home' } } }, error: message => { throw Error(message); } };
const context = { cc, Date, console: { log() {} }, Utils: { getDailyQuestReward: (id, callback) => requests.push([id, response => {
// DailyTaskSync updates the task cache before notifying the panel.
if (response.code === 1) cc.fx.GameConfig.GM_INFO.tasks = response.data.task;
callback(response);
}]) },
MiniGameSdk: { API: { showToast() {} } } };
cc.fx.GameTool = {getDailyTaskServerTime: () => Date.now(), claimDailyQuestReward: context.Utils.getDailyQuestReward};
cc.fx.GameTool.getCachedDailyQuests = () => null;
cc.fx.StorageMessage = {
getStorage: key => storage.has(key) ? structuredClone(storage.get(key)) : null,
setStorage: (key, value) => storage.set(key, structuredClone(value))
};
Object.assign(context, contentModule, { PopupAnimator: animationModule.default });
vm.runInNewContext(code, context);
const daily = new context.DailyQuests(); installCountFrames(daily); daily.node = build(1);
daily.content = daily.node.getChildByName('bg').getChildByName('content');
daily.taskNormal = 'normal-frame'; daily.taskClaimable = 'claimable-frame'; daily.rewardIcons = [{ getRect: () => ({ width: 84, height: 95 }) }];
daily.schedule = () => {}; daily.unschedule = () => {}; daily.scheduleOnce = callback => timers.push(callback);
daily.openLoad = daily.closeLoad = () => {};
daily.openReward = (...args) => grants.push(args);
// Reward-flight timing has its own integration suite; isolate row animation here.
daily.playDailyRewardAnimation = (row, task, rewards, complete) => { flights.push({ row, task, rewards }); complete(); };
const template = daily.content.children[0];
if (options.scale) template.setScale(...options.scale);
if (options.widgetEnabled !== undefined) template.getComponent('cc.Widget').enabled = options.widgetEnabled;
const data = { version: 2, weeklyStars: 10, weeklyRewards: [], dailyTasks: states.map((state, index) => ({
taskId: `daily_${index}`, metric: 'pass_levels', state, value: state === 0 ? 0 : 1, target: 1, stars: 6, rewards: [{ type: 'coin', count: 50 }]
})) };
cc.fx.GameConfig.GM_INFO.tasks = data;
daily.onLoad();
if (options.initialize !== false) daily.init(data);
return { daily, data, cc, storage, tweens, requests, timers, grants, flights, rows: daily.content.children,
advance(seconds) { [...tweens].forEach(tween => tween.advance(seconds)); } };
}
function button(row) { return row.getChildByName('btnGet').getComponent('cc.Button'); }
function progress(row) { return row.getChildByName('process').getComponent('cc.ProgressBar').progress; }
function progressText(row) { return countText(row.getChildByName('processLabel')); }
function progressSetup() {
const h = setup([0, 0], { initialize: false });
h.storage.set('uid', 'player-a'); h.data.dayKey = '2026-09-29';
Object.assign(h.data.dailyTasks[0], { target: 10, value: 2 });
Object.assign(h.data.dailyTasks[1], { target: 1800, value: 360, metric: 'online_seconds' });
return h;
}
test('daily progress clips a fixed-size rounded fill instead of squeezing its corners', () => {
const process = prefab.find(value => value._name === 'process');
const progress = process._components.map(ref => prefab[ref.__id__]).find(value => value.__type__ === 'cc.ProgressBar');
const sprite = prefab[progress._N$barSprite.__id__], bar = prefab[sprite.node.__id__];
assert.equal(progress._N$mode, 2, 'ProgressBar uses FILLED mode');
assert.equal(progress._N$totalLength, 1, 'filled progress is a fraction');
assert.equal(sprite._type, 3, 'Sprite uses FILLED type');
assert.equal(sprite._fillType, 0, 'fill is horizontal');
assert.equal(sprite._fillStart, 0);
const meta = JSON.parse(fs.readFileSync(path.join(root, 'assets/action_bundle/img/dailyQuest/process-bar.png.meta')));
assert.equal(bar._contentSize.width, meta.width);
assert.equal(bar._contentSize.height, meta.height);
assert.ok(bar._trs.array[0] > 0);
assert.ok(bar._trs.array[0] + bar._contentSize.width < process._contentSize.width);
assert.ok(Math.abs(bar._trs.array[1]) + bar._contentSize.height / 2 < process._contentSize.height / 2);
});
test('task progress uses task digit and slash sprites in the prefab and during updates', () => {
const h = progressSetup(), component = prefab.find(value => value.taskDigits);
assert.deepEqual(component.taskDigits.map(ref => ref.__uuid__), h.daily.taskDigits.map(frame => frame.uuid));
assert.equal(component.taskSlash.__uuid__, h.daily.taskSlash.uuid);
const template = prefab.find(value => value._name === 'processLabel');
assert.equal(template._components.length, 0);
assert.equal(template._children.length, 4);
template._children.forEach((ref, index) => {
const glyph = prefab[ref.__id__], sprite = prefab[glyph._components[0].__id__];
const token = '0/10'[index];
assert.equal(sprite.__type__, 'cc.Sprite');
assert.equal(sprite._spriteFrame.__uuid__, token === '/' ? h.daily.taskSlash.uuid : h.daily.taskDigits[Number(token)].uuid);
});
for (const [value, target] of [[0, 0], [12345, 67890], [999999, 999999]]) {
h.daily.renderDailyTaskProgress(h.rows[0], { target, metric: 'clear_blocks' }, value);
assert.equal(progressText(h.rows[0]), `${value}/${target}`);
const container = h.rows[0].getChildByName('processLabel');
const group = container.getChildByName('RewardCountGlyphs');
group.children.filter(glyph => glyph.active).forEach(glyph => {
const frame = glyph.getComponent('cc.Sprite').spriteFrame;
assert.ok(h.daily.taskDigits.includes(frame) || frame === h.daily.taskSlash);
assert.equal(glyph.getComponent('cc.Label'), null);
assert.ok((Math.abs(glyph.x) + glyph.width * glyph.scale / 2) * group.scale <= container.width / 2 + 1e-6);
});
}
});
test('opening animates all task progress together, caches the final values, and displays online minutes', () => {
const h = progressSetup(); h.daily.init(h.data);
assert.equal(h.tweens.length, 1);
assert.equal(progressText(h.rows[0]), '0/10'); assert.equal(progressText(h.rows[1]), '0/30');
assert.equal(h.daily.touchStart, false);
const snapshot = h.storage.get('dailyTaskOpenedProgress:player-a');
assert.deepEqual(Object.values(snapshot.values), [2, 360]);
h.advance(0.3);
assert.ok(progress(h.rows[0]) > 0 && progress(h.rows[0]) < 0.2);
assert.equal(progress(h.rows[0]), progress(h.rows[1]));
h.advance(0.3);
assert.equal(progressText(h.rows[0]), '2/10'); assert.equal(progressText(h.rows[1]), '6/30');
assert.equal(h.daily.touchStart, true);
assert.deepEqual(h.data.dailyTasks.map(task => task.value), [2, 360]);
h.daily.onDisable();
h.data.dailyTasks[0].value = 8; h.data.dailyTasks[1].value = 900;
h.daily.init(h.data);
assert.equal(progressText(h.rows[0]), '2/10'); assert.equal(progressText(h.rows[1]), '6/30');
h.advance(0.3);
assert.equal(progressText(h.rows[0]), '5/10');
assert.ok(progress(h.rows[1]) > 0.2 && progress(h.rows[1]) < 0.5);
h.advance(0.3);
assert.equal(progressText(h.rows[0]), '8/10'); assert.equal(progressText(h.rows[1]), '15/30');
});
test('progress reaches the goal before completion jelly animations and claims stay locked through both stages', () => {
const h = progressSetup();
h.data.dailyTasks.forEach(task => { task.value = task.target; task.state = 1; });
h.daily.init(h.data);
h.daily.getReward(null, 'daily_0'); assert.equal(h.requests.length, 0);
h.advance(0.3); assert.equal(h.rows[0].scale, 1); assert.equal(h.tweens.length, 1);
h.advance(0.3);
assert.equal(h.tweens.length, 3);
assert.ok(h.rows.every(row => progress(row) === 1));
h.daily.getReward(null, 'daily_0'); assert.equal(h.requests.length, 0);
h.advance(0.14);
assert.ok(h.rows.every(row => !button(row).interactable));
h.advance(0.34);
assert.ok(h.rows.every(row => button(row).interactable));
h.daily.getReward(null, 'daily_0'); assert.equal(h.requests.length, 1);
});
test('background refresh is deferred during progress and never overwrites the opening snapshot', () => {
const h = progressSetup(); h.daily.init(h.data);
const updated = structuredClone(h.data); updated.dailyTasks[0].value = 7;
h.cc.fx.GameTool.getCachedDailyQuests = () => updated;
h.daily.refreshFromCache();
assert.equal(h.daily.cacheRefreshPending, true);
assert.equal(progressText(h.rows[0]), '0/10');
h.advance(0.6); h.daily.updateCountdowns();
assert.equal(progressText(h.rows[0]), '7/10');
assert.deepEqual(Object.values(h.storage.get('dailyTaskOpenedProgress:player-a').values), [2, 360]);
assert.equal(h.tweens.length, 1);
h.daily.onDisable(); h.daily.init(updated);
assert.equal(progressText(h.rows[0]), '2/10');
});
test('closing cancels progress without starting completion animations or changing a reopened panel', () => {
const h = progressSetup(); h.data.dailyTasks[0].value = 10; h.data.dailyTasks[0].state = 1;
h.daily.init(h.data); h.advance(0.2);
const oldTween = h.tweens[0];
h.daily.node.active = false; h.daily.onDisable();
assert.equal(oldTween.cancelled, true); assert.equal(h.tweens.length, 1);
h.daily.node.active = true; h.daily.init(h.data);
const count = h.tweens.length;
oldTween.advance(10); assert.equal(h.tweens.length, count);
assert.equal(progress(h.rows[0]), 1);
h.advance(1); assertClaimable(h.rows[0], true);
});
test('snapshots separate players, dates and task configurations, and never animate progress backwards', () => {
for (const change of [
h => h.storage.set('uid', 'player-b'),
h => h.data.dayKey = '2026-09-30',
h => h.data.dailyTasks[0].params = { colorId: 3 }
]) {
const h = progressSetup(); h.daily.init(h.data); h.advance(1); h.daily.onDisable();
change(h); h.daily.init(h.data);
assert.equal(progress(h.rows[0]), 0);
}
const h = progressSetup(); h.daily.init(h.data); h.advance(1); h.daily.onDisable();
h.data.dailyTasks[0].value = 1; h.daily.init(h.data);
assert.equal(progressText(h.rows[0]), '1/10'); assert.equal(h.daily.dailyTaskProgressTween, null);
});
test('online progress keeps second precision in the bar while labels use whole minutes; storage failure is harmless', () => {
const h = progressSetup(); h.data.dailyTasks[1].value = 390;
h.cc.fx.StorageMessage.setStorage = () => { throw new Error('storage full'); };
h.daily.init(h.data);
assert.equal(progressText(h.rows[1]), '6/30');
assert.equal(progress(h.rows[1]), 390 / 1800);
assert.equal(h.daily.touchStart, true);
});
function assertClaimable(row, interactable) {
assert.equal(row.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'claimable-frame');
assert.equal(row.getChildByName('btnGet').active, true);
assert.equal(button(row).interactable, interactable);
assert.equal(row.getChildByName('desc').active, false);
assert.equal(row.getChildByName('process').active, false);
assert.equal(row.getChildByName('processLabel').active, false);
}
function assertClaimed(row) {
assert.equal(row.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'normal-frame');
assert.equal(row.getChildByName('btnGet').active, false);
assert.equal(button(row).interactable, false);
assert.equal(row.getChildByName('complete').active, true);
assert.equal(row.getChildByName('rewards').active, false);
assert.equal(row.getChildByName('desc').active, true);
assert.equal(row.getChildByName('process').active, false);
assert.equal(row.getChildByName('processLabel').active, false);
}
function claimedResponse(h, taskId = 'daily_0') {
const refreshed = structuredClone(h.data);
refreshed.dailyTasks.find(task => task.taskId === taskId).state = 2;
refreshed.weeklyStars += 6;
return { code: 1, data: { task: refreshed, rewards: [{ type: 'coin', count: 50 }] } };
}
test('all unclaimed completed tasks transition together from normal through 1.1 scale, with claims locked until settled', () => {
const h = setup(), ready = h.rows.filter((row, index) => h.data.dailyTasks[index].state === 1);
assert.deepEqual(h.tweens.map(tween => tween.target), ready);
assert.deepEqual(h.data.dailyTasks.map(task => task.state), [0, 1, 1, 2], 'display animation must not mutate server state');
assert.ok(h.tweens.every(tween => tween.steps[0].properties), 'all rows start shrinking immediately without stagger delays');
for (const row of ready) {
assert.equal(row.scale, 1);
assert.equal(row.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'normal-frame');
assert.equal(row.getChildByName('desc').active, true);
assert.equal(row.getChildByName('process').active, true);
assert.equal(row.getChildByName('processLabel').active, true);
assert.equal(row.getChildByName('btnGet').active, false);
assert.equal(button(row).interactable, false);
h.daily.getReward(null, row.name);
}
assert.equal(h.requests.length, 0);
h.advance(0.07);
assert.ok(ready.every(row => row.scale > 0 && row.scale < 1));
assert.ok(ready.every(row => row.getChildByName('background').getComponent('cc.Sprite').spriteFrame === 'normal-frame'));
h.advance(0.07);
for (const row of ready) { assert.equal(row.scale, 0); assertClaimable(row, false); }
h.advance(0.22);
for (const row of ready) { assert.equal(row.scale, 1.1); assertClaimable(row, false); h.daily.getReward(null, row.name); }
assert.equal(h.requests.length, 0, 'visible claim buttons must still reject programmatic clicks at the overshoot');
h.advance(0.06);
assert.ok(ready.every(row => row.scale > 1 && row.scale < 1.1));
h.advance(0.06);
for (const row of ready) { assert.equal(row.scale, 1); assertClaimable(row, true); }
assert.equal(h.rows[0].getChildByName('process').active, true);
assert.equal(h.rows[3].getChildByName('complete').active, true);
assert.equal(h.rows[3].getChildByName('rewards').active, false);
assert.equal(h.requests.length, 0);
});
test('a row finishing early cannot enable claims before every opening animation finishes', () => {
const h = setup([1, 1]);
h.tweens[0].advance(1);
assert.equal(button(h.rows[0]).interactable, false);
h.daily.getReward(null, h.rows[0].name);
assert.equal(h.requests.length, 0);
h.tweens[1].advance(1);
assert.ok(h.rows.every(row => button(row).interactable));
});
test('claim transitions are independent: another row remains clickable while the first shrinks and settles', () => {
const h = setup([1, 1, 2]); h.advance(1);
const [row, other] = h.rows, openingCount = h.tweens.length;
h.daily.getReward(null, row.name);
assertClaimable(row, false); assertClaimable(other, true);
h.daily.getReward(null, row.name); assert.equal(h.requests.length, 1);
const first = claimedResponse(h);
h.requests[0][1](first);
assert.equal(h.grants.length, 1); assert.equal(h.daily.touchStart, true);
h.advance(0.07); assert.ok(row.scale > 0 && row.scale < 1);
h.daily.getReward(null, other.name); assert.equal(h.requests.length, 2);
const second = structuredClone(first); second.data.task.dailyTasks[1].state = 2;
second.data.task.weeklyStars += 6;
h.requests[1][1](second);
assert.equal(h.grants.length, 2);
assert.equal(h.daily.dailyTaskStateAnimations.length, 2);
assert.equal(h.tweens[openingCount].cancelled, false);
h.tweens[openingCount].advance(1);
assertClaimed(row); assert.equal(h.daily.dailyTaskStateAnimations.length, 1);
assert.equal(h.tweens[openingCount + 1].cancelled, false);
assert.equal(other.scale, 1); assertClaimable(other, false);
h.advance(0.14); assert.equal(other.scale, 0); assertClaimed(other);
h.advance(0.22); assert.equal(other.scale, 1.1);
h.advance(0.12); assert.equal(other.scale, 1);
assert.equal(h.daily.dailyTaskStateAnimations.length, 0);
assert.equal(h.daily.reward, false);
h.requests[0][1](first); assert.equal(h.grants.length, 2);
});
test('failure unlocks only the failed row, while a slow pending request stays protected against duplicates', () => {
const h = setup([1, 1]); h.advance(1);
const openingCount = h.tweens.length;
h.daily.getReward(null, h.rows[0].name); h.daily.getReward(null, h.rows[1].name);
assert.equal(h.requests.length, 2);
h.timers[0]();
h.daily.getReward(null, h.rows[0].name); assert.equal(h.requests.length, 2);
h.requests[1][1]({ code: 0 });
assertClaimable(h.rows[0], false); assertClaimable(h.rows[1], true);
h.daily.getReward(null, h.rows[1].name); assert.equal(h.requests.length, 3);
assert.equal(h.grants.length, 0); assert.equal(h.tweens.length, openingCount);
assert.equal(h.daily.touchStart, true);
});
test('claim responses match the displayed row by task ID when the server changes task order', () => {
const h = setup([1, 1, 2]); h.advance(1);
const row = h.rows[0], other = h.rows[1], openingCount = h.tweens.length;
h.daily.getReward(null, row.name);
const response = claimedResponse(h); response.data.task.dailyTasks.reverse();
h.requests[0][1](response);
assert.equal(h.tweens.length, openingCount + 1);
assert.equal(h.tweens[openingCount].target, row);
assert.equal(row.name, 'daily_0'); assertClaimable(row, false);
h.advance(0.14); assertClaimed(row);
assert.equal(other.name, 'daily_1'); assertClaimable(other, true);
h.advance(1);
assertClaimed(h.daily.content.getChildByName('daily_0'));
assertClaimable(h.daily.content.getChildByName('daily_1'), true);
});
test('closing during a claim cancels its transition, restores layout, and prevents stale callbacks after reopening', () => {
for (const elapsed of [0.07, 0.21, 0.42]) {
const h = setup([1, 1], { scale: [0.8, 0.65] }); h.advance(1);
const [row, other] = h.rows, openingCount = h.tweens.length;
h.daily.getReward(null, row.name);
const response = claimedResponse(h); h.requests[0][1](response);
const transition = h.tweens[openingCount];
let completions = 0;
const animation = h.daily.dailyTaskStateAnimations[0];
const complete = animation.complete;
animation.complete = () => { completions++; complete(); };
h.advance(elapsed); h.daily.node.active = false; h.daily.onDisable(); h.daily.onDisable();
assert.equal(completions, 1, 'cancelled state animations consume their completion callback once');
assert.equal(transition.cancelled, true);
assert.equal(row.scaleX, 0.8); assert.equal(row.scaleY, 0.65);
assert.equal(row.getComponent('cc.Widget').enabled, true); assertClaimed(row);
assert.equal(h.daily.touchStart, true); assert.equal(h.daily.reward, false);
assert.equal(h.grants.length, 1);
assert.equal(h.tweens.length, openingCount + 1, 'closing must not replay other claimable tasks');
h.daily.node.active = true; h.daily.init(response.data.task);
assert.deepEqual(h.tweens.slice(openingCount + 1).map(tween => tween.target), [other], 'a fresh opening animates only tasks still awaiting a claim');
assertClaimed(row);
transition.advance(1);
assert.equal(completions, 1);
assert.equal(other.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'normal-frame', 'the stale callback cannot finish the new opening animation');
h.advance(1);
assertClaimed(row); assertClaimable(other, true);
assert.equal(h.grants.length, 1);
}
});
test('refreshing after a claim displays updated tasks without replaying the opening animation', () => {
const h = setup([1, 1]); h.advance(1);
const refreshed = structuredClone(h.data); refreshed.dailyTasks[0].state = 2; refreshed.weeklyStars += 6;
h.daily.init(refreshed);
assert.equal(h.tweens.length, 2);
assert.equal(h.rows[0].getChildByName('complete').active, true);
assert.equal(h.rows[0].getChildByName('rewards').active, false);
assertClaimable(h.rows[1], true);
assert.equal(h.requests.length, 0);
});
test('a refresh during the opening animation stops old tweens and restores current task presentation', () => {
const h = setup([1, 1]); h.advance(0.21);
const refreshed = structuredClone(h.data); refreshed.dailyTasks[0].state = 2;
h.daily.init(refreshed);
assert.ok(h.tweens.every(tween => tween.cancelled));
assert.equal(h.tweens.length, 2);
h.advance(1);
assert.ok(h.rows.every(row => row.scaleX === 1 && row.scaleY === 1));
assert.equal(h.rows[0].getChildByName('complete').active, true);
assertClaimable(h.rows[1], true);
});
test('closing at any stage restores scales and widget state, and reopening starts a fresh animation', () => {
for (const elapsed of [0.07, 0.21, 0.42]) {
for (const widgetEnabled of [false, true]) {
const h = setup([1], { scale: [0.8, 0.65], widgetEnabled }), row = h.rows[0], widget = row.getComponent('cc.Widget');
assert.equal(widget.enabled, false, 'alignment must not resize a shrinking row');
if (widgetEnabled) assert.ok(widget.alignments > 0, 'initial layout is resolved before scaling');
h.advance(elapsed); h.daily.node.active = false; h.daily.onDisable();
assert.equal(row.scaleX, 0.8); assert.equal(row.scaleY, 0.65);
assert.equal(widget.enabled, widgetEnabled);
assert.equal(h.tweens[0].cancelled, true);
h.daily.node.active = true; h.daily.init(h.data);
assert.equal(h.tweens.length, 2);
assert.equal(row.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'normal-frame');
h.tweens[0].advance(1);
assert.equal(row.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'normal-frame', 'cancelled animation cannot alter the reopened panel');
h.advance(1);
assert.equal(row.scaleX, 0.8); assert.equal(row.scaleY, 0.65);
assert.equal(widget.enabled, widgetEnabled); assertClaimable(row, true);
}
}
});
test('destroying an animating panel stops tweens, restores row layout, and removes listeners', () => {
const h = setup([1], { scale: [0.9, 0.7] }); h.advance(0.2);
h.daily.onDestroy(); h.daily.onDestroy(); h.advance(1);
assert.ok(h.tweens.every(tween => tween.cancelled));
assert.equal(h.rows[0].scaleX, 0.9); assert.equal(h.rows[0].scaleY, 0.7);
assert.equal(h.rows[0].getComponent('cc.Widget').enabled, true);
assert.equal(h.daily.node.listeners.length, 0);
});
test('panels with no claimable tasks do not animate, and later refreshes remain immediate until reopening', () => {
const h = setup([0, 2]);
assert.equal(h.tweens.length, 0); assert.equal(h.daily.touchStart, true);
const refreshed = structuredClone(h.data); refreshed.dailyTasks[0].state = 1;
h.daily.init(refreshed); assert.equal(h.tweens.length, 0); assertClaimable(h.rows[0], true);
h.daily.node.active = false; h.daily.onDisable(); h.daily.node.active = true; h.daily.init(refreshed);
assert.equal(h.tweens.length, 1);
});
test('invalid initialization does not consume the first valid opening animation', () => {
const h = setup([1], { initialize: false });
h.daily.init(null); h.daily.init({ version: 1 });
assert.equal(h.tweens.length, 0);
h.daily.init(h.data); assert.equal(h.tweens.length, 1);
});