|
1 | | -import { SOLAR_PRICE_CHANGES, solarOfferAt } from '@codebuff/common/constants/freebuff-solar-promo' |
| 1 | +import { getFreebucksInfo } from '@codebuff/common/types/freebuff-session' |
| 2 | +import { |
| 3 | + SOLAR_PRICE_CHANGES, |
| 4 | + solarOfferAt, |
| 5 | +} from '@codebuff/common/constants/freebuff-solar-promo' |
2 | 6 | import { |
3 | 7 | toLandingSession, |
4 | 8 | resolveFreebuffModelPickForSession, |
@@ -73,7 +77,7 @@ afterEach(() => { |
73 | 77 |
|
74 | 78 | const renderSelector = async ( |
75 | 79 | maxHeight = 40, |
76 | | - startSession?: (model: string) => Promise<void>, |
| 80 | + startSession?: (model: string, limit?: number | 'session') => Promise<void>, |
77 | 81 | ) => { |
78 | 82 | // Tear down any selector this test already rendered. Only the LAST one was |
79 | 83 | // reachable from afterEach, so a test that renders twice used to leave the |
@@ -1118,8 +1122,12 @@ test('an open Solar CLI picker leaves the holiday price at the cutoff and submit |
1118 | 1122 | [FREEBUFF_GLM_V53_FLASH_MODEL_ID]: 5, |
1119 | 1123 | [FREEBUFF_SOLAR_PRO_4_MODEL_ID]: 0, |
1120 | 1124 | }), |
1121 | | - priceNotices: { [FREEBUFF_SOLAR_PRO_4_MODEL_ID]: solarOfferAt(cutoff - 137).tagline }, |
1122 | | - priceChanges: SOLAR_PRICE_CHANGES.filter((change) => Date.parse(change.at) >= cutoff), |
| 1125 | + priceNotices: { |
| 1126 | + [FREEBUFF_SOLAR_PRO_4_MODEL_ID]: solarOfferAt(cutoff - 137).tagline, |
| 1127 | + }, |
| 1128 | + priceChanges: SOLAR_PRICE_CHANGES.filter( |
| 1129 | + (change) => Date.parse(change.at) >= cutoff, |
| 1130 | + ), |
1123 | 1131 | }, |
1124 | 1132 | }) |
1125 | 1133 | useFreebuffModelStore |
@@ -1155,7 +1163,9 @@ test('an open Solar CLI picker leaves the holiday price at the cutoff and submit |
1155 | 1163 | await setup.renderOnce() |
1156 | 1164 | expect(setup.captureCharFrame()).not.toContain('Labor Day weekend') |
1157 | 1165 | expect(setup.captureCharFrame()).toContain('Solar Pro 4') |
1158 | | - expect(setup.captureCharFrame()).toMatch(/Solar Pro 4[^\n]*\n[^\n]*5 Freebucks\/hr/) |
| 1166 | + expect(setup.captureCharFrame()).toMatch( |
| 1167 | + /Solar Pro 4[^\n]*\n[^\n]*5 Freebucks\/hr/, |
| 1168 | + ) |
1159 | 1169 | await setup.mockInput.pressEnter() |
1160 | 1170 | await setup.renderOnce() |
1161 | 1171 | expect(requested).toEqual([FREEBUFF_SOLAR_PRO_4_MODEL_ID]) |
@@ -1230,3 +1240,166 @@ describe('FreebuffModelSelector limited upgrade CTA', () => { |
1230 | 1240 | ).not.toContain('usage for $') |
1231 | 1241 | }) |
1232 | 1242 | }) |
| 1243 | + |
| 1244 | +describe('unavailable balances in the mounted CLI picker', () => { |
| 1245 | + test.each(['full', 'limited'] as const)( |
| 1246 | + '%s: fresh admission requires confirmation and ignores exhausted legacy quotas', |
| 1247 | + async (accessTier) => { |
| 1248 | + const id = FREEBUFF_GLM_V53_FLASH_MODEL_ID |
| 1249 | + const pending = { |
| 1250 | + status: 'none' as const, |
| 1251 | + accessTier, |
| 1252 | + freebucks: null, |
| 1253 | + rateLimitsByModel: { |
| 1254 | + [id]: { |
| 1255 | + model: id, |
| 1256 | + limit: 0, |
| 1257 | + recentCount: 0, |
| 1258 | + period: 'pacific_day' as const, |
| 1259 | + resetTimeZone: 'America/Los_Angeles', |
| 1260 | + resetAt: '2027-01-01', |
| 1261 | + windowHours: 24, |
| 1262 | + }, |
| 1263 | + }, |
| 1264 | + } |
| 1265 | + useFreebuffSessionStore.getState().setSession(pending) |
| 1266 | + useFreebuffModelStore.getState().setSelectedModel(id) |
| 1267 | + const requests: string[] = [] |
| 1268 | + const limits: (number | 'session' | undefined)[] = [] |
| 1269 | + const setup = await renderSelector(40, async (model, limit) => { |
| 1270 | + limits.push(limit) |
| 1271 | + requests.push( |
| 1272 | + resolveFreebuffModelPickForSession( |
| 1273 | + model, |
| 1274 | + useFreebuffSessionStore.getState().session, |
| 1275 | + ), |
| 1276 | + ) |
| 1277 | + }) |
| 1278 | + expect(getSelectedFreebuffModel()).toBe(id) |
| 1279 | + expect(setup.captureCharFrame()).toContain( |
| 1280 | + 'balance temporarily unavailable', |
| 1281 | + ) |
| 1282 | + expect(setup.captureCharFrame()).not.toContain('0 of 0') |
| 1283 | + flushSync(() => setup.mockInput.pressEnter()) |
| 1284 | + await setup.renderOnce() |
| 1285 | + expect(requests).toEqual([]) |
| 1286 | + expect(setup.captureCharFrame()).toContain('Balance unavailable') |
| 1287 | + flushSync(() => setup.mockInput.pressEnter()) |
| 1288 | + await setup.renderOnce() |
| 1289 | + expect(requests).toEqual([id]) |
| 1290 | + // A poll recovers the open control without remounting or changing its model. |
| 1291 | + useFreebuffSessionStore |
| 1292 | + .getState() |
| 1293 | + .setSession({ ...pending, freebucks: freebucksFixture(5) }) |
| 1294 | + await setup.renderOnce() |
| 1295 | + expect(setup.captureCharFrame()).not.toContain('unavailable') |
| 1296 | + expect(setup.captureCharFrame()).toContain('5 Freebucks/hr') |
| 1297 | + flushSync(() => setup.mockInput.pressEnter()) |
| 1298 | + await setup.renderOnce() |
| 1299 | + expect(requests).toEqual([id, id]) |
| 1300 | + const known = freebucksFixture(5) |
| 1301 | + useFreebuffSessionStore.getState().setSession({ |
| 1302 | + ...pending, |
| 1303 | + freebucks: { |
| 1304 | + ...known, |
| 1305 | + daily: { ...known.daily, remaining: 0 }, |
| 1306 | + wallet: { ...known.wallet, balance: 5 }, |
| 1307 | + }, |
| 1308 | + }) |
| 1309 | + await setup.renderOnce() |
| 1310 | + flushSync(() => setup.mockInput.pressEnter()) |
| 1311 | + await setup.renderOnce() |
| 1312 | + expect(requests).toEqual([id, id]) |
| 1313 | + expect(setup.captureCharFrame()).toContain( |
| 1314 | + 'Enter uses 5 from your wallet', |
| 1315 | + ) |
| 1316 | + flushSync(() => setup.mockInput.pressEnter()) |
| 1317 | + await setup.renderOnce() |
| 1318 | + expect(requests).toEqual([id, id, id]) |
| 1319 | + expect(limits).toEqual(['session', undefined, 5]) |
| 1320 | + }, |
| 1321 | + ) |
| 1322 | + |
| 1323 | + test.each(['full', 'limited'] as const)( |
| 1324 | + '%s: paid reuse is accessible until expiry, then asks before admission', |
| 1325 | + async (accessTier) => { |
| 1326 | + const id = FREEBUFF_GLM_V53_FLASH_MODEL_ID |
| 1327 | + const live = { |
| 1328 | + status: 'active' as const, |
| 1329 | + accessTier, |
| 1330 | + model: id, |
| 1331 | + instanceId: 'paid-picker', |
| 1332 | + admittedAt: new Date(FIXED_NOW_MS - 30_000).toISOString(), |
| 1333 | + remainingMs: 30_000, |
| 1334 | + expiresAt: new Date(FIXED_NOW_MS + 30_000).toISOString(), |
| 1335 | + freebucks: null, |
| 1336 | + } |
| 1337 | + useFreebuffSessionStore.getState().setSession(live) |
| 1338 | + useFreebuffModelStore.getState().setSelectedModel(id) |
| 1339 | + const requests: string[] = [] |
| 1340 | + const setup = await renderSelector(40, async (model) => { |
| 1341 | + requests.push(model) |
| 1342 | + }) |
| 1343 | + flushSync(() => setup.mockInput.pressEnter()) |
| 1344 | + await setup.renderOnce() |
| 1345 | + expect(requests).toEqual([id]) |
| 1346 | + // Even a known zero balance cannot hide a paid reuse. |
| 1347 | + useFreebuffSessionStore |
| 1348 | + .getState() |
| 1349 | + .setSession({ ...live, freebucks: freebucksFixture(0) }) |
| 1350 | + await setup.renderOnce() |
| 1351 | + flushSync(() => setup.mockInput.pressEnter()) |
| 1352 | + await setup.renderOnce() |
| 1353 | + expect(requests).toEqual([id, id]) |
| 1354 | + useFreebuffSessionStore.getState().setSession({ |
| 1355 | + ...live, |
| 1356 | + expiresAt: new Date(FIXED_NOW_MS).toISOString(), |
| 1357 | + }) |
| 1358 | + await setup.renderOnce() |
| 1359 | + flushSync(() => setup.mockInput.pressEnter()) |
| 1360 | + await setup.renderOnce() |
| 1361 | + expect(requests).toHaveLength(2) |
| 1362 | + expect(setup.captureCharFrame()).toContain('Balance unavailable') |
| 1363 | + flushSync(() => setup.mockInput.pressEnter()) |
| 1364 | + await setup.renderOnce() |
| 1365 | + expect(requests).toEqual([id, id, id]) |
| 1366 | + }, |
| 1367 | + ) |
| 1368 | + |
| 1369 | + test('returning to the landing picker preserves null rather than reviving the legacy meter', () => { |
| 1370 | + expect( |
| 1371 | + toLandingSession({ status: 'ended', freebucks: null }).freebucks, |
| 1372 | + ).toBeNull() |
| 1373 | + expect(toLandingSession({ status: 'ended' }).freebucks).toBeUndefined() |
| 1374 | + }) |
| 1375 | +}) |
| 1376 | + |
| 1377 | + |
| 1378 | +test.each(['full', 'limited'] as const)( |
| 1379 | + '%s earned grants offer confirmation without inflating the CLI balance', |
| 1380 | + async (accessTier) => { |
| 1381 | + const id = FREEBUFF_GLM_V53_FLASH_MODEL_ID |
| 1382 | + useFreebuffSessionStore |
| 1383 | + .getState() |
| 1384 | + .setSession({ |
| 1385 | + status: 'none', |
| 1386 | + accessTier, |
| 1387 | + freebucks: { ...freebucksFixture(0), claimableGrantFreebucks: 15 }, |
| 1388 | + }) |
| 1389 | + useFreebuffModelStore.getState().setSelectedModel(id) |
| 1390 | + const picked: string[] = [] |
| 1391 | + const setup = await renderSelector(40, async (model) => { |
| 1392 | + picked.push(model) |
| 1393 | + }) |
| 1394 | + flushSync(() => setup.mockInput.pressEnter()) |
| 1395 | + await setup.renderOnce() |
| 1396 | + expect(picked).toEqual([]) |
| 1397 | + expect(setup.captureCharFrame()).toContain('Claim earned Freebucks') |
| 1398 | + expect(getFreebucksInfo(useFreebuffSessionStore.getState().session!)?.balance).toBe( |
| 1399 | + 0, |
| 1400 | + ) |
| 1401 | + flushSync(() => setup.mockInput.pressEnter()) |
| 1402 | + await setup.renderOnce() |
| 1403 | + expect(picked).toEqual([id]) |
| 1404 | + }, |
| 1405 | +) |
0 commit comments