Skip to content
1,282 changes: 1,237 additions & 45 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"build_ff": "webpack --env mode=prod browser=firefox --config webpack.config.js --stats-error-details",
"start": "webpack --env mode=development browser=chrome --config webpack.config.js --stats-error-details --watch",
"start_ff": "webpack --env mode=development browser=firefox --config webpack.config.js --stats-error-details --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"format": "prettier --ignore-path .lintignore --write \"**/*.+(js|ts|json)\"",
"checkformat": "prettier --ignore-path .lintignore --check \"**/*.+(js|ts|json)\"",
Expand Down Expand Up @@ -49,7 +51,6 @@
"fast-json-stable-stringify": "^2.1.0",
"file-loader": "^6.2.0",
"file-replace-loader": "^1.4.2",
"filtrex": "^3.1.0",
"glob": "^11.0.1",
"globals": "^16.0.0",
"html-loader": "^5.1.0",
Expand All @@ -65,6 +66,7 @@
"sass-loader": "^16.0.5",
"ts-loader": "^9.5.2",
"typescript": "^5.8.3",
"vitest": "^4.1.10",
"webpack": "^5.98.0",
"webpack-cli": "^6.0.1"
},
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/filter/filter_creator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {css, html, HTMLTemplateResult, nothing} from 'lit';
import {css, html, nothing} from 'lit';
import {styleMap} from 'lit-html/directives/style-map.js';

import {state, query} from 'lit/decorators.js';
Expand Down
61 changes: 61 additions & 0 deletions src/lib/components/market/react/filter_panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {css, html, nothing, HTMLTemplateResult} from 'lit';
import {CustomElement, InjectBefore, InjectionMode} from '../../injectors';
import {FloatElement} from '../../custom';
import {isReactSteamMarket} from '../mode';

import '../../filter/filter_container';

/**
* Steam Market Beta equivalent of {@link UtilityBelt}.
*/
@CustomElement()
@InjectBefore(
'div:has(> [style*="grid-columns:repeat(auto-fill, minmax(260px"])',
InjectionMode.CONTINUOUS,
isReactSteamMarket
)
export class ReactFilterPanel extends FloatElement {
static styles = [
...FloatElement.styles,
css`
.panel {
margin-bottom: 16px;
padding: 16px;
background-color: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
color: #c7d5e0;
font-family: 'Motiva Sans', sans-serif;
}

.panel-title {
font-family: 'Motiva Sans', sans-serif;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #ebebeb;
margin: 0 0 12px;
}
`,
];

private get key(): string {
return getSteamMarketID() || '';
}

protected render(): HTMLTemplateResult | typeof nothing {
if (!this.key) return nothing;
return html`
<div class="panel">
<h3 class="panel-title">CSFloat Filters</h3>
<csfloat-filter-container .key="${this.key}"></csfloat-filter-container>
</div>
`;
}
}

/** Example: G18FD03209F033003 */
function getSteamMarketID(): string | undefined {
return location.pathname.split('/').pop();
}
Comment thread
GODrums marked this conversation as resolved.
55 changes: 55 additions & 0 deletions src/lib/components/market/react/highlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {nothing} from 'lit';
import {property} from 'lit/decorators.js';
import type {Subscription} from 'rxjs';
import {CustomElement, InjectIntoScope} from '../../injectors';
import {FloatElement} from '../../custom';
import {gFilterService} from '../../../services/filter';
import {pickTextColour} from '../../../utils/colours';
import {ReactMarketListingScope, type ReactListingContext} from './listing';

@CustomElement()
@InjectIntoScope(ReactMarketListingScope, {
anchor: ({scope}) => scope,
})
export class ReactListingHighlight extends FloatElement {
@property({attribute: false}) injectionContext?: ReactListingContext;

private filterSubscription?: Subscription;

private originalStyle: {backgroundColor: string; color: string} = {backgroundColor: '', color: ''};

connectedCallback(): void {
super.connectedCallback();
const card = this.parentElement;
if (card) {
this.originalStyle = {backgroundColor: card.style.backgroundColor, color: card.style.color};
}
this.filterSubscription = gFilterService.onUpdate$.subscribe(() => this.applyColour());
}

disconnectedCallback(): void {
super.disconnectedCallback();
this.filterSubscription?.unsubscribe();
this.filterSubscription = undefined;
}

private get convertedPrice(): number | undefined {
const listing = this.injectionContext?.listing;
if (!listing?.unPrice) return undefined;
return (listing.unPrice + listing.unFee) / 100;
}

private applyColour(): void {
const card = this.parentElement;
const context = this.injectionContext;
if (!card || !context) return;

const colour = gFilterService.matchColour(context.itemInfo, this.convertedPrice);
card.style.backgroundColor = colour ?? this.originalStyle.backgroundColor;
card.style.color = colour ? pickTextColour(colour, '#8F98A0', '#484848') : this.originalStyle.color;
}

protected render() {
return nothing;
}
}
135 changes: 135 additions & 0 deletions src/lib/filter/compile_expression.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {describe, expect, it} from 'vitest';
import {compileExpression, type FilterValue} from './compile_expression';
import type {InternalInputVars} from './types';

