Skip to content

Commit 9d87e6f

Browse files
committed
chore: add PR CI, bump deploy Action versions, drop unused ts-node, test price script
- Add a pull_request-triggered CI workflow (lint/test/build) so PRs are checked before merge, including the automated price-update PRs. - Bump actions/checkout and actions/setup-node to v4 in the deploy workflow for consistency with the price-update workflow. - Remove the unused ts-node devDependency now that scripts run via tsx. - Split scripts/update-prices.ts into testable price-utils.ts and device-consts-ast.ts modules, with unit tests covering price parsing, the currency sanity check, and the ts-morph AST read/write logic.
1 parent 3c8a95a commit 9d87e6f

12 files changed

Lines changed: 459 additions & 193 deletions

.github/workflows/ci.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
6+
jobs:
7+
lint-test-build:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- name: Checkout
11+
uses: actions/checkout@v4
12+
13+
- name: Use Node.js 20.x
14+
uses: actions/setup-node@v4
15+
with:
16+
node-version: 20.x
17+
18+
- name: Install dependencies
19+
run: yarn
20+
21+
- name: Lint
22+
run: yarn nx lint
23+
24+
- name: Test
25+
run: yarn test
26+
27+
- name: Build
28+
run: yarn build

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ jobs:
1515
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
1616
steps:
1717
- name: Checkout
18-
uses: actions/checkout@v2
18+
uses: actions/checkout@v4
1919

2020
- name: Use Node.js ${{ matrix.node-version }}
21-
uses: actions/setup-node@v2
21+
uses: actions/setup-node@v4
2222
with:
2323
node-version: ${{ matrix.node-version }}
2424

.yarn/install-state.gz

466 Bytes
Binary file not shown.

jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ export default {
2222
testMatch: [
2323
'<rootDir>/src/**/__tests__/**/*.[jt]s?(x)',
2424
'<rootDir>/src/**/*(*.)@(spec|test).[jt]s?(x)',
25+
'<rootDir>/scripts/**/*(*.)@(spec|test).[jt]s?(x)',
2526
],
2627
};

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
"prettier": "^3.4.2",
6565
"ts-jest": "^29.1.0",
6666
"ts-morph": "^28.0.0",
67-
"ts-node": "10.9.1",
6867
"tsx": "^4.23.13",
6968
"typescript": "~5.5.2"
7069
},

scripts/device-consts-ast.spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Project } from 'ts-morph';
2+
3+
import {
4+
findDeviceObjectLiteral,
5+
getPriceProperty,
6+
parseCommittedPrice,
7+
} from './device-consts-ast';
8+
9+
const SAMPLE_SOURCE = `
10+
export const CCX = {
11+
key: 'ccx',
12+
other: {
13+
price: 99.99,
14+
},
15+
};
16+
17+
export const M4G = {
18+
key: 'm4g',
19+
other: {
20+
price: { type: 'number-range', value: { min: 599.99, max: 749.99 } },
21+
},
22+
};
23+
24+
export const CCU = {
25+
key: 'ccu',
26+
other: {
27+
price: 'N/A',
28+
},
29+
};
30+
`;
31+
32+
function createSourceFile() {
33+
const project = new Project({ useInMemoryFileSystem: true });
34+
return project.createSourceFile('device.consts.ts', SAMPLE_SOURCE);
35+
}
36+
37+
describe('findDeviceObjectLiteral', () => {
38+
it('finds the object literal declared with the matching key', () => {
39+
const sourceFile = createSourceFile();
40+
const deviceObject = findDeviceObjectLiteral(sourceFile, 'ccx');
41+
expect(deviceObject.getProperty('key')?.getText()).toBe("key: 'ccx'");
42+
});
43+
44+
it('throws when no device has the given key', () => {
45+
const sourceFile = createSourceFile();
46+
expect(() => findDeviceObjectLiteral(sourceFile, 'does-not-exist')).toThrow(
47+
/was not found/,
48+
);
49+
});
50+
});
51+
52+
describe('getPriceProperty / parseCommittedPrice', () => {
53+
it('parses a plain numeric price', () => {
54+
const sourceFile = createSourceFile();
55+
const property = getPriceProperty(sourceFile, 'ccx');
56+
expect(parseCommittedPrice(property)).toBe(99.99);
57+
});
58+
59+
it('parses a number-range price', () => {
60+
const sourceFile = createSourceFile();
61+
const property = getPriceProperty(sourceFile, 'm4g');
62+
expect(parseCommittedPrice(property)).toEqual({ min: 599.99, max: 749.99 });
63+
});
64+
65+
it('returns null for a placeholder like N/A', () => {
66+
const sourceFile = createSourceFile();
67+
const property = getPriceProperty(sourceFile, 'ccu');
68+
expect(parseCommittedPrice(property)).toBeNull();
69+
});
70+
71+
it('reflects a written price back after setInitializer', () => {
72+
const sourceFile = createSourceFile();
73+
const property = getPriceProperty(sourceFile, 'ccx');
74+
property.setInitializer('149.99');
75+
expect(parseCommittedPrice(property)).toBe(149.99);
76+
});
77+
});

