feat: words editor replacement highlight (#644)

This commit is contained in:
秋澪Akimio
2026-08-07 07:01:41 +08:00
committed by GitHub
parent 21b485ca84
commit 0703a99e41
5 changed files with 423 additions and 108 deletions

View File

@@ -60,7 +60,7 @@
},
"src/ace-config.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 31
"count": 24
},
"no-useless-escape": {
"count": 4

View File

@@ -7,17 +7,22 @@ interface AceToken {
value: string
}
interface WordListSyntaxMode {
interface WordListMode {
getTokenizer: () => {
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
}
}
const WordListSyntaxMode = ace.require('ace/mode/word_list_syntax').Mode as new () => WordListSyntaxMode
const tokenizer = new WordListSyntaxMode().getTokenizer()
const WordListMode = ace.require('ace/mode/word_list').Mode as new (options?: { syntax?: boolean }) => WordListMode
const syntaxTokenizer = new WordListMode({ syntax: true }).getTokenizer()
const plainTokenizer = new WordListMode().getTokenizer()
function tokenize(line: string) {
return tokenizer.getLineTokens(line, 'start').tokens
return syntaxTokenizer.getLineTokens(line, 'start').tokens
}
function tokenizePlain(line: string) {
return plainTokenizer.getLineTokens(line, 'start').tokens
}
describe('word list syntax mode', () => {
@@ -25,14 +30,103 @@ describe('word list syntax mode', () => {
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
})
it('separates replaced and replacement fields without parsing their content', () => {
it('parses valid replacement parameters', () => {
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名.*' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 {[tmdbid=123;type=tv]}' },
{ type: 'word_list_replacement', value: '新名 ' },
{ type: 'word_list_parameter_syntax', value: '{[' },
{ type: 'word_list_parameter_key', value: 'tmdbid' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '123' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'type' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: 'tv' },
{ type: 'word_list_parameter_syntax', value: ']}' },
])
})
it('marks invalid replacement parameter keys and values', () => {
expect(tokenize('旧名 => 新名 {[unknown=1;tmdbid=abc;type=anime;g=group;s=2;e=3]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 ' },
{ type: 'word_list_parameter_syntax', value: '{[' },
{ type: 'invalid.word-list', value: 'unknown' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '1' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'tmdbid' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'invalid.word-list', value: 'abc' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'type' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'invalid.word-list', value: 'anime' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'g' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: 'group' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 's' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '2' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'e' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '3' },
{ type: 'word_list_parameter_syntax', value: ']}' },
])
})
it('marks an unknown replacement parameter key as invalid', () => {
expect(tokenize('旧名 => {[unknown=1]}')).toContainEqual({ type: 'invalid.word-list', value: 'unknown' })
})
it('marks visible syntax for missing or malformed replacement parameters', () => {
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123;broken;;]}')
.filter(token => token.type === 'invalid.word-list')
.map(token => token.value)
expect(invalidTokenValues).toEqual(['tmdbid=', '=123', 'broken;;'])
expect(tokenize('旧名 => {[]}')).toContainEqual({ type: 'invalid.word-list', value: '{[]}' })
})
it('marks the second semicolon when it creates an empty parameter', () => {
const semicolonTokens = tokenize('旧名 => {[tmdbid=1;;type=tv]}').filter(token => token.value.includes(';'))
expect(semicolonTokens).toEqual([{ type: 'invalid.word-list', value: ';;' }])
})
it('marks both sides of missing keys and values', () => {
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123]}')
.filter(token => token.type === 'invalid.word-list')
.map(token => token.value)
expect(invalidTokenValues).toEqual(['tmdbid=', '=123'])
})
it('treats an unclosed parameter block as ordinary replacement text', () => {
expect(tokenize('旧名 => 新名 {[tmdbid=1')).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 {[tmdbid=1' },
])
})
it('accepts every supported replacement parameter type', () => {
expect(
tokenize('旧名 => {[tmdbid=1;doubanid=2;bangumiid=3;anilistid=4;type=movie;g=group;s=5;e=6]}'),
).not.toContainEqual(expect.objectContaining({ type: 'invalid.word-list' }))
})
it('accepts season and episode number ranges', () => {
expect(tokenize('旧名 => {[s=1-2;e=3-5]}')).not.toContainEqual(
expect.objectContaining({ type: 'invalid.word-list' }),
)
})
it('separates front, back, and episode offset fields', () => {
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
{ type: 'word_list_front', value: '第' },
@@ -59,4 +153,24 @@ describe('word list syntax mode', () => {
])
expect(tokenize('屏蔽词')[0].type).toBe('word_list_block')
})
it('highlights comment lines', () => {
expect(tokenize('# 这是一个注释')).toEqual([{ type: 'comment.word-list', value: '# 这是一个注释' }])
})
})
describe('word list plain mode', () => {
it('does not highlight comment lines', () => {
expect(tokenizePlain('# 这是一个注释')).toEqual([{ type: 'text', value: '# 这是一个注释' }])
})
it('does not highlight word list fields', () => {
expect(tokenizePlain('旧名 => 新名 && 第 <> 集 >> 2*EP-1')).toEqual([
{ type: 'text', value: '旧名 => 新名 && 第 <> 集 >> 2*EP-1' },
])
})
it('returns no tokens for empty lines', () => {
expect(tokenizePlain('')).toEqual([])
})
})