const VARS: InternalInputVars = {
float: 0.2,
seed: 555,
minfloat: 0,
maxfloat: 1,
minwearfloat: 0,
maxwearfloat: 1,
phase: 'Ruby',
low_rank: 10,
high_rank: 90,
price: 100,
pattern: 387,
};

/** Compile and run in one step against {@link VARS} (or an override). */
function run(expression: string, vars: Partial<InternalInputVars> = {}): FilterValue {
return compileExpression(expression)({...VARS, ...vars});
}

describe('literals and variables', () => {
it('reads numbers, strings, and booleans', () => {
expect(run('42')).toBe(42);
expect(run('.5')).toBe(0.5);
expect(run('1e3')).toBe(1000);
expect(run("'hello'")).toBe('hello');
expect(run('true')).toBe(true);
expect(run('false')).toBe(false);
});

it('resolves variables', () => {
expect(run('float')).toBe(0.2);
expect(run('phase')).toBe('Ruby');
});

it('returns an error for unknown/undefined variables', () => {
expect(run('nope')).toBeInstanceOf(Error);
expect(run('price', {price: undefined})).toBeInstanceOf(Error);
});
});

describe('arithmetic', () => {
it('respects precedence and associativity', () => {
expect(run('1 + 2 * 3')).toBe(7);
expect(run('(1 + 2) * 3')).toBe(9);
expect(run('2 ^ 3 ^ 2')).toBe(512); // right-associative
expect(run('-2 ^ 2')).toBe(4); // unary binds tighter than ^
expect(run('7 % 3')).toBe(1);
});

it('overloads + for string concatenation', () => {
expect(run("'a' + 'b'")).toBe('ab');
expect(run("'x' + 1")).toBe('x1');
});

it('rejects arithmetic on non-numbers', () => {
expect(run("'a' - 1")).toBeInstanceOf(Error);
});
});

describe('comparisons and equality', () => {
it('compares numbers and strings', () => {
expect(run('float < 0.5')).toBe(true);
expect(run('float >= 0.2')).toBe(true);
expect(run("'a' < 'b'")).toBe(true);
});

it('uses strict equality with no coercion', () => {
expect(run('5 == 5')).toBe(true);
expect(run("5 == '5'")).toBe(false);
expect(run('5 != 6')).toBe(true);
expect(run('5 = 5')).toBe(true); // = is an alias for ==
});

it('errors when comparing across types', () => {
expect(run("1 < 'a'")).toBeInstanceOf(Error);
});
});

describe('logical operators', () => {
it('evaluates and/or/not', () => {
expect(run('true and false')).toBe(false);
expect(run('true or false')).toBe(true);
expect(run('not false')).toBe(true);
});

it('short-circuits so the right side is not required to be valid', () => {
expect(run('false and nope')).toBe(false);
expect(run('true or nope')).toBe(true);
});
});

describe('membership', () => {
it('handles in and not in', () => {
expect(run('seed in (1, 555, 999)')).toBe(true);
expect(run('seed not in (1, 2, 3)')).toBe(true);
expect(run("phase in ('Emerald', 'Ruby')")).toBe(true);
});

it('rejects an empty list', () => {
expect(run('seed in ()')).toBeInstanceOf(Error);
});
});

describe('functions', () => {
it('supports built-in math functions', () => {
expect(run('abs(-3)')).toBe(3);
expect(run('max(1, 5, 2)')).toBe(5);
expect(run('round(1.6)')).toBe(2);
});

it('supports injected extra functions', () => {
const runner = compileExpression('double(seed)', {
extraFunctions: {double: (n: number) => n * 2},
});
expect(runner(VARS)).toBe(1110);
});

it('errors on unknown functions', () => {
expect(run('bogus(1)')).toBeInstanceOf(Error);
});
});

describe('error handling', () => {
it('never throws, returning an Error for malformed input', () => {
expect(() => compileExpression('1 +')).not.toThrow();
expect(run('1 +')).toBeInstanceOf(Error);
expect(run('(1 + 2')).toBeInstanceOf(Error);
expect(run("'unterminated")).toBeInstanceOf(Error);
expect(run('1 @ 2')).toBeInstanceOf(Error);
});
});
Loading