scripts/device-consts-ast.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {
2+
Node,
3+
ObjectLiteralExpression,
4+
Project,
5+
PropertyAssignment,
6+
SourceFile,
7+
} from 'ts-morph';
8+
9+
import { PriceValue } from './price-utils';
10+
11+
export function getObjectProperty(
12+
obj: ObjectLiteralExpression,
13+
name: string,
14+
): PropertyAssignment {
15+
const prop = obj.getProperty(name);
16+
if (!prop || !Node.isPropertyAssignment(prop)) {
17+
throw new Error(`Property "${name}" was not found on object literal`);
18+
}
19+
return prop;
20+
}
21+
22+
export function findDeviceObjectLiteral(
23+
sourceFile: SourceFile,
24+
deviceKey: string,
25+
): ObjectLiteralExpression {
26+
const deviceObject = sourceFile
27+
.getVariableDeclarations()
28+
.map((declaration) => declaration.getInitializer())
29+
.find(
30+
(initializer): initializer is ObjectLiteralExpression =>
31+
Node.isObjectLiteralExpression(initializer) &&
32+
getObjectProperty(initializer, 'key').getInitializer()?.getText() ===
33+
`'${deviceKey}'`,
34+
);
35+
if (!deviceObject) {
36+
throw new Error(`Device with key "${deviceKey}" was not found`);
37+
}
38+
return deviceObject;
39+
}
40+
41+
export function getPriceProperty(
42+
sourceFile: SourceFile,
43+
deviceKey: string,
44+
): PropertyAssignment {
45+
const deviceObject = findDeviceObjectLiteral(sourceFile, deviceKey);
46+
const otherInitializer = getObjectProperty(
47+
deviceObject,
48+
'other',
49+
).getInitializer();
50+
if (!otherInitializer || !Node.isObjectLiteralExpression(otherInitializer)) {
51+
throw new Error(
52+
`"other" property on device "${deviceKey}" is not an object literal`,
53+
);
54+
}
55+
return getObjectProperty(otherInitializer, 'price');
56+
}
57+
58+
// Parses the currently-committed price back into a PriceValue, or null if
59+
// it's currently a placeholder like 'N/A' / '???' with no numeric baseline.
60+
export function parseCommittedPrice(
61+
priceProperty: PropertyAssignment,
62+
): PriceValue | null {
63+
const initializer = priceProperty.getInitializer();
64+
if (!initializer) {
65+
return null;
66+
}
67+
if (Node.isNumericLiteral(initializer)) {
68+
return initializer.getLiteralValue();
69+
}
70+
if (Node.isObjectLiteralExpression(initializer)) {
71+
const valueInitializer = getObjectProperty(
72+
initializer,
73+
'value',
74+
).getInitializer();
75+
if (valueInitializer && Node.isObjectLiteralExpression(valueInitializer)) {
76+
const min = getObjectProperty(valueInitializer, 'min').getInitializer();
77+
const max = getObjectProperty(valueInitializer, 'max').getInitializer();
78+
if (min && max && Node.isNumericLiteral(min) && Node.isNumericLiteral(max)) {
79+
return { min: min.getLiteralValue(), max: max.getLiteralValue() };
80+
}
81+
}
82+
}
83+
return null;
84+
}
85+
86+
export function loadSourceFile(filePath: string): SourceFile {
87+
const project = new Project();
88+
return project.addSourceFileAtPath(filePath);
89+
}