View File

@@ -530,6 +530,234 @@ function registerJinja2Mode() {
)
}
interface WordListToken {
type: string
value: string
}
const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g
const wordListUnsignedIntegerPattern = /^\d+$/
const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/
const wordListParameterTypes = {
tmdbid: 'uint',
doubanid: 'uint',
bangumiid: 'uint',
anilistid: 'uint',
type: 'media-type',
g: 'string',
s: 'uint-or-range',
e: 'uint-or-range',
} as const
function appendWordListToken(tokens: WordListToken[], type: string, value: string) {
if (!value) return
const previousToken = tokens.at(-1)
if (previousToken?.type === type) previousToken.value += value
else tokens.push({ type, value })
}
function isValidWordListParameterValue(key: keyof typeof wordListParameterTypes, value: string) {
switch (wordListParameterTypes[key]) {
case 'uint':
return wordListUnsignedIntegerPattern.test(value)
case 'uint-or-range':
return wordListUnsignedIntegerOrRangePattern.test(value)
case 'media-type':
return value === 'movie' || value === 'tv'
case 'string':
return value.length > 0
}
}
function tokenizeWordListParameters(parameters: string): WordListToken[] {
const tokens: WordListToken[] = []
const parameterList = parameters.split(';')
parameterList.forEach((parameter, index) => {
const isEmptyParameter = parameter.length === 0
if (index > 0) {
appendWordListToken(
tokens,
parameterList[index - 1] === '' || isEmptyParameter ? 'invalid.word-list' : 'word_list_parameter_syntax',
';',
)
}
if (isEmptyParameter) return
const equalsIndex = parameter.indexOf('=')
if (equalsIndex === -1) {
appendWordListToken(tokens, 'invalid.word-list', parameter)
return
}
const key = parameter.slice(0, equalsIndex)
const value = parameter.slice(equalsIndex + 1)
const hasKey = key.length > 0
const hasValue = value.length > 0
const isKnownKey = hasKey && Object.hasOwn(wordListParameterTypes, key)
appendWordListToken(tokens, isKnownKey && hasValue ? 'word_list_parameter_key' : 'invalid.word-list', key)
appendWordListToken(tokens, hasKey && hasValue ? 'word_list_parameter_syntax' : 'invalid.word-list', '=')
const isValidValue =
isKnownKey &&
!value.includes('=') &&
isValidWordListParameterValue(key as keyof typeof wordListParameterTypes, value)
appendWordListToken(
tokens,
isValidValue || (!isKnownKey && hasKey) ? 'word_list_parameter_value' : 'invalid.word-list',
value,
)
})
return tokens
}
function tokenizeWordListReplacement(replacement: string): WordListToken[] {
const tokens: WordListToken[] = []
let replacementStart = 0
for (const match of replacement.matchAll(wordListReplacementParametersPattern)) {
const parameterStart = match.index ?? 0
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart, parameterStart))
if (!match[1]) appendWordListToken(tokens, 'invalid.word-list', match[0])
else {
appendWordListToken(tokens, 'word_list_parameter_syntax', '{[')
tokenizeWordListParameters(match[1]).forEach(token => appendWordListToken(tokens, token.type, token.value))
appendWordListToken(tokens, 'word_list_parameter_syntax', ']}')
}
replacementStart = parameterStart + match[0].length
}
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart))
return tokens
}
function splitWordListField(value: string, operator: string) {
let operatorIndex = value.indexOf(operator)
while (operatorIndex !== -1) {
const operatorEnd = operatorIndex + operator.length
if (value[operatorIndex - 1] === ' ' && value[operatorEnd] === ' ') {
let operatorStart = operatorIndex
while (value[operatorStart - 1] === ' ') operatorStart -= 1
let operatorWithSpacesEnd = operatorEnd
while (value[operatorWithSpacesEnd] === ' ') operatorWithSpacesEnd += 1
return {
before: value.slice(0, operatorStart),
operator: value.slice(operatorStart, operatorWithSpacesEnd),
after: value.slice(operatorWithSpacesEnd),
}
}
operatorIndex = value.indexOf(operator, operatorEnd)
}
}
function getWordListLineWhitespace(value: string) {
const leadingSpaceLength = value.length - value.trimStart().length
const trailingSpaceLength = value.length - value.trimEnd().length
return {
leadingSpace: value.slice(0, leadingSpaceLength),
content: value.slice(leadingSpaceLength, value.length - trailingSpaceLength),
trailingSpace: value.slice(value.length - trailingSpaceLength),
}
}
function tokenizeWordListReplacementLine(value: string): WordListToken[] {
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
const replacementField = splitWordListField(content, '=>')
if (!replacementField) return [{ type: 'text', value }]
const tokens: WordListToken[] = []
appendWordListToken(tokens, 'text', leadingSpace)
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
tokenizeWordListReplacement(replacementField.after).forEach(token =>
appendWordListToken(tokens, token.type, token.value),
)
appendWordListToken(tokens, 'text', trailingSpace)
return tokens
}
function tokenizeCombinedWordListLine(value: string): WordListToken[] {
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
const replacementField = splitWordListField(content, '=>')
const frontField = replacementField && splitWordListField(replacementField.after, '&&')
const backField = frontField && splitWordListField(frontField.after, '<>')
const offsetField = backField && splitWordListField(backField.after, '>>')
if (!replacementField || !frontField || !backField || !offsetField) return [{ type: 'text', value }]
const tokens: WordListToken[] = []
appendWordListToken(tokens, 'text', leadingSpace)
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
tokenizeWordListReplacement(frontField.before).forEach(token => appendWordListToken(tokens, token.type, token.value))
appendWordListToken(tokens, 'keyword.operator.word-list', frontField.operator)
appendWordListToken(tokens, 'word_list_front', backField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', backField.operator)
appendWordListToken(tokens, 'word_list_back', offsetField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', offsetField.operator)
appendWordListToken(tokens, 'word_list_offset', offsetField.after)
appendWordListToken(tokens, 'text', trailingSpace)
return tokens
}
function buildWordListRules(syntax: boolean) {
if (!syntax) {
return {
start: [
{
token: 'empty_line',
regex: '^$',
},
{
defaultToken: 'text',
},
],
}
}
return {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
{
token: 'text',
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
onMatch: tokenizeCombinedWordListLine,
},
{
token: 'text',
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
onMatch: tokenizeWordListReplacementLine,
},
{
token: [
'text',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_block', 'text'],
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
},
],
}
}
function registerWordListMode() {
aceModule.define?.(
'ace/mode/word_list_highlight_rules',
@@ -538,15 +766,8 @@ function registerWordListMode() {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const WordListHighlightRules = function (this: any) {
this.$rules = {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
],
}
const WordListHighlightRules = function (this: any, options?: { syntax?: boolean }) {
this.$rules = buildWordListRules(options?.syntax === true)
this.normalizeRules()
}
@@ -564,9 +785,10 @@ function registerWordListMode() {
const TextMode = require('./text').Mode
const WordListHighlightRules = require('./word_list_highlight_rules').WordListHighlightRules
const Mode = function (this: any) {
const Mode = function (this: any, options?: { syntax?: boolean }) {
TextMode.call(this)
this.HighlightRules = WordListHighlightRules
this.$highlightRuleConfig = options || {}
}
oop.inherits(Mode, TextMode)
@@ -578,90 +800,6 @@ function registerWordListMode() {
exports.Mode = Mode
},
)
aceModule.define?.(
'ace/mode/word_list_syntax_highlight_rules',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const WordListSyntaxHighlightRules = function (this: any) {
this.$rules = {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
{
token: [
'text',
'word_list_replaced',
'keyword.operator.word-list',
'word_list_replacement',
'keyword.operator.word-list',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_replaced', 'keyword.operator.word-list', 'word_list_replacement', 'text'],
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
},
{
token: [
'text',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_block', 'text'],
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
},
],
}
this.normalizeRules()
}
oop.inherits(WordListSyntaxHighlightRules, TextHighlightRules)
exports.WordListSyntaxHighlightRules = WordListSyntaxHighlightRules
},
)
aceModule.define?.(
'ace/mode/word_list_syntax',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_syntax_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const WordListSyntaxHighlightRules = require('./word_list_syntax_highlight_rules').WordListSyntaxHighlightRules
const Mode = function (this: any) {
TextMode.call(this)
this.HighlightRules = WordListSyntaxHighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/word_list_syntax'
}).call(Mode.prototype)
exports.Mode = Mode
},
)
}
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)

View File

@@ -4,6 +4,7 @@ import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { configureAceEditorPadding } from '@/utils/aceEditor'
import type { Ace } from 'ace-builds'
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
@@ -52,7 +53,27 @@ const saving = ref(false)
const showLineNumbers = ref(localStorage.getItem(WORDS_LINE_NUMBERS_STORAGE_KEY) === 'true')
const showSyntaxHighlighting = ref(localStorage.getItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY) === 'true')
const textEditorLanguage = computed(() => (showSyntaxHighlighting.value ? 'word_list_syntax' : 'word_list'))
interface WordListModeConfig {
path: 'ace/mode/word_list'
syntax: boolean
}
const aceEditor = shallowRef<Ace.Editor | null>(null)
function onAceInit(editor: Ace.Editor) {
aceEditor.value = editor
configureAceEditorPadding(editor)
applyWordListSyntax()
}
function applyWordListSyntax() {
if (!aceEditor.value) return
const mode: WordListModeConfig = {
path: 'ace/mode/word_list',
syntax: showSyntaxHighlighting.value,
}
aceEditor.value.session.setMode(mode as unknown as Ace.SyntaxMode)
}
const textEditorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
const textEditorOptions = computed(() => ({
fontSize: 13.6,
@@ -73,6 +94,8 @@ watch(showSyntaxHighlighting, value => {
localStorage.setItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY, String(value))
})
watch(showSyntaxHighlighting, applyWordListSyntax)
const savedTextValues = reactive<Record<TextSectionKey, string>>({
identifiers: '',
releaseGroups: '',
@@ -528,14 +551,14 @@ onMounted(() => {
<VAceEditor
v-if="activeSection === 'identifiers'"
v-model:value="activeTextValue"
:lang="textEditorLanguage"
lang="word_list"
:theme="textEditorTheme"
:options="textEditorOptions"
:placeholder="activeTextPlaceholder"
:print-margin="false"
wrap
class="words-text-editor"
@init="configureAceEditorPadding"
@init="onAceInit"
/>
<VTextarea
v-else
@@ -924,6 +947,9 @@ onMounted(() => {
--words-token-block: #af00db;
--words-token-replaced: #001080;
--words-token-replacement: #a31515;
--words-token-parameter-syntax: #795e26;
--words-token-parameter-key: #0451a5;
--words-token-parameter-value: #098658;
--words-token-front: #267f99;
--words-token-back: #795e26;
--words-token-offset: #098658;
@@ -943,6 +969,9 @@ onMounted(() => {
--words-token-block: #c586c0;
--words-token-replaced: #9cdcfe;
--words-token-replacement: #ce9178;
--words-token-parameter-syntax: #dcdcaa;
--words-token-parameter-key: #9cdcfe;
--words-token-parameter-value: #b5cea8;
--words-token-front: #4ec9b0;
--words-token-back: #dcdcaa;
--words-token-offset: #b5cea8;
@@ -982,6 +1011,25 @@ onMounted(() => {
color: var(--words-token-replacement);
}
.words-text-editor :deep(.ace_word_list_parameter_syntax) {
color: var(--words-token-parameter-syntax);
}
.words-text-editor :deep(.ace_word_list_parameter_key) {
color: var(--words-token-parameter-key);
}
.words-text-editor :deep(.ace_word_list_parameter_value) {
color: var(--words-token-parameter-value);
}
.words-text-editor :deep(.ace_invalid.ace_word-list) {
color: #f44336 !important;
background-color: transparent !important;
text-decoration: underline wavy #f44336 !important;
text-underline-offset: 0.12em;
}
.words-text-editor :deep(.ace_word_list_front) {
color: var(--words-token-front);
}

View File

@@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({
apiPost: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
aceSetMode: vi.fn(),
aceSetPadding: vi.fn(),
aceSetScrollMargin: vi.fn(),
}))
vi.mock('@/api', () => ({
@@ -33,6 +36,13 @@ const AceEditorStub = defineComponent({
options: { type: Object, default: () => ({}) },
value: { type: String, default: '' },
},
emits: ['init', 'update:value'],
mounted() {
this.$emit('init', {
session: { setMode: mocks.aceSetMode },
renderer: { setPadding: mocks.aceSetPadding, setScrollMargin: mocks.aceSetScrollMargin },
})
},
template: `
<div
data-testid="words-ace-editor"
@@ -61,6 +71,9 @@ describe('WordsView editor preferences', () => {
return Promise.resolve({ data: { value: ['alpha', 'beta'] } })
})
mocks.apiPost.mockResolvedValue({ success: true })
mocks.aceSetMode.mockClear()
mocks.aceSetPadding.mockClear()
mocks.aceSetScrollMargin.mockClear()
})
it('keeps line numbers disabled when no preference is stored', async () => {
@@ -74,6 +87,7 @@ describe('WordsView editor preferences', () => {
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
expect(editor).toHaveAttribute('data-lang', 'word_list')
expect(screen.getByRole('checkbox', { name: '语法高亮' })).not.toBeChecked()
expect(mocks.aceSetMode).toHaveBeenCalledWith({ path: 'ace/mode/word_list', syntax: false })
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBeNull()
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBeNull()
})
@@ -110,7 +124,8 @@ describe('WordsView editor preferences', () => {
const editor = await screen.findByTestId('words-ace-editor')
expect(screen.getByRole('checkbox', { name: '语法高亮' })).toBeChecked()
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
expect(mocks.aceSetMode).toHaveBeenCalledWith({ path: 'ace/mode/word_list', syntax: true })
expect(editor).toHaveAttribute('data-lang', 'word_list')
})
it('updates Ace options and persists the preference without changing content', async () => {
@@ -139,7 +154,7 @@ describe('WordsView editor preferences', () => {
})
})
it('switches the Ace mode and persists syntax highlighting without changing content', async () => {
it('switches word list syntax highlighting and persists the preference without changing content', async () => {
const user = userEvent.setup()
await renderWordsView()
@@ -149,7 +164,7 @@ describe('WordsView editor preferences', () => {
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
expect(mocks.aceSetMode).toHaveBeenLastCalledWith({ path: 'ace/mode/word_list', syntax: true })
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('true')
})
expect(editor).toHaveAttribute('data-value', 'alpha\nbeta')
@@ -158,7 +173,7 @@ describe('WordsView editor preferences', () => {
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-lang', 'word_list')
expect(mocks.aceSetMode).toHaveBeenLastCalledWith({ path: 'ace/mode/word_list', syntax: false })
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('false')
})
})