scripts/price-utils.spec.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import {
2+
assertPriceIsReasonable,
3+
computePrices,
4+
getVariantPrice,
5+
priceLiteralText,
6+
ShopifyProduct,
7+
} from './price-utils';
8+
9+
describe('getVariantPrice', () => {
10+
const products: ShopifyProduct[] = [
11+
{
12+
handle: 'charachorder-x',
13+
variants: [{ title: 'Default Title', price: '99.99' }],
14+
},
15+
{
16+
handle: 'master-forge-1',
17+
variants: [
18+
{ title: 'Standard', price: '599.99' },
19+
{ title: 'Premium', price: '749.99' },
20+
],
21+
},
22+
];
23+
24+
it('returns the price of the only variant when no title is given', () => {
25+
expect(getVariantPrice(products, 'charachorder-x')).toBe(99.99);
26+
});
27+
28+
it('returns the price of the matching variant title', () => {
29+
expect(getVariantPrice(products, 'master-forge-1', 'Premium')).toBe(749.99);
30+
});
31+
32+
it('throws when the product handle is not found', () => {
33+
expect(() => getVariantPrice(products, 'does-not-exist')).toThrow(
34+
/was not found/,
35+
);
36+
});
37+
38+
it('throws when the variant title is not found', () => {
39+
expect(() =>
40+
getVariantPrice(products, 'master-forge-1', 'does-not-exist'),
41+
).toThrow(/was not found/);
42+
});
43+
44+
it('throws when the price is not a valid number', () => {
45+
const badProducts: ShopifyProduct[] = [
46+
{ handle: 'broken', variants: [{ title: 'Default', price: 'n/a' }] },
47+
];
48+
expect(() => getVariantPrice(badProducts, 'broken')).toThrow(
49+
/not a valid number/,
50+
);
51+
});
52+
});
53+
54+
describe('computePrices', () => {
55+
it('computes single prices and ranges from Shopify product data', () => {
56+
const charachorderProducts: ShopifyProduct[] = [
57+
{
58+
handle: 'master-forge-1',
59+
variants: [{ title: 'Default Title', price: '599.99' }],
60+
},
61+
{
62+
handle: 'master-forge-premium',
63+
variants: [{ title: 'Default Title', price: '749.99' }],
64+
},
65+
{ handle: 'cc2', variants: [{ title: 'Default Title', price: '249.99' }] },
66+
{
67+
handle: 'charachorder-lite',
68+
variants: [{ title: 'Default Title', price: '149.99' }],
69+
},
70+
{
71+
handle: 'charachorder-x',
72+
variants: [{ title: 'Default Title', price: '99.99' }],
73+
},
74+
];
75+
const svalboardProducts: ShopifyProduct[] = [
76+
{
77+
handle: 'lightly',
78+
variants: [
79+
{ title: 'No Pointer', price: '800' },
80+
{ title: 'Dual Pointer', price: '1050' },
81+
],
82+
},
83+
];
84+
85+
expect(computePrices(charachorderProducts, svalboardProducts)).toEqual({
86+
m4g: { min: 599.99, max: 749.99 },
87+
'cc2-1': 249.99,
88+
cclite: 149.99,
89+
ccx: 99.99,
90+
sval: { min: 800, max: 1050 },
91+
});
92+
});
93+
});
94+
95+
describe('priceLiteralText', () => {
96+
it('renders a plain number', () => {
97+
expect(priceLiteralText(99.99)).toBe('99.99');
98+
});
99+
100+
it('renders a number range as a device-spec object literal', () => {
101+
expect(priceLiteralText({ min: 599.99, max: 749.99 })).toBe(
102+
"{ type: 'number-range', value: { min: 599.99, max: 749.99 } }",
103+
);
104+
});
105+
});
106+
107+
describe('assertPriceIsReasonable', () => {
108+
it('does not throw when there is no committed price to compare against', () => {
109+
expect(() =>
110+
assertPriceIsReasonable('sval', null, { min: 800, max: 1050 }),
111+
).not.toThrow();
112+
});
113+
114+
it('does not throw when the fresh price is close to the committed price', () => {
115+
expect(() =>
116+
assertPriceIsReasonable('ccx', 99.99, 104.99),
117+
).not.toThrow();
118+
});
119+
120+
it('throws when the fresh price is a currency-localized outlier', () => {
121+
expect(() => assertPriceIsReasonable('m4g', 599.99, 19263)).toThrow(
122+
/sanity check failed/,
123+
);
124+
});
125+
126+
it('throws when the fresh price is implausibly small', () => {
127+
expect(() => assertPriceIsReasonable('ccx', 99.99, 1)).toThrow(
128+
/sanity check failed/,
129+
);
130+
});
131+
132+
it('checks both ends of a price range', () => {
133+
expect(() =>
134+
assertPriceIsReasonable(
135+
'm4g',
136+
{ min: 599.99, max: 749.99 },
137+
{ min: 599.99, max: 24079 },
138+
),
139+
).toThrow(/sanity check failed/);
140+
});
141+
});

0 commit comments

Comments
 (